From 9327b6120fca451e2dd3698e7fd9ef2273610ebf Mon Sep 17 00:00:00 2001 From: Raktbastr Date: Sat, 14 Feb 2026 13:35:40 -0600 Subject: [PATCH 1/4] Added ability to add custom teams and events. --- android/app/build.gradle.kts | 2 + lib/eventadder.dart | 136 ++++++++++++++++++++++ lib/eventpicker.dart | 97 ++++++++++++++-- lib/login.dart | 208 +++++++++++++++++----------------- lib/notespage.dart | 211 +++++++++++++++++++++++------------ lib/teamadder.dart | 147 ++++++++++++++++++++++++ lib/teampicker.dart | 73 +++++++++++- pubspec.yaml | 2 +- 8 files changed, 687 insertions(+), 189 deletions(-) create mode 100644 lib/eventadder.dart create mode 100644 lib/teamadder.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2391af5..84cb48d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -28,6 +28,8 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + val fileName = "${rootProject.name}_v${versionName}_${versionCode}" + setProperty("archivesBaseName", fileName) } buildTypes { diff --git a/lib/eventadder.dart b/lib/eventadder.dart new file mode 100644 index 0000000..7563e24 --- /dev/null +++ b/lib/eventadder.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class EventAdder extends StatefulWidget { + const EventAdder({super.key}); + + @override + State createState() => _EventAdderState(); +} + +class _EventAdderState extends State { + final _customEventNameController = TextEditingController(); + final _customEventCodeController = TextEditingController(); + List customEventNames = []; + List customEventCodes = []; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _loadEvents(); + } + + Future _loadEvents() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + setState(() { + customEventNames = prefs.getStringList('custom_event_names') ?? []; + customEventCodes = prefs.getStringList('custom_event_codes') ?? []; + }); + } + + Future _saveEvent() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String eventName = _customEventNameController.text; + String eventCode; + + if (_customEventCodeController.text.isEmpty) { + eventCode = '${DateTime.now().year.toString()}${eventName.replaceAll(' ', '').substring(0,4)}'; + } else { + eventCode = _customEventCodeController.text; + } + + customEventNames.add(eventName); + customEventCodes.add(eventCode); + + await prefs.setStringList('custom_event_names', customEventNames); + await prefs.setStringList('custom_event_codes', customEventCodes); + + _customEventNameController.clear(); + _customEventCodeController.clear(); + setState(() {}); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added Event: $eventName - $eventCode'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16.0), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Add Event')), + body: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text('Add Custom Event', style: Theme.of(context).textTheme.titleLarge) + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + children: [ + const SizedBox(height: 16), + TextFormField( + controller: _customEventNameController, + decoration: const InputDecoration( + labelText: 'Event Name', + hintText: 'gm_construct Regional', + border: OutlineInputBorder() + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a event name'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _customEventCodeController, + decoration: const InputDecoration( + labelText: 'Event Code', + hintText: '2026gmct', + border: OutlineInputBorder() + ) + ), + Text('Leave this field EMPTY unless you NEED a custom code.'), + const SizedBox(height: 16.0), + ElevatedButton( + onPressed: () { _saveEvent(); }, + child: const Text('Create Event'), + ), + ], + ) + ), + Divider(), + Text('Custom Events', style: Theme.of(context).textTheme.titleLarge), + ListView.builder( + shrinkWrap: true, + itemCount: customEventNames.length, + itemBuilder: (context, index) { + return ListTile( + title: Text('${customEventNames[index]} - ${customEventCodes[index]}') + ); + } + ) + ], + ) + ) + ); + } +} \ No newline at end of file diff --git a/lib/eventpicker.dart b/lib/eventpicker.dart index b60e616..6b4acda 100644 --- a/lib/eventpicker.dart +++ b/lib/eventpicker.dart @@ -1,30 +1,107 @@ import 'package:flutter/material.dart'; +import 'package:laserscouter/eventadder.dart'; import 'teampicker.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + + +class EventPicker extends StatefulWidget { + const EventPicker({super.key, required this.eventNames, required this.eventCodes}); -class EventPicker extends StatelessWidget { final List eventNames; final List eventCodes; - const EventPicker({super.key, required this.eventNames, required this.eventCodes}); + @override + State createState() => _EventPickerState(); +} + +class _EventPickerState extends State { + List eventNames = []; + List eventCodes = []; + List isCustom = []; + + @override + void initState() { + super.initState(); + eventNames = List.from(widget.eventNames); + eventCodes = List.from(widget.eventCodes); + isCustom = List.filled(widget.eventCodes.length, false); + _refreshEvents(); + } + + Future _refreshEvents() async { + eventNames = widget.eventNames.toList(); + eventCodes = widget.eventCodes.toList(); + isCustom = List.filled(widget.eventCodes.length, false).toList(); + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final storedNames = prefs.getStringList('custom_event_names') ?? []; + final storedCodes = prefs.getStringList('custom_event_codes') ?? []; + + for (int i = 0; i < storedNames.length; i++) { + if (!eventCodes.contains(storedCodes[i])) { + eventNames.add(storedNames[i]); + eventCodes.add(storedCodes[i]); + isCustom.add(true); + } + } + if (mounted) { + setState(() {}); + } + } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Event')), + appBar: AppBar( + title: const Text('Event'), + actions: [ + IconButton( + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EventAdder(), + ), + ); + _refreshEvents(); + }, + icon: Icon(Icons.add), + ) + ] + ), body: ListView.builder( itemCount: eventNames.length, itemBuilder: (context, index) { return ListTile( title: Text(eventNames[index]), onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TeamPicker( - eventCode: eventCodes[index], + bool isEventCustom = isCustom[index]; + String eventCode = eventCodes[index]; + String eventName = eventCodes[index]; + + if (isEventCustom) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TeamPicker( + eventCode: eventCode, + eventName: eventName, + isCustomOnly: isEventCustom, + ), ), - ), - ); + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TeamPicker( + isCustomOnly: isEventCustom, + eventName: eventName, + eventCode: eventCode, + ), + ), + ); + } }, ); }, diff --git a/lib/login.dart b/lib/login.dart index a31463d..181e6a5 100644 --- a/lib/login.dart +++ b/lib/login.dart @@ -67,112 +67,116 @@ class _LoginPageState extends State { appBar: AppBar( title: const Text('Login'), ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset('assets/main.png', height: 75, alignment: Alignment.center), - const SizedBox(height: 16.0), - TextField( - controller: _teamNumberController, - decoration: const InputDecoration( - labelText: 'Team Number', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16.0), - ElevatedButton( - onPressed: _isLoading - ? null - : () async { - final navigator = Navigator.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - - setState(() { - _isLoading = true; - }); - - try { - await _saveData(); - String teamNumber = _teamNumberController.text; - final result = await teamSearch(teamNumber); - navigator.push( - MaterialPageRoute( - builder: (context) => EventPicker( - eventNames: result.eventNames, - eventCodes: result.eventCodes, - ), - ), - ); - } catch (e) { - scaffoldMessenger.showSnackBar( - SnackBar(content: Text('Error: ${e.toString()}')), - ); - } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } - } - }, - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2.0, - color: Colors.white, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const Spacer(), + Image.asset('assets/main.png', height: 75, alignment: Alignment.center), + const SizedBox(height: 16.0), + TextField( + controller: _teamNumberController, + decoration: const InputDecoration( + labelText: 'Team Number', + border: OutlineInputBorder(), ), - ) - : const Text('Login'), - ) - ], - ), - ), - bottomNavigationBar: Padding ( - padding: const EdgeInsets.all(50.0), - child: Row ( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - child: const Text('Info'), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Info'), - content: const Text( - "This app makes use of The Blue Alliance APIv3 through Laser Proxy. No API keys are stored on device. Laser Scouter was created by FRC 2077 Laser Robotics. \n\nVersion: Rebuilt 26.1.23" + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16.0), + ElevatedButton( + onPressed: _isLoading + ? null + : () async { + final navigator = Navigator.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(context); + + setState(() { + _isLoading = true; + }); + + try { + await _saveData(); + String teamNumber = _teamNumberController.text; + final result = await teamSearch(teamNumber); + navigator.push( + MaterialPageRoute( + builder: (context) => EventPicker( + eventNames: result.eventNames, + eventCodes: result.eventCodes, ), - ); + ), + ); + } catch (e) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text('Error: ${e.toString()}'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16.0), + ), + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); } - ); - }, - ), - TextButton( - child: const Text('Github'), - onPressed: () { - _launchUrl(Uri.parse('https://github.com/raktbastr/laserscouter')); - } - ), - TextButton( - onPressed: _isLoading - ? null - : () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsPage(), + } + }, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2.0, + color: Colors.white, + ), + ) + : const Text('Login'), + ), + const Spacer(), + Row (mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + child: const Text('Info'), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Info'), + content: const Text( + "This app makes use of The Blue Alliance APIv3 through Laser Proxy. No API keys are stored on device. Laser Scouter was created by FRC 2077 Laser Robotics. \n\nVersion: Rebuilt 26.2.14}" + ), + ); + } + ); + }, + ), + TextButton( + child: const Text('Github'), + onPressed: () { + _launchUrl(Uri.parse('https://github.com/raktbastr/laserscouter')); + } + ), + TextButton( + onPressed: _isLoading + ? null + : () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsPage(), + ) + ); + }, + child: const Text('Settings'), ) - ); - }, - child: const Text('Settings'), - ) - ] - ) + ] + ), + ], + ), + ), ) ); } diff --git a/lib/notespage.dart b/lib/notespage.dart index f7de9c3..ee4122f 100644 --- a/lib/notespage.dart +++ b/lib/notespage.dart @@ -20,19 +20,22 @@ class NotesPage extends StatefulWidget { } class _NotesPageState extends State { - late SharedPreferences _prefs; + late SharedPreferences prefs; ScoutingView _selectedView = ScoutingView.match; bool _isLoading = true; - final _autonRundownController = TextEditingController(); final _botPositionController = TextEditingController(); final _generalObservationsController = TextEditingController(); - - final _driveTrainTypeController = TextEditingController(); - bool _hasVision = false; - double _climbLevel = 100.0; - bool _trenchable = false; + final _autonRundownController = TextEditingController(); + final _intakePositionController = TextEditingController(); + final _scoreMechanisimController = TextEditingController(); + double _fuelPerCycle = 0; + bool _canDriveUnderTrench = false; + bool _canDriveOverBump = false; + double _climbLevel = 0.0; double _fuelCapacity = 0.0; + bool _canGiveToHumanPlayer = false; + double _cycleTime = 0.0; @override void initState() { @@ -44,8 +47,9 @@ class _NotesPageState extends State { void dispose() { _autonRundownController.dispose(); _botPositionController.dispose(); - _driveTrainTypeController.dispose(); _generalObservationsController.dispose(); + _intakePositionController.dispose(); + _scoreMechanisimController.dispose(); super.dispose(); } @@ -54,21 +58,33 @@ class _NotesPageState extends State { } Future _loadNotes() async { - _prefs = await SharedPreferences.getInstance(); + SharedPreferences prefs = await SharedPreferences.getInstance(); - _autonRundownController.text = _prefs.getString(_generateKey('autonRundown')) ?? ''; - _botPositionController.text = _prefs.getString(_generateKey('botPosition')) ?? ''; - _generalObservationsController.text = _prefs.getString(_generateKey('generalObservations')) ?? ''; - _driveTrainTypeController.text = _prefs.getString(_generateKey('driveTrainType')) ?? ''; - _hasVision = _prefs.getBool(_generateKey('hasVision')) ?? false; - _climbLevel = _prefs.getDouble(_generateKey('climbLevel')) ?? 0.0; - _trenchable = _prefs.getBool(_generateKey('trenchable')) ?? false; - _fuelCapacity = _prefs.getDouble(_generateKey('fuelCapacity')) ?? 0.0; - _autonRundownController.addListener(() => _saveString('autonRundown', _autonRundownController.text)); + _botPositionController.text = prefs.getString(_generateKey('botPosition')) ?? ''; _botPositionController.addListener(() => _saveString('botPosition', _botPositionController.text)); - _driveTrainTypeController.addListener(() => _saveString('driveTrainType', _driveTrainTypeController.text)); + + _generalObservationsController.text = prefs.getString(_generateKey('generalObservations')) ?? ''; _generalObservationsController.addListener(() => _saveString('generalObservations', _generalObservationsController.text)); + _autonRundownController.text = prefs.getString(_generateKey('autonRundown')) ?? ''; + _autonRundownController.addListener(() => _saveString('autonRundown', _autonRundownController.text)); + + _intakePositionController.text = prefs.getString(_generateKey('intakePosition')) ?? ''; + _intakePositionController.addListener(() => _saveString('intakePosition', _intakePositionController.text)); + + _scoreMechanisimController.text = prefs.getString(_generateKey('scoreMechanism')) ?? ''; + _scoreMechanisimController.addListener(() => _saveString('scoreMechanism', _scoreMechanisimController.text)); + + _fuelPerCycle = prefs.getDouble(_generateKey('fuelPerCycle')) ?? 0.0; + _canDriveUnderTrench = prefs.getBool(_generateKey('canDriveUnderTrench')) ?? false; + _canDriveOverBump = prefs.getBool(_generateKey('canDriveOverBump')) ?? false; + _climbLevel = prefs.getDouble(_generateKey('climbLevel')) ?? 0.0; + _fuelCapacity = prefs.getDouble(_generateKey('fuelCapacity')) ?? 0.0; + _canGiveToHumanPlayer = prefs.getBool(_generateKey('canGiveToHumanPlayer')) ?? false; + _cycleTime = prefs.getDouble(_generateKey('cycleTime')) ?? 0.0; + + + if (mounted) { setState(() { _isLoading = false; @@ -77,15 +93,15 @@ class _NotesPageState extends State { } Future _saveString(String field, String value) async { - await _prefs.setString(_generateKey(field), value); + await prefs.setString(_generateKey(field), value); } Future _saveBool(String field, bool value) async { - await _prefs.setBool(_generateKey(field), value); + await prefs.setBool(_generateKey(field), value); } Future _saveDouble(String field, double value) async { - await _prefs.setDouble(_generateKey(field), value); + await prefs.setDouble(_generateKey(field), value); } @override @@ -96,37 +112,39 @@ class _NotesPageState extends State { ), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Text(widget.teamName, style: Theme.of(context).textTheme.titleLarge) + : SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text(widget.teamName, style: Theme.of(context).textTheme.titleLarge) + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: SegmentedButton( + segments: const [ + ButtonSegment(value: ScoutingView.match, label: Text('Match Scouting')), + ButtonSegment(value: ScoutingView.pit, label: Text('Pit Scouting')), + ], + selected: {_selectedView}, + onSelectionChanged: (newSelection) { + setState(() { + _selectedView = newSelection.first; + }); + }, + ), + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _selectedView == ScoutingView.match + ? _buildMatchScoutingView() + : _buildPitScoutingView(), + ), + ), + ], ), - Padding( - padding: const EdgeInsets.all(16.0), - child: SegmentedButton( - segments: const [ - ButtonSegment(value: ScoutingView.match, label: Text('Match Scouting')), - ButtonSegment(value: ScoutingView.pit, label: Text('Pit Scouting')), - ], - selected: {_selectedView}, - onSelectionChanged: (newSelection) { - setState(() { - _selectedView = newSelection.first; - }); - }, - ), - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: _selectedView == ScoutingView.match - ? _buildMatchScoutingView() - : _buildPitScoutingView(), - ), - ), - ], - ), + ) ); } @@ -146,15 +164,6 @@ class _NotesPageState extends State { ), ), const SizedBox(height: 16), - TextField( - controller: _autonRundownController, - decoration: const InputDecoration( - labelText: 'Autonomous Rundown', - hintText: 'Describe their auto', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), TextField( controller: _generalObservationsController, decoration: const InputDecoration( @@ -175,32 +184,42 @@ class _NotesPageState extends State { Text('Robot Technical Details', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 16), TextField( - controller: _driveTrainTypeController, + controller: _autonRundownController, decoration: const InputDecoration( - labelText: 'Drivetrain Type', - hintText: 'e.g., Swerve, Tank', + labelText: 'Autonomous Rundown', + hintText: 'Describe their auto', border: OutlineInputBorder(), ), ), const SizedBox(height: 16), SwitchListTile( - title: Text('Has Vision/AprilTags', style: Theme.of(context).textTheme.titleSmall), - value: _hasVision, + title: Text('Can Drive Over Bump', style: Theme.of(context).textTheme.titleSmall), + value: _canDriveOverBump, onChanged: (bool value) { setState(() { - _hasVision = value; + _canDriveOverBump = value; }); - _saveBool('hasVision', value); + _saveBool('canDriveOverBump', value); }, ), SwitchListTile( title: Text('Can Go Under Trench', style: Theme.of(context).textTheme.titleSmall), - value: _trenchable, + value: _canDriveUnderTrench, onChanged: (bool value) { setState(() { - _trenchable = value; + _canDriveUnderTrench = value; }); - _saveBool('trenchable', value); + _saveBool('canDriveUnderTrench', value); + }, + ), + SwitchListTile( + title: Text('Can Give Fuel To Human Player', style: Theme.of(context).textTheme.titleSmall), + value: _canGiveToHumanPlayer, + onChanged: (bool value) { + setState(() { + _canGiveToHumanPlayer = value; + }); + _saveBool('canGiveToHumanPlayer', value); }, ), const SizedBox(height: 16), @@ -253,6 +272,56 @@ class _NotesPageState extends State { ], ), ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Fuel Per Cycle - ${_fuelPerCycle.round()}', style: Theme.of(context).textTheme.titleSmall), + Slider( + value: _fuelPerCycle, + min: 0, + max: _fuelCapacity, + divisions: 50, + label: _fuelPerCycle.round().toString(), + onChanged: (double value) { + setState(() { + _fuelPerCycle = value; + }); + }, + onChangeEnd: (double value) { + _saveDouble('fuelPerCycle', value); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Cycle Time - ${_cycleTime.round()}', style: Theme.of(context).textTheme.titleSmall), + Slider( + value: _cycleTime, + min: 0, + max: _cycleTime, + divisions: 50, + label: _cycleTime.round().toString(), + onChanged: (double value) { + setState(() { + _cycleTime = value; + }); + }, + onChangeEnd: (double value) { + _saveDouble('cycleTime', value); + }, + ), + ], + ), + ), ], ); } diff --git a/lib/teamadder.dart b/lib/teamadder.dart new file mode 100644 index 0000000..b6ed028 --- /dev/null +++ b/lib/teamadder.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TeamAdder extends StatefulWidget { + final String eventCode; + const TeamAdder({super.key, required this.eventCode}); + + @override + State createState() => _TeamAdderState(); +} + +class _TeamAdderState extends State { + final _teamNumberController = TextEditingController(); + final _teamNameController = TextEditingController(); + List customTeamNumbers = []; + List customTeamNames = []; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _loadTeams(); + } + + Future _loadTeams() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + setState(() { + customTeamNumbers = prefs.getStringList('custom_team_numbers_${widget.eventCode}') ?? []; + customTeamNames = prefs.getStringList('custom_team_names_${widget.eventCode}') ?? []; + }); + } + + Future _saveTeam() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final String teamNumber = _teamNumberController.text; + final String teamName = _teamNameController.text; + + setState(() { + customTeamNumbers.add(teamNumber); + customTeamNames.add(teamName); + }); + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('custom_team_numbers_${widget.eventCode}', customTeamNumbers); + await prefs.setStringList('custom_team_names_${widget.eventCode}', customTeamNames); + + _teamNumberController.clear(); + _teamNameController.clear(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added Team: $teamNumber - $teamName'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16.0), + ), + ); + } + } + + @override + void dispose() { + _teamNumberController.dispose(); + _teamNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Add Team'), + ), + body: Form( + key: _formKey, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Add a New Team', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextFormField( + controller: _teamNumberController, + decoration: const InputDecoration( + labelText: 'Team Number', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a team number'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _teamNameController, + decoration: const InputDecoration( + labelText: 'Team Name', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a team name'; + } + return null; + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _saveTeam, + child: const Text('Save Team'), + ), + ), + ], + ), + ), + const Divider(height: 32), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Saved Teams', style: Theme.of(context).textTheme.titleLarge), + ), + Expanded( + child: ListView.builder( + itemCount: customTeamNumbers.length, + itemBuilder: (context, index) { + return ListTile( + title: Text('${customTeamNumbers[index]} - ${customTeamNames[index]}'), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/teampicker.dart b/lib/teampicker.dart index 83badfc..25832ad 100644 --- a/lib/teampicker.dart +++ b/lib/teampicker.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; import 'package:laserscouter/core/api.dart'; import 'notespage.dart'; +import 'teamadder.dart'; import 'package:to_csv/to_csv.dart' as csv_export; import 'package:shared_preferences/shared_preferences.dart'; class TeamPicker extends StatefulWidget { final String eventCode; + final String eventName; + final bool isCustomOnly; - const TeamPicker({super.key, required this.eventCode}); + const TeamPicker({super.key, required this.eventCode, required this.eventName,this.isCustomOnly = false}); @override State createState() => _TeamPickerState(); @@ -83,29 +86,89 @@ class _TeamPickerState extends State { Future _fetchTeams() async { try { - final EventSearchResult result = await eventSearch(widget.eventCode); + List apiTeamNames = []; + List apiTeamCodes = []; + + if (!widget.isCustomOnly) { + final EventSearchResult apiResult = await eventSearch(widget.eventCode); + apiTeamNames = apiResult.teamNames; + apiTeamCodes = apiResult.teamCodes; + } + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final List customTeamNames = prefs.getStringList('custom_team_names_${widget.eventName}') ?? []; + final List customTeamCodes = prefs.getStringList('custom_team_numbers_${widget.eventCode}') ?? []; + + List combinedNames = []; + List combinedCodes = []; + for (int i = 0; i < apiTeamCodes.length; i++) { + if (!combinedCodes.contains(apiTeamCodes[i])) { + combinedCodes.add(apiTeamCodes[i]); + if (i < apiTeamNames.length) { + combinedNames.add(apiTeamNames[i]); + } else { + combinedNames.add(''); + } + } + } + for (int i = 0; i < customTeamCodes.length; i++) { + if (!combinedCodes.contains(customTeamCodes[i])) { + combinedCodes.add(customTeamCodes[i]); + if (i < customTeamNames.length) { + combinedNames.add(customTeamNames[i]); + } else { + combinedNames.add(''); + } + } + } + if (mounted) { setState(() { - teamNames = result.teamNames; - teamCodes = result.teamCodes; + teamNames = combinedNames.toList(); + teamCodes = combinedCodes.toList(); isLoading = false; }); } } catch (e) { if (mounted) { setState(() { - errorMessage = "Failed to load teams. Please try again."; + errorMessage = widget.isCustomOnly + ? "Could not load custom teams." + : "Failed to load teams. Please try again."; isLoading = false; }); } } } + + Future _refreshTeams() async { + setState(() { + isLoading = true; + }); + await _fetchTeams(); + } + + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Teams'), + actions: [ + IconButton( + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TeamAdder(eventCode: widget.eventCode,), + ), + ); + _refreshTeams(); + }, + icon: Icon(Icons.add), + ) + ] ), body: _buildBody(), floatingActionButton: FloatingActionButton( diff --git a/pubspec.yaml b/pubspec.yaml index 740e216..a7935b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 26.1.23 +version: 26.2.14+2 environment: sdk: ^3.10.7 From 1c3b52723f0f2a76ba9e84f0b1f2abc76e64b5d0 Mon Sep 17 00:00:00 2001 From: Raktbastr Date: Sat, 14 Feb 2026 14:14:55 -0600 Subject: [PATCH 2/4] Readme fix --- README.md | 4 +--- lib/login.dart | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index abd4ac9..e6e6a73 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -![Laser Scouter](https://raw.githubusercontent.com/Raktbastr/laserscouter/refs/heads/main/assets/laserscouterlogo.png) -WIP Logo +![Laser Scouter](https://raw.githubusercontent.com/Raktbastr/laserscouter/refs/heads/main/assets/main.png) # Simple FRC scouting app. Developed by me for Laser Robotics and the FRC community! @@ -9,7 +8,6 @@ Input your team number and event, and start scouting! When you're done export yo * Better and updated scouting questions, these are examples from Reefscape * Match scouting -* Online accessible version # Some extra notes diff --git a/lib/login.dart b/lib/login.dart index 181e6a5..ead7a5b 100644 --- a/lib/login.dart +++ b/lib/login.dart @@ -146,7 +146,7 @@ class _LoginPageState extends State { return AlertDialog( title: const Text('Info'), content: const Text( - "This app makes use of The Blue Alliance APIv3 through Laser Proxy. No API keys are stored on device. Laser Scouter was created by FRC 2077 Laser Robotics. \n\nVersion: Rebuilt 26.2.14}" + "This app makes use of The Blue Alliance APIv3 through Laser Proxy. No API keys are stored on device. Laser Scouter was created by FRC 2077 Laser Robotics. \n\nVersion: Rebuilt v26.2.14" ), ); } From dc7de896902163aa498260c4b566e4477324beb2 Mon Sep 17 00:00:00 2001 From: Raktbastr Date: Sat, 14 Feb 2026 14:52:21 -0600 Subject: [PATCH 3/4] Patched cycletime bug. Max cycletime now set to 60 sec --- lib/notespage.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/notespage.dart b/lib/notespage.dart index ee4122f..0aa56f6 100644 --- a/lib/notespage.dart +++ b/lib/notespage.dart @@ -303,12 +303,12 @@ class _NotesPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Cycle Time - ${_cycleTime.round()}', style: Theme.of(context).textTheme.titleSmall), + Text('Cycle Time - ${_cycleTime.round()}s', style: Theme.of(context).textTheme.titleSmall), Slider( value: _cycleTime, min: 0, - max: _cycleTime, - divisions: 50, + max: 60, + divisions: 60, label: _cycleTime.round().toString(), onChanged: (double value) { setState(() { From b082e78eec3754cb493c9132be5fabd17d6cd777 Mon Sep 17 00:00:00 2001 From: Raktbastr Date: Sun, 22 Feb 2026 07:29:10 -0600 Subject: [PATCH 4/4] Fixed exports, added misssing questions. --- README.md | 6 ++--- lib/login.dart | 2 +- lib/notespage.dart | 35 +++++++++++++++---------- lib/teampicker.dart | 63 +++++++++++++++++++++++++++++---------------- pubspec.yaml | 2 +- 5 files changed, 68 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index e6e6a73..3ce298f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Input your team number and event, and start scouting! When you're done export yo # Some extra notes -* This is a side project by a me and is my first Flutter project. Please leave suggestions and tips about what I could do better! -* I am providing APK's, but it may not be the most up to date version. +* This is a side project by me and is my first Flutter project. Please leave suggestions and tips about what I could do better! +* I am providing APK's, but it may not be the most up-to-date version. * To run on iOS you will still need to compile it and install with Xcode # How to contribute @@ -26,4 +26,4 @@ Input your team number and event, and start scouting! When you're done export yo 1. Run the command `flutter run` in your terminal while inside project folder. 2. Open the app in a browser, on your phone, or as a computer app. -* If you plan to test on an iPhone, you must have a Apple Developer account and a MacOS device with Xcode. \ No newline at end of file +* If you plan to test on an iPhone, you must have an Apple Developer account and a macOS device with Xcode. \ No newline at end of file diff --git a/lib/login.dart b/lib/login.dart index ead7a5b..7794b7d 100644 --- a/lib/login.dart +++ b/lib/login.dart @@ -146,7 +146,7 @@ class _LoginPageState extends State { return AlertDialog( title: const Text('Info'), content: const Text( - "This app makes use of The Blue Alliance APIv3 through Laser Proxy. No API keys are stored on device. Laser Scouter was created by FRC 2077 Laser Robotics. \n\nVersion: Rebuilt v26.2.14" + "This app makes use of The Blue Alliance APIv3 through Laser Proxy. No API keys are stored on device. Laser Scouter was created by FRC 2077 Laser Robotics. \n\nVersion: Rebuilt v26.2.22" ), ); } diff --git a/lib/notespage.dart b/lib/notespage.dart index 0aa56f6..1582628 100644 --- a/lib/notespage.dart +++ b/lib/notespage.dart @@ -28,7 +28,7 @@ class _NotesPageState extends State { final _generalObservationsController = TextEditingController(); final _autonRundownController = TextEditingController(); final _intakePositionController = TextEditingController(); - final _scoreMechanisimController = TextEditingController(); + final _scoreMechanismController = TextEditingController(); double _fuelPerCycle = 0; bool _canDriveUnderTrench = false; bool _canDriveOverBump = false; @@ -49,7 +49,7 @@ class _NotesPageState extends State { _botPositionController.dispose(); _generalObservationsController.dispose(); _intakePositionController.dispose(); - _scoreMechanisimController.dispose(); + _scoreMechanismController.dispose(); super.dispose(); } @@ -58,7 +58,7 @@ class _NotesPageState extends State { } Future _loadNotes() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs = await SharedPreferences.getInstance(); _botPositionController.text = prefs.getString(_generateKey('botPosition')) ?? ''; _botPositionController.addListener(() => _saveString('botPosition', _botPositionController.text)); @@ -72,8 +72,8 @@ class _NotesPageState extends State { _intakePositionController.text = prefs.getString(_generateKey('intakePosition')) ?? ''; _intakePositionController.addListener(() => _saveString('intakePosition', _intakePositionController.text)); - _scoreMechanisimController.text = prefs.getString(_generateKey('scoreMechanism')) ?? ''; - _scoreMechanisimController.addListener(() => _saveString('scoreMechanism', _scoreMechanisimController.text)); + _scoreMechanismController.text = prefs.getString(_generateKey('scoreMechanism')) ?? ''; + _scoreMechanismController.addListener(() => _saveString('scoreMechanism', _scoreMechanismController.text)); _fuelPerCycle = prefs.getDouble(_generateKey('fuelPerCycle')) ?? 0.0; _canDriveUnderTrench = prefs.getBool(_generateKey('canDriveUnderTrench')) ?? false; @@ -107,12 +107,12 @@ class _NotesPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text('Notes'), - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : SafeArea( + appBar: AppBar( + title: Text('Notes'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SafeArea( child: Column( children: [ Padding( @@ -144,7 +144,7 @@ class _NotesPageState extends State { ), ], ), - ) + ) ); } @@ -192,6 +192,15 @@ class _NotesPageState extends State { ), ), const SizedBox(height: 16), + TextField( + controller: _intakePositionController, + decoration: const InputDecoration( + labelText: 'Intake Position', + hintText: 'e.g., Ground', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), SwitchListTile( title: Text('Can Drive Over Bump', style: Theme.of(context).textTheme.titleSmall), value: _canDriveOverBump, @@ -325,4 +334,4 @@ class _NotesPageState extends State { ], ); } -} +} \ No newline at end of file diff --git a/lib/teampicker.dart b/lib/teampicker.dart index 25832ad..36aca8e 100644 --- a/lib/teampicker.dart +++ b/lib/teampicker.dart @@ -32,14 +32,17 @@ class _TeamPickerState extends State { Future _exportData() async { List header = []; header.add('Team Number'); - header.add('Drivetrain Type'); - header.add('Has Vision'); - header.add('Climb Level'); - header.add('Trenchable'); - header.add('Fuel Capacity'); header.add('Bot Position'); + header.add('Gen. Observations'); header.add('Auton Rundown'); - header.add('General Observations'); + header.add('Intake Position'); + header.add('Can Drive Over Bump'); + header.add('Can Go Under Trench'); + header.add('Can Give Fuel to HP'); + header.add('Climb Level'); + header.add('Fuel Capacity'); + header.add('Fuel per Cycle'); + header.add("Cycle Time"); List> data = []; for (int i = 0; i < teamCodes.length; i++) { @@ -47,41 +50,57 @@ class _TeamPickerState extends State { return '${teamCodes[i]}_${widget.eventCode}_$field'; } SharedPreferences prefs = await SharedPreferences.getInstance(); - String? autonRundown = prefs.getString(generateKey('autonRundown')); String? botPosition = prefs.getString(generateKey('botPosition')); String? generalObservations = prefs.getString(generateKey('generalObservations')); - String? driveTrainType = prefs.getString(generateKey('driveTrainType')); - String? hasVision = prefs.getBool(generateKey('hasVision')).toString(); + String? autonRundown = prefs.getString(generateKey('autonRundown')); + String? intakePosition = prefs.getString(generateKey('intakePosition')); + String? canDriveOverBump = prefs.getBool(generateKey('canDriveOverBump')).toString(); + String? canDriveUnderTrench = prefs.getBool(generateKey('canDriveUnderTrench')).toString(); + String? canGiveToHumanPlayer = prefs.getBool(generateKey('canGiveToHumanPlayer')).toString(); String? climbLevel = prefs.getDouble(generateKey('climbLevel')).toString(); - String? trenchable = prefs.getBool(generateKey('trenchable')).toString(); String? fuelCapacity = prefs.getDouble(generateKey('fuelCapacity')).toString(); + String? fuelPerCycle = prefs.getDouble(generateKey('fuelPerCycle')).toString(); + String? cycleTime = prefs.getDouble(generateKey('cycleTime')).toString(); - if (hasVision == 'null') { - hasVision = ''; + if (canDriveOverBump == 'null') { + canDriveOverBump = ''; + } + if (canDriveUnderTrench == 'null') { + canDriveUnderTrench = ''; + } + if (canGiveToHumanPlayer == 'null') { + canGiveToHumanPlayer = ''; } if (climbLevel == 'null') { climbLevel = ''; } - if (trenchable == 'null') { - trenchable = ''; - } if (fuelCapacity == 'null') { fuelCapacity = ''; } + if (fuelPerCycle == 'null') { + fuelPerCycle = ''; + } + if (cycleTime == 'null') { + cycleTime = ''; + } + List teamData = []; teamData.add(teamCodes[i]); - teamData.add(driveTrainType ?? ''); - teamData.add(hasVision); - teamData.add(climbLevel); - teamData.add(trenchable); - teamData.add(fuelCapacity); teamData.add(botPosition ?? ''); - teamData.add(autonRundown ?? ''); teamData.add(generalObservations ?? ''); + teamData.add(autonRundown ?? ''); + teamData.add(intakePosition ?? ''); + teamData.add(canDriveOverBump); + teamData.add(canDriveUnderTrench); + teamData.add(canGiveToHumanPlayer); + teamData.add(climbLevel); + teamData.add(fuelCapacity); + teamData.add(fuelPerCycle); + teamData.add(cycleTime); data.add(teamData); } - csv_export.myCSV(header, data, setHeadersInFirstRow: true, emptyRowsConfig: {1: 1}, fileName: 'laserscouter_${widget.eventCode}.csv'); + csv_export.myCSV(header, data, setHeadersInFirstRow: true, fileName: 'laserscouter_${widget.eventCode}.csv'); } Future _fetchTeams() async { diff --git a/pubspec.yaml b/pubspec.yaml index a7935b9..58b08bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 26.2.14+2 +version: 26.2.22 environment: sdk: ^3.10.7