From 3b2a065b1f9f31d7d738086cceb3278ebcc5e2a4 Mon Sep 17 00:00:00 2001 From: Dhairya Trivedi <140292400+Dhairya-exe@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:33:44 -0600 Subject: [PATCH 1/2] added quick comparing for picklistsand general fixes --- APP/lib/pages/pick_list_page.dart | 2409 ++++++++++++++++++++++------- 1 file changed, 1826 insertions(+), 583 deletions(-) diff --git a/APP/lib/pages/pick_list_page.dart b/APP/lib/pages/pick_list_page.dart index f9882ae..a2c9d41 100644 --- a/APP/lib/pages/pick_list_page.dart +++ b/APP/lib/pages/pick_list_page.dart @@ -10,6 +10,7 @@ import 'package:scouting_app/widgets/polar_forecast_app_bar.dart'; import 'dart:html' as html; import '../models/group.dart'; import '../models/team_stats_2026.dart'; +import '../models/picture_data.dart'; class PicklistPage extends StatefulWidget { final String groupName; @@ -28,10 +29,12 @@ class PicklistPage extends StatefulWidget { } class _PicklistPageState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + int _getTeamRank(String teamNumber) { final index = rankings.indexWhere((t) => t.team_number == teamNumber); if (index == -1) return 0; - return index + 1; + return rankings[index].rank; } void exportCSV() { @@ -41,12 +44,17 @@ class _PicklistPageState extends State { rows.add([ "Rank", + "Comp Rank", "Team", "Comments", "OPR", "Auto Points", "Teleop Points", - "Endgame Points" + "Endgame Points", + "Teleop Pass", + "Sim RP", + "Death Rate", + "Defense Rate" ]); for (int i = 0; i < picks.length; i++) { @@ -55,12 +63,17 @@ class _PicklistPageState extends State { rows.add([ (i + 1).toString(), + stats?.rank.toString() ?? "-", pick.number, pick.comments ?? "", stats?.OPR.toStringAsFixed(2) ?? "", stats?.auto_points.toStringAsFixed(2) ?? "", stats?.teleop_points.toStringAsFixed(2) ?? "", stats?.endgame_points.toStringAsFixed(2) ?? "", + stats?.teleop_pass.toStringAsFixed(2) ?? "", + stats?.simulated_rp.toString() ?? "", + stats?.death_rate.toStringAsFixed(2) ?? "", + stats?.defense_rate.toStringAsFixed(2) ?? "", ]); } @@ -70,8 +83,6 @@ class _PicklistPageState extends State { final blob = html.Blob([bytes]); final url = html.Url.createObjectUrlFromBlob(blob); - // THIS IS THE PART YOU WERE MISSING - final anchor = html.AnchorElement(href: url) ..setAttribute( "download", @@ -82,15 +93,12 @@ class _PicklistPageState extends State { html.Url.revokeObjectUrl(url); } - /// Renames a picklist with the given ID inside a GroupEvent - /// Renames a picklist using the API service Future renamePicklist({ required String picklistId, required String newName, }) async { if (selectedPicklist == null) return; - // Show loading state if desired setState(() { _saving = true; }); @@ -98,21 +106,15 @@ class _PicklistPageState extends State { try { final apiService = Provider.of(context, listen: false); - // Create updated picklist with new name final updatedPicklist = Picklist2026( picklist_id: picklistId, name: newName, picks: selectedPicklist!.picks, ); - // Call API to update await apiService.updatePicklist( widget.groupName, widget.eventCode, picklistId, updatedPicklist); - - // No need to manually update state here because your WebSocket - // listener will update the picklists automatically } catch (e) { - // Handle error (you might want to show a snackbar) if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -145,6 +147,7 @@ class _PicklistPageState extends State { "Foul Points": (t) => t.foul_points, "Defense Rate": (t) => t.defense_rate, "Death Rate": (t) => t.death_rate, + "Simulated RP": (t) => t.simulated_rp.toDouble(), }; bool _saving = false; @@ -162,7 +165,6 @@ class _PicklistPageState extends State { Picklist2026? selectedPicklist; List picks = []; - // NEW: cache for team nicknames (keyed by plain team number string) final Map teamNames = {}; String get _eventYear { @@ -193,20 +195,17 @@ class _PicklistPageState extends State { Future _loadInitialPicklists() async { final apiService = Provider.of(context, listen: false); - final initialPicklists = await apiService.fetchPicklists(widget.groupName, widget.eventCode); setState(() { picklists = initialPicklists; - if (picklists.isNotEmpty) { selectedPicklist = picklists.first; picks = List.from(selectedPicklist!.picks); } }); - // fetch team names for initial picks for (final p in picks) { _ensureTeamName(p.number); } @@ -246,7 +245,6 @@ class _PicklistPageState extends State { } }); - // fetch nicknames for new picks for (final p in picks) { _ensureTeamName(p.number); } @@ -262,7 +260,6 @@ class _PicklistPageState extends State { picks = List.from(picklist.picks); }); - // fetch nicknames for selected picklist for (final p in picks) { _ensureTeamName(p.number); } @@ -335,7 +332,7 @@ class _PicklistPageState extends State { return AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16)), - title: const Text("Delete Picklist", + title: Text("Delete Picklist", style: TextStyle( fontWeight: FontWeight.bold, color: Colors.redAccent)), content: Column( @@ -386,17 +383,6 @@ class _PicklistPageState extends State { ); } - void addTeam(TeamStats2026 team) { - setState(() { - if (!picks.any((p) => p.number == team.team_number)) { - picks.add(Picks(number: team.team_number, comments: "")); - } - }); - - // fetch nickname for the added team - _ensureTeamName(team.team_number); - } - TeamStats2026? _getTeamStats(String teamNumber) { try { return rankings.firstWhere((t) => t.team_number == teamNumber); @@ -405,21 +391,17 @@ class _PicklistPageState extends State { } } - // NEW: ensure we have the team's nickname (calls ApiService.fetchTeamNicknames) Future _ensureTeamName(String teamNumber) async { if (teamNames.containsKey(teamNumber)) return; try { final apiService = Provider.of(context, listen: false); - // TBA expects team key like 'frcXXXX' final nickname = await apiService.fetchTeamNicknames('frc$teamNumber'); if (mounted) { setState(() { teamNames[teamNumber] = nickname ?? ""; }); } - } catch (e) { - // ignore failures silently — nickname will remain missing - } + } catch (e) {} } void openCreateDialog() { @@ -432,10 +414,12 @@ class _PicklistPageState extends State { return StatefulBuilder( builder: (context, setStateDialog) { final isValid = nameController.text.trim().isNotEmpty; + final theme = Theme.of(context); + final cs = theme.colorScheme; return AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16)), - title: const Text("Create Picklist", + title: Text("Create Picklist", style: TextStyle(fontWeight: FontWeight.bold)), content: Column( mainAxisSize: MainAxisSize.min, @@ -449,8 +433,7 @@ class _PicklistPageState extends State { filled: true, ), onChanged: (val) { - setStateDialog( - () {}); // Trigger rebuild to update validation + setStateDialog(() {}); }, ), const SizedBox(height: 16), @@ -478,7 +461,7 @@ class _PicklistPageState extends State { ), FilledButton( style: FilledButton.styleFrom( - backgroundColor: isValid ? Colors.blue : Colors.grey, + backgroundColor: isValid ? cs.primary : Colors.grey, foregroundColor: Colors.white, ), onPressed: isValid @@ -498,19 +481,53 @@ class _PicklistPageState extends State { ); } - Widget _buildStatBadge(String label, double value, Color color) { + void _openQuickCompare() { + if (picks.length < 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not enough teams to compare!')), + ); + return; + } + + showDialog( + context: context, + builder: (context) { + return QuickCompareDialog( + picks: picks, + rankings: rankings, + eventYear: int.parse(_eventYear), + eventCode: widget.eventCode, + onSwap: (idx1, idx2) { + setState(() { + final temp = picks[idx1]; + picks[idx1] = picks[idx2]; + picks[idx2] = temp; + _triggerHighlight(picks[idx1].number); + _triggerHighlight(picks[idx2].number); + }); + _autoSave(); + }, + teamNames: teamNames, + ); + }, + ); + } + + Widget _buildStatBadge(String label, String value, Color color) { return Container( - margin: const EdgeInsets.only(right: 6, top: 4), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color.withOpacity(0.08), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withOpacity(0.18)), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.2)), ), child: Text( - '$label: ${value.toStringAsFixed(1)}', - style: - TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: color), + '$label: $value', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + ), ), ); } @@ -528,6 +545,16 @@ class _PicklistPageState extends State { }); } + void _moveTop(int index) { + if (index <= 0) return; + setState(() { + final item = picks.removeAt(index); + picks.insert(0, item); + _triggerHighlight(item.number); + }); + _autoSave(); + } + void _moveUp(int index) { if (index <= 0) return; setState(() { @@ -581,11 +608,12 @@ class _PicklistPageState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), child: Text("Cancel")), + onPressed: () => Navigator.pop(context), + child: const Text("Cancel")), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.blue), onPressed: () => Navigator.pop(context, controller.text), - child: Text("Save")), + child: const Text("Save")), ], ); }, @@ -599,584 +627,1685 @@ class _PicklistPageState extends State { } } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - final isMobile = MediaQuery.of(context).size.width < 800; - - final Widget teamsListPane = Container( - decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.02), - blurRadius: 8, - offset: const Offset(0, 3)) - ], - ), - padding: const EdgeInsets.all(12), - child: picks.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + Widget _buildDrawerMenu(ThemeData theme, ColorScheme cs) { + return Drawer( + backgroundColor: theme.cardColor, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( children: [ - Icon(Icons.list_alt, - size: 72, color: cs.onSurface.withOpacity(0.14)), - const SizedBox(height: 16), + Icon(Icons.list, color: cs.primary), + const SizedBox(width: 8), Text( - "No teams in this picklist yet.", + "Picklists", style: TextStyle( - fontSize: 18, color: cs.onSurface.withOpacity(0.6)), + fontSize: 20, + fontWeight: FontWeight.bold, + color: cs.onSurface), ), - const SizedBox(height: 10), - Text( - "Create a new picklist or add teams from rankings.", - style: TextStyle( - fontSize: 13, color: cs.onSurface.withOpacity(0.5)), + const Spacer(), + if (_saving) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), ) ], ), - ) - : ReorderableListView.builder( - // DISABLE default handles so we can place our handle under the rank number - buildDefaultDragHandles: false, - itemCount: picks.length, - onReorder: (oldIndex, newIndex) { - setState(() { - if (newIndex > oldIndex) newIndex--; - final item = picks.removeAt(oldIndex); - picks.insert(newIndex, item); - _triggerHighlight(item.number); - }); - _autoSave(); - }, - itemBuilder: (context, index) { - final pick = picks[index]; - - final tbaProxyAvatar = - 'https://images.weserv.nl/?url=www.thebluealliance.com/avatar/$_eventYear/frc${pick.number}.png&w=96&h=96&fit=contain'; - final dicebearAvatar = - 'https://api.dicebear.com/9.x/identicon/png?seed=frc${pick.number}&size=64'; - - final teamStats = _getTeamStats(pick.number); - - final tint = teamColors[pick.number]; - final cardTint = - tint != null ? tint.withOpacity(0.08) : cs.surfaceVariant; - - bool isFirst = index == 0; - bool isLast = index == picks.length - 1; - bool isHighlighted = _highlightedTeam == pick.number; - - return AnimatedContainer( - duration: const Duration(milliseconds: 400), - curve: Curves.easeOut, - key: ValueKey(pick.number), - margin: - const EdgeInsets.symmetric(vertical: 6, horizontal: 2), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: isHighlighted - ? [ - BoxShadow( - color: cs.primary.withOpacity(0.4), - blurRadius: 16, - spreadRadius: 2) - ] - : [], - ), - child: Card( - elevation: 0, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: isHighlighted - ? cs.primary.withOpacity(0.6) - : cs.outline.withOpacity(0.12), - width: isHighlighted ? 1.5 : 1.0, - ), - ), - child: Stack( - children: [ - Positioned.fill( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - cardTint, - cardTint.withOpacity(0.02) - ], + const Divider(height: 24), + Expanded( + child: picklists.isEmpty + ? Center( + child: Text("No picklists found", + style: TextStyle( + color: cs.onSurface.withOpacity(0.6))), + ) + : ListView.builder( + itemCount: picklists.length, + itemBuilder: (context, i) { + final pl = picklists[i]; + final isSelected = pl == selectedPicklist; + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Material( + color: isSelected + ? cs.primary.withOpacity(0.06) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () { + selectPicklist(pl); + Navigator.pop(context); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: isSelected + ? cs.primary + : Colors.transparent, + ), + borderRadius: BorderRadius.circular(10), ), - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ), - // Replaced ListTile with a Custom Row to remove trailing height constraints - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 12.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Rank + Drag Handle (handle placed under rank) - Container( - width: 42, - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${_getTeamRank(pick.number)}', + child: ListTile( + dense: true, + title: Text( + pl.name, style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - color: cs.onSurface, - ), - ), - const SizedBox(height: 6), - // custom drag handle placed under the rank number - ReorderableDragStartListener( - index: index, - child: Icon( - Icons.drag_handle, - size: 20, - color: cs.onSurface.withOpacity(0.35), + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? cs.primary + : cs.onSurface.withOpacity(0.9), ), ), - ], - ), - ), - const SizedBox(width: 8), + trailing: isSelected + ? PopupMenuButton( + icon: const Icon(Icons.more_vert, + size: 20), + onSelected: (value) async { + if (value == 'rename') { + final newName = + await showDialog( + context: context, + builder: (context) { + String tempName = pl.name; + return AlertDialog( + title: const Text( + 'Rename Picklist'), + content: TextField( + autofocus: true, + controller: + TextEditingController( + text: pl.name), + onChanged: (val) => + tempName = val, + decoration: + const InputDecoration( + hintText: + 'Picklist Name', + border: + OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of( + context) + .pop(), + child: const Text( + 'Cancel'), + ), + ElevatedButton( + onPressed: () => + Navigator.of( + context) + .pop( + tempName), + child: const Text( + 'Rename'), + ), + ], + ); + }, + ); - // Avatar - InkWell( - onTap: () { - final eventCode = widget.eventCode; - final teamNumber = pick.number; - Navigator.pushNamed( - context, - '/event/$eventCode/team/frc$teamNumber', - ); - }, - child: TeamAvatar( - primaryUrl: tbaProxyAvatar, - fallbackUrl: dicebearAvatar, - teamNumber: pick.number, - size: 48, - onColor: (color) { - if (color != null) { - setState(() { - teamColors[pick.number] = color; - }); - } - }, + if (newName != null && + newName.trim().isNotEmpty) { + await renamePicklist( + picklistId: pl.picklist_id, + newName: newName.trim(), + ); + } + } else if (value == 'delete') { + _confirmDeletePicklist(pl); + } + }, + itemBuilder: + (BuildContext context) => + >[ + const PopupMenuItem( + value: 'rename', + child: Text('Rename'), + ), + const PopupMenuItem( + value: 'delete', + child: Text( + 'Delete', + style: TextStyle( + color: Colors.redAccent), + ), + ), + ], + ) + : null, + ), ), ), - const SizedBox(width: 16), + ), + ); + }, + ), + ), + const SizedBox(height: 12), + GlassButton( + color: cs.primary, + onPressed: () { + Navigator.pop(context); + openCreateDialog(); + }, + icon: Icons.add, + label: "New Picklist", + ), + const SizedBox(height: 10), + GlassButton( + color: cs.secondary, + onPressed: () { + Navigator.pop(context); + _openQuickCompare(); + }, + icon: Icons.compare_arrows, + label: "Quick Compare", + ), + const SizedBox(height: 10), + GlassButton( + color: Colors.green, + onPressed: exportCSV, + icon: Icons.download, + label: "Export CSV", + ), + ], + ), + ), + ), + ); + } - // Main Info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: () { - final eventCode = widget.eventCode; - Navigator.pushNamed( - context, - '/event/$eventCode/team/frc${pick.number}', - ); - }, - child: Text( - // SHOW team nickname next to team number separated by " | " - "${pick.number}${teamNames[pick.number] != null && teamNames[pick.number]!.isNotEmpty ? ' | ${teamNames[pick.number]}' : ''}", - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: cs.onSurface, + Future _openTeamImages(String teamNumber) async { + final eventYear = int.parse(_eventYear); + setState(() {}); + showDialog( + context: context, + builder: (context) { + return TeamImagesDialog( + teamNumber: teamNumber, + eventYear: eventYear, + eventCode: widget.eventCode, + rankings: rankings, + picklistIndex: picks.indexWhere((p) => p.number == teamNumber) + 1, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + return Scaffold( + key: _scaffoldKey, + backgroundColor: Colors.black, + appBar: + PolarForecastAppBar(extraText: 'Picklist for ${widget.eventCode}'), + endDrawer: _buildDrawerMenu(theme, cs), + floatingActionButton: Builder( + builder: (context) { + return GlassRoundButton( + color: cs.primary, + icon: Icons.menu, + tooltip: 'Open Picklist Panel', + onPressed: () { + try { + _scaffoldKey.currentState?.openEndDrawer(); + } catch (_) { + Scaffold.of(context).openEndDrawer(); + } + }, + ); + }, + ), + body: Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: true, + child: SnowField( + particleCount: snowCount, + color: cs.onBackground, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(12.0), + child: Container( + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 8, + offset: const Offset(0, 3)) + ], + ), + padding: const EdgeInsets.all(8), + child: picks.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.list_alt, + size: 72, color: cs.onSurface.withOpacity(0.14)), + const SizedBox(height: 16), + Text( + "No teams in this picklist yet.", + style: TextStyle( + fontSize: 18, + color: cs.onSurface.withOpacity(0.6)), + ), + const SizedBox(height: 10), + Text( + "Create a new picklist or add teams from rankings.", + style: TextStyle( + fontSize: 13, + color: cs.onSurface.withOpacity(0.5)), + ) + ], + ), + ) + : ReorderableListView.builder( + buildDefaultDragHandles: false, + itemCount: picks.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex--; + final item = picks.removeAt(oldIndex); + picks.insert(newIndex, item); + _triggerHighlight(item.number); + }); + _autoSave(); + }, + itemBuilder: (context, index) { + final pick = picks[index]; + + final tbaProxyAvatar = + 'https://images.weserv.nl/?url=www.thebluealliance.com/avatar/$_eventYear/frc${pick.number}.png&w=96&h=96&fit=contain'; + final dicebearAvatar = + 'https://api.dicebear.com/9.x/identicon/png?seed=frc${pick.number}&size=64'; + + final teamStats = _getTeamStats(pick.number); + + final tint = teamColors[pick.number]; + final cardTint = tint != null + ? tint.withOpacity(0.08) + : cs.surfaceVariant; + + bool isFirst = index == 0; + bool isLast = index == picks.length - 1; + bool isHighlighted = _highlightedTeam == pick.number; + + Color trophyColorForRank(int rank) { + if (rank == 1) return Colors.amber; + if (rank == 2) return Colors.grey; + if (rank == 3) return const Color(0xFFcd7f32); + return cs.primary; + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeOut, + key: ValueKey(pick.number), + margin: const EdgeInsets.symmetric( + vertical: 4, horizontal: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: isHighlighted + ? [ + BoxShadow( + color: cs.primary.withOpacity(0.4), + blurRadius: 16, + spreadRadius: 2) + ] + : [], + ), + child: Card( + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isHighlighted + ? cs.primary.withOpacity(0.6) + : cs.outline.withOpacity(0.12), + width: isHighlighted ? 1.5 : 1.0, + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 6, sigmaY: 6), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + cardTint, + cardTint.withOpacity(0.02) + ], + ), + borderRadius: + BorderRadius.circular(12), ), ), ), - const SizedBox(height: 6), - if (teamStats != null) - Wrap( - children: [ - _buildStatBadge("OPR", teamStats.OPR, - Colors.purple), - _buildStatBadge( - "Auto", - teamStats.auto_points, - Colors.green), - _buildStatBadge( - "Teleop", - teamStats.teleop_points, - Colors.orange), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: cs.surfaceVariant, - borderRadius: - BorderRadius.circular(10), - border: Border.all( - color: cs.outline - .withOpacity(0.06)), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, vertical: 10.0), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Container( + width: 48, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '#${index + 1}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 6), + Icon( + Icons.emoji_events, + size: 20, + color: trophyColorForRank( + teamStats?.rank ?? 0), ), - child: Text( - pick.comments.isEmpty - ? "No comments. Tap edit to add." - : pick.comments, - maxLines: 3, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 2), + Text( + teamStats != null + ? '#${teamStats.rank}' + : '-', style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: trophyColorForRank( + teamStats?.rank ?? 0), + ), + ), + const SizedBox(height: 6), + ReorderableDragStartListener( + index: index, + child: Icon( + Icons.drag_handle, + size: 22, color: cs.onSurface - .withOpacity(0.85), - fontStyle: pick.comments.isEmpty - ? FontStyle.normal - : FontStyle.italic, - fontSize: 13, + .withOpacity(0.4), ), ), - ), + ], ), - const SizedBox(width: 8), - IconButton( - icon: Icon(Icons.edit, - size: 20, - color: cs.onSurface - .withOpacity(0.7)), - onPressed: () => - _editCommentsDialog(index), - tooltip: "Edit comments", + ), + const SizedBox(width: 8), + InkWell( + onTap: () { + _openTeamImages(pick.number); + }, + child: TeamAvatar( + primaryUrl: tbaProxyAvatar, + fallbackUrl: dicebearAvatar, + teamNumber: pick.number, + size: 40, + onColor: (color) { + if (color != null) { + setState(() { + teamColors[pick.number] = color; + }); + } + }, ), - ], - ), - ], - ), - ), - const SizedBox(width: 12), - - // Trailing Controls (Now free to expand vertically) - SizedBox( - width: 86, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: AnimatedOpacity( - duration: const Duration( - milliseconds: 200), - opacity: isFirst ? 0.3 : 1.0, - child: GlassIconButton( - icon: Icons.keyboard_arrow_up, - color: cs.onSurface, - tooltip: 'Move up 1', - onPressed: isFirst - ? null - : () => _moveUp(index), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + crossAxisAlignment: + WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + InkWell( + onTap: () { + Navigator.pushNamed( + context, + '/event/${widget.eventCode}/team/frc${pick.number}', + ); + }, + child: Text( + "${pick.number}${teamNames[pick.number] != null && teamNames[pick.number]!.isNotEmpty ? ' | ${teamNames[pick.number]}' : ''}", + style: TextStyle( + fontSize: 16, + fontWeight: + FontWeight.w800, + color: cs.onSurface, + ), + ), + ), + if (teamStats != null) ...[ + _buildStatBadge( + "OPR", + teamStats.OPR + .toStringAsFixed(1), + Colors.purple), + _buildStatBadge( + "Auto", + teamStats.auto_points + .toStringAsFixed(1), + Colors.green), + _buildStatBadge( + "Teleop", + teamStats.teleop_points + .toStringAsFixed(1), + Colors.orange), + _buildStatBadge( + "Pass", + teamStats.teleop_pass + .toStringAsFixed(1), + Colors.blue), + _buildStatBadge( + "Death", + '${(teamStats.death_rate * 100).toStringAsFixed(0)}%', + Colors.red), + _buildStatBadge( + "Def", + '${(teamStats.defense_rate * 100).toStringAsFixed(0)}%', + Colors.brown), + _buildStatBadge( + "Sim RP", + teamStats.simulated_rp + .toString(), + Colors.teal), + ], + ], ), - ), - ), - const SizedBox(width: 6), - Expanded( - child: AnimatedOpacity( - duration: const Duration( - milliseconds: 200), - opacity: isLast ? 0.3 : 1.0, - child: GlassIconButton( - icon: Icons.keyboard_arrow_down, - color: cs.onSurface, - tooltip: 'Move down 1', - onPressed: isLast - ? null - : () => _moveDown(index), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets + .symmetric( + horizontal: 8, + vertical: 4), + decoration: BoxDecoration( + color: cs.surfaceVariant, + borderRadius: + BorderRadius.circular( + 8), + border: Border.all( + color: cs.outline + .withOpacity( + 0.06)), + ), + child: Text( + pick.comments.isEmpty + ? "No comments." + : pick.comments, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface + .withOpacity(0.85), + fontStyle: pick.comments + .isEmpty + ? FontStyle.normal + : FontStyle.italic, + fontSize: 12, + ), + ), + ), + ), + IconButton( + icon: Icon(Icons.edit, + size: 18, + color: cs.onSurface + .withOpacity(0.7)), + onPressed: () => + _editCommentsDialog( + index), + tooltip: "Edit comments", + ), + ], ), - ), + ], ), - ], - ), - const SizedBox(height: 6), - AnimatedOpacity( - duration: - const Duration(milliseconds: 200), - opacity: isLast ? 0.3 : 1.0, - child: GlassIconButton( - icon: Icons.arrow_drop_down, - color: Colors.orange, - tooltip: 'Move to bottom', - onPressed: isLast - ? null - : () => _moveToBottom(index), ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ); - - final Widget controlsPane = Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ) - ], - border: Border.all(color: cs.outline.withOpacity(0.12)), - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon(Icons.list, color: Colors.blue), - const SizedBox(width: 8), - Text( - "Your Picklists", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: cs.onSurface), - ), - const Spacer(), - if (_saving) - Padding( - padding: const EdgeInsets.only(left: 6.0), - child: Row( - children: [ - SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.blue), - ), - const SizedBox(width: 6), - Text("Saving...", - style: TextStyle(fontSize: 12, color: Colors.blue)), - ], - ), - ), - ], - ), - const Divider(height: 24), - Expanded( - child: picklists.isEmpty - ? Center( - child: Text("No picklists found", - style: TextStyle(color: cs.onSurface.withOpacity(0.6))), - ) - : ListView.builder( - itemCount: picklists.length, - itemBuilder: (context, i) { - final pl = picklists[i]; - final isSelected = pl == selectedPicklist; - - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Material( - color: isSelected - ? cs.primary.withOpacity(0.06) - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () => selectPicklist(pl), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: isSelected - ? Colors.blue - : Colors.transparent, - ), - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - dense: true, - title: Text( - pl.name, - style: TextStyle( - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - color: isSelected - ? Colors.blue - : cs.onSurface.withOpacity(0.9), - ), - ), - trailing: isSelected - ? PopupMenuButton( - icon: const Icon(Icons.more_vert, - size: 20), - onSelected: (value) async { - // In the PopupMenuButton onSelected section, replace the rename part: - if (value == 'rename') { - final newName = - await showDialog( - context: context, - builder: (context) { - String tempName = pl.name; - return AlertDialog( - title: const Text( - 'Rename Picklist'), - content: TextField( - autofocus: true, - controller: - TextEditingController( - text: pl.name), - onChanged: (val) => - tempName = val, - decoration: - const InputDecoration( - hintText: 'Picklist Name', - border: - OutlineInputBorder(), + const SizedBox(width: 8), + SizedBox( + width: 72, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: AnimatedOpacity( + duration: const Duration( + milliseconds: 200), + opacity: + isFirst ? 0.3 : 1.0, + child: GlassIconButton( + icon: Icons + .keyboard_arrow_up, + color: cs.onSurface, + tooltip: 'Move up 1', + onPressed: isFirst + ? null + : () => + _moveUp(index), ), ), - actions: [ - TextButton( - onPressed: () => - Navigator.of(context) - .pop(), - child: - const Text('Cancel'), + ), + const SizedBox(width: 4), + Expanded( + child: AnimatedOpacity( + duration: const Duration( + milliseconds: 200), + opacity: isLast ? 0.3 : 1.0, + child: GlassIconButton( + icon: Icons + .keyboard_arrow_down, + color: cs.onSurface, + tooltip: 'Move down 1', + onPressed: isLast + ? null + : () => + _moveDown(index), ), - ElevatedButton( - onPressed: () => - Navigator.of(context) - .pop(tempName), - child: - const Text('Rename'), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: AnimatedOpacity( + duration: const Duration( + milliseconds: 200), + opacity: + isFirst ? 0.3 : 1.0, + child: GlassIconButton( + icon: Icons + .vertical_align_top, + color: Colors.green, + tooltip: 'Move to top', + onPressed: isFirst + ? null + : () => + _moveTop(index), ), - ], - ); - }, - ); - - if (newName != null && - newName.trim().isNotEmpty) { - await renamePicklist( - picklistId: pl.picklist_id, - newName: newName.trim(), - ); - } - } else if (value == 'delete') { - _confirmDeletePicklist(pl); - } - }, - itemBuilder: (BuildContext context) => - >[ - const PopupMenuItem( - value: 'rename', - child: Text('Rename'), - ), - const PopupMenuItem( - value: 'delete', - child: Text( - 'Delete', - style: TextStyle( - color: Colors.redAccent), + ), + ), + const SizedBox(width: 4), + Expanded( + child: AnimatedOpacity( + duration: const Duration( + milliseconds: 200), + opacity: isLast ? 0.3 : 1.0, + child: GlassIconButton( + icon: Icons + .vertical_align_bottom, + color: Colors.orange, + tooltip: 'Move to bottom', + onPressed: isLast + ? null + : () => _moveToBottom( + index), + ), + ), + ), + ], ), - ), - ], - ) - : null, - ), + ], + ), + ), + ], + ), + ), + ], ), ), - ), - ); - }, - ), - ), - const SizedBox(height: 12), - GlassButton( - color: Colors.blue, - onPressed: openCreateDialog, - icon: Icons.add, - label: "New Picklist", - ), - const SizedBox(height: 10), - GlassButton( - color: Colors.green, - onPressed: exportCSV, - icon: Icons.download, - label: "Export CSV", + ); + }, + ), + ), ), ], ), ); + } +} - return Scaffold( - backgroundColor: Colors.black, - appBar: - PolarForecastAppBar(extraText: 'Picklist for ${widget.eventCode}'), - body: Stack( - children: [ - Positioned.fill( - child: IgnorePointer( - ignoring: true, - child: SnowField( - particleCount: snowCount, - color: cs.onBackground, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: isMobile - ? Column( - children: [ - Flexible(flex: 3, child: controlsPane), - const SizedBox(height: 16), - Expanded(flex: 5, child: teamsListPane), - ], - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 5, child: teamsListPane), - const SizedBox(width: 20), - Expanded(flex: 2, child: controlsPane), - ], +class QuickCompareDialog extends StatefulWidget { + final List picks; + final List rankings; + final int eventYear; + final String eventCode; + final void Function(int index1, int index2) onSwap; + final Map teamNames; + + const QuickCompareDialog({ + super.key, + required this.picks, + required this.rankings, + required this.eventYear, + required this.eventCode, + required this.onSwap, + required this.teamNames, + }); + + @override + State createState() => _QuickCompareDialogState(); +} + +class _QuickCompareDialogState extends State { + int leftIndex = 0; + int rightIndex = 1; + + List teamAImages = []; + List teamBImages = []; + bool isLoading = false; + final Map names = {}; + + @override + void initState() { + super.initState(); + names.addAll(widget.teamNames); + if (widget.picks.length > 1) { + leftIndex = 0; + rightIndex = 1; + } + _loadPair(); + } + + Future _loadPair() async { + if (widget.picks.length < 2) return; + if (leftIndex < 0 || leftIndex >= widget.picks.length) return; + if (rightIndex < 0 || rightIndex >= widget.picks.length) return; + if (leftIndex == rightIndex) return; + + setState(() => isLoading = true); + final a = widget.picks[leftIndex].number; + final b = widget.picks[rightIndex].number; + + try { + final apiService = Provider.of(context, listen: false); + final futures = await Future.wait([ + apiService.fetchTeamImages(widget.eventYear, widget.eventCode, a), + apiService.fetchTeamImages(widget.eventYear, widget.eventCode, b), + apiService.fetchTeamNicknames('frc$a'), + apiService.fetchTeamNicknames('frc$b'), + ]); + + setState(() { + teamAImages = futures[0] as List; + teamBImages = futures[1] as List; + final nA = futures[2] as String?; + final nB = futures[3] as String?; + if (nA != null && nA.isNotEmpty) names[a] = nA; + if (nB != null && nB.isNotEmpty) names[b] = nB; + }); + } catch (_) { + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + TeamStats2026? _statsFor(String teamNumber) { + try { + return widget.rankings.firstWhere((t) => t.team_number == teamNumber); + } catch (_) { + return null; + } + } + + void _handleLike(String likedTeamNumber) { + if (leftIndex == rightIndex) return; + + final idxA = leftIndex; + final idxB = rightIndex; + final teamA = widget.picks[idxA].number; + final teamB = widget.picks[idxB].number; + final originalLowerIndex = max(idxA, idxB); + + if (likedTeamNumber == teamA && idxA > idxB) { + widget.onSwap(idxB, idxA); + } else if (likedTeamNumber == teamB && idxB > idxA) { + widget.onSwap(idxA, idxB); + } + + final loserTeam = likedTeamNumber == teamA ? teamB : teamA; + final loserIndex = widget.picks.indexWhere((p) => p.number == loserTeam); + int nextIndex = originalLowerIndex + 1; + + if (nextIndex == loserIndex) { + nextIndex += 1; + } + + if (loserIndex != -1 && nextIndex >= 0 && nextIndex < widget.picks.length) { + setState(() { + leftIndex = loserIndex; + rightIndex = nextIndex; + }); + _loadPair(); + return; + } + + final updatedAIndex = widget.picks.indexWhere((p) => p.number == teamA); + final updatedBIndex = widget.picks.indexWhere((p) => p.number == teamB); + if (updatedAIndex != -1 && updatedBIndex != -1) { + setState(() { + leftIndex = updatedAIndex; + rightIndex = updatedBIndex; + }); + } + + _loadPair(); + } + + Future _showImagePreview(String imageUrl) async { + if (imageUrl.isEmpty) return; + await showDialog( + context: context, + builder: (context) { + return Dialog( + insetPadding: const EdgeInsets.all(24), + backgroundColor: Colors.black87, + child: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + minScale: 0.8, + maxScale: 5, + child: Center( + child: Image.network(imageUrl, fit: BoxFit.contain), ), + ), + ), + Positioned( + top: 8, + right: 8, + child: IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close, color: Colors.white), + ), + ), + ], + ), + ); + }, + ); + } + + Future _selectTeam({required bool leftSide}) async { + final currentTeam = + leftSide ? widget.picks[leftIndex].number : widget.picks[rightIndex].number; + String search = ''; + + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return StatefulBuilder( + builder: (context, setSheetState) { + final filtered = widget.picks + .where( + (p) => _teamLabel(p.number) + .toLowerCase() + .contains(search.toLowerCase()), + ) + .toList(); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 12, + right: 12, + top: 8, + bottom: MediaQuery.of(context).viewInsets.bottom + 12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: const InputDecoration( + hintText: 'Search team number or name', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (value) { + setSheetState(() => search = value); + }, + ), + const SizedBox(height: 10), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: ListView.builder( + shrinkWrap: true, + itemCount: filtered.length, + itemBuilder: (context, index) { + final team = filtered[index].number; + final isSelected = team == currentTeam; + return ListTile( + dense: true, + title: Text( + _teamLabel(team), + overflow: TextOverflow.ellipsis, + ), + trailing: isSelected ? const Icon(Icons.check) : null, + onTap: () => Navigator.pop(context, team), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + + if (selected == null) return; + final newIndex = widget.picks.indexWhere((p) => p.number == selected); + if (newIndex == -1) return; + + setState(() { + if (leftSide) { + leftIndex = newIndex; + if (leftIndex == rightIndex) { + rightIndex = (leftIndex + 1) % widget.picks.length; + } + } else { + rightIndex = newIndex; + if (leftIndex == rightIndex) { + leftIndex = (rightIndex - 1 + widget.picks.length) % widget.picks.length; + } + } + }); + _loadPair(); + } + + Widget _glassContainer({required Widget child, double radius = 12}) { + final cs = Theme.of(context).colorScheme; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceVariant.withOpacity(0.18), + border: Border.all(color: cs.outline.withOpacity(0.08)), + borderRadius: BorderRadius.circular(radius), + ), + child: child, + ), + ), + ); + } + + String _teamLabel(String teamNumber) { + final nickname = names[teamNumber]; + if (nickname != null && nickname.isNotEmpty) { + return '$teamNumber | $nickname'; + } + return teamNumber; + } + + String _formatMetric(_CompareMetric metric, double value) { + if (metric.asPercent) { + return '${(value * 100).toStringAsFixed(1)}%'; + } + if (metric.integerLike) { + return value.toStringAsFixed(0); + } + return value.toStringAsFixed(2); + } + + List<_CompareMetric> _buildMetrics(TeamStats2026? a, TeamStats2026? b) { + return [ + _CompareMetric('Comp Rank', (a?.rank ?? 0).toDouble(), + (b?.rank ?? 0).toDouble(), lowerIsBetter: true, integerLike: true), + _CompareMetric( + 'Sim Rank', + (a?.simulated_rank ?? 0).toDouble(), + (b?.simulated_rank ?? 0).toDouble(), + lowerIsBetter: true, + integerLike: true), + _CompareMetric('OPR Rank', (a?.OPRRank ?? 0).toDouble(), + (b?.OPRRank ?? 0).toDouble(), + lowerIsBetter: true, integerLike: true), + _CompareMetric('OPR', a?.OPR ?? 0, b?.OPR ?? 0), + _CompareMetric('Auto Points', a?.auto_points ?? 0, b?.auto_points ?? 0), + _CompareMetric( + 'Teleop Points', a?.teleop_points ?? 0, b?.teleop_points ?? 0), + _CompareMetric( + 'Endgame Points', a?.endgame_points ?? 0, b?.endgame_points ?? 0), + _CompareMetric( + 'Climbing Points', a?.climbing_points ?? 0, b?.climbing_points ?? 0), + _CompareMetric('Total Pass', a?.total_pass ?? 0, b?.total_pass ?? 0), + _CompareMetric('Auto Pass', a?.auto_pass ?? 0, b?.auto_pass ?? 0), + _CompareMetric('Teleop Pass', a?.teleop_pass ?? 0, b?.teleop_pass ?? 0), + _CompareMetric( + 'Auto Fuel', a?.auto_fuel_cycles ?? 0, b?.auto_fuel_cycles ?? 0), + _CompareMetric( + 'Teleop Fuel', a?.teleop_fuel_cycles ?? 0, b?.teleop_fuel_cycles ?? 0), + _CompareMetric( + 'Total Fuel', a?.total_fuel_cycles ?? 0, b?.total_fuel_cycles ?? 0), + _CompareMetric('Foul Points', a?.foul_points ?? 0, b?.foul_points ?? 0, + lowerIsBetter: true), + _CompareMetric('Death Rate', a?.death_rate ?? 0, b?.death_rate ?? 0, + lowerIsBetter: true, asPercent: true), + _CompareMetric('Defense Rate', a?.defense_rate ?? 0, b?.defense_rate ?? 0, + asPercent: true), + _CompareMetric('Sim RP', (a?.simulated_rp ?? 0).toDouble(), + (b?.simulated_rp ?? 0).toDouble(), + integerLike: true), + ]; + } + + Widget _metricRow(_CompareMetric metric, bool compactMode) { + final cs = Theme.of(context).colorScheme; + final tied = (metric.left - metric.right).abs() < 1e-9; + final leftBetter = metric.lowerIsBetter + ? metric.left < metric.right + : metric.left > metric.right; + + final leftBg = tied + ? cs.surfaceVariant.withOpacity(0.08) + : leftBetter + ? Colors.green.withOpacity(0.14) + : Colors.red.withOpacity(0.08); + final rightBg = tied + ? cs.surfaceVariant.withOpacity(0.08) + : leftBetter + ? Colors.red.withOpacity(0.08) + : Colors.green.withOpacity(0.14); + + final leftBorder = tied + ? cs.outline.withOpacity(0.18) + : leftBetter + ? Colors.green.withOpacity(0.5) + : Colors.red.withOpacity(0.38); + final rightBorder = tied + ? cs.outline.withOpacity(0.18) + : leftBetter + ? Colors.red.withOpacity(0.38) + : Colors.green.withOpacity(0.5); + + final labelWidth = compactMode ? 120.0 : 160.0; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: leftBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: leftBorder), + ), + child: Text( + _formatMetric(metric, metric.left), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: labelWidth, + child: Text( + metric.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w700, + color: cs.onSurface.withOpacity(0.85)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: rightBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: rightBorder), + ), + child: Text( + _formatMetric(metric, metric.right), + textAlign: TextAlign.right, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), ), ], ), ); } + + Widget _teamPanel({ + required String teamNumber, + required int pickIndex, + required TeamStats2026? stats, + required String avatar, + required String fallback, + required List images, + }) { + final rankColor = trophyColorForRank(stats?.rank ?? 0); + return _glassContainer( + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + TeamAvatar( + primaryUrl: avatar, + fallbackUrl: fallback, + teamNumber: teamNumber, + size: 52, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _teamLabel(teamNumber), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w800, fontSize: 14), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _pill('Pick #$pickIndex', Colors.blue), + _pill( + stats != null ? 'Comp #${stats.rank}' : 'Comp #-', + rankColor), + ], + ) + ], + ), + ) + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 112, + width: double.infinity, + child: images.isEmpty + ? const Center(child: Text('No images')) + : ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final image = images[index]; + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => _showImagePreview(image.link), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + image.link, + width: 128, + height: 112, + fit: BoxFit.cover, + ), + ), + ); + }, + ), + ) + ], + ), + ), + ); + } + + Widget _pill(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.34)), + ), + child: Text( + text, + style: TextStyle( + fontWeight: FontWeight.w700, + color: color, + fontSize: 12, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (widget.picks.length < 2) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Container( + padding: const EdgeInsets.all(16), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Text('Not enough teams to compare'), + const SizedBox(height: 12), + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ) + ]), + ), + ); + } + + if (leftIndex == rightIndex) { + rightIndex = (leftIndex + 1) % widget.picks.length; + } + + final aNum = widget.picks[leftIndex].number; + final bNum = widget.picks[rightIndex].number; + final aStats = _statsFor(aNum); + final bStats = _statsFor(bNum); + final metrics = _buildMetrics(aStats, bStats); + + final aAvatar = teamImageUrl(widget.eventYear, widget.eventCode, aNum); + final bAvatar = teamImageUrl(widget.eventYear, widget.eventCode, bNum); + + final screen = MediaQuery.of(context).size; + final dialogWidth = min(1100.0, screen.width * 0.96); + final dialogHeight = min(860.0, screen.height * 0.9); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: const EdgeInsets.all(12), + child: _glassContainer( + radius: 16, + child: SizedBox( + width: dialogWidth, + height: dialogHeight, + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 900; + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _selectTeam(leftSide: true), + icon: const Icon(Icons.groups_2_outlined), + label: Text( + 'Team A: ${_teamLabel(aNum)}', + overflow: TextOverflow.ellipsis, + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _selectTeam(leftSide: false), + icon: const Icon(Icons.groups_2_outlined), + label: Text( + 'Team B: ${_teamLabel(bNum)}', + overflow: TextOverflow.ellipsis, + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton.filledTonal( + tooltip: 'Close', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + if (compact) + Column( + children: [ + _teamPanel( + teamNumber: aNum, + pickIndex: leftIndex + 1, + stats: aStats, + avatar: aAvatar, + fallback: avatarFallback(aNum), + images: teamAImages, + ), + const SizedBox(height: 10), + _teamPanel( + teamNumber: bNum, + pickIndex: rightIndex + 1, + stats: bStats, + avatar: bAvatar, + fallback: avatarFallback(bNum), + images: teamBImages, + ), + ], + ) + else + Row( + children: [ + Expanded( + child: _teamPanel( + teamNumber: aNum, + pickIndex: leftIndex + 1, + stats: aStats, + avatar: aAvatar, + fallback: avatarFallback(aNum), + images: teamAImages, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _teamPanel( + teamNumber: bNum, + pickIndex: rightIndex + 1, + stats: bStats, + avatar: bAvatar, + fallback: avatarFallback(bNum), + images: teamBImages, + ), + ), + ], + ), + const SizedBox(height: 10), + Expanded( + child: _glassContainer( + child: Padding( + padding: const EdgeInsets.all(10), + child: ListView.builder( + itemCount: metrics.length, + itemBuilder: (context, index) => + _metricRow(metrics[index], compact), + ), + ), + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 12, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + GlassActionButton( + icon: const Icon(Icons.thumb_up), + label: Text('I like $aNum'), + color: Colors.green, + onPressed: () => _handleLike(aNum), + ), + GlassActionButton( + icon: const Icon(Icons.thumb_up), + label: Text('I like $bNum'), + color: Colors.green, + onPressed: () => _handleLike(bNum), + ), + ], + ) + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } + + Color trophyColorForRank(int rank) { + if (rank == 1) return Colors.amber; + if (rank == 2) return Colors.grey; + if (rank == 3) return const Color(0xFFcd7f32); + return Theme.of(context).colorScheme.primary; + } + + String teamImageUrl(int year, String eventCode, String teamNumber) { + return 'https://images.weserv.nl/?url=www.thebluealliance.com/avatar/$year/frc$teamNumber.png&w=256&h=256&fit=contain'; + } + + String avatarFallback(String teamNumber) { + return 'https://api.dicebear.com/9.x/identicon/png?seed=frc$teamNumber&size=128'; + } + + Widget _statRowComparison( + String label, double aVal, double bVal, bool invertDeath) { + return const SizedBox.shrink(); + } +} + +class _CompareMetric { + final String label; + final double left; + final double right; + final bool lowerIsBetter; + final bool asPercent; + final bool integerLike; + + const _CompareMetric( + this.label, + this.left, + this.right, { + this.lowerIsBetter = false, + this.asPercent = false, + this.integerLike = false, + }); +} + +class TeamImagesDialog extends StatefulWidget { + final String teamNumber; + final int eventYear; + final String eventCode; + final List rankings; + final int picklistIndex; + + const TeamImagesDialog({ + super.key, + required this.teamNumber, + required this.eventYear, + required this.eventCode, + required this.rankings, + required this.picklistIndex, + }); + + @override + State createState() => _TeamImagesDialogState(); +} + +class _TeamImagesDialogState extends State { + List images = []; + bool isLoading = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => isLoading = true); + try { + final apiService = Provider.of(context, listen: false); + final fetched = await apiService.fetchTeamImages( + widget.eventYear, widget.eventCode, widget.teamNumber); + if (mounted) { + setState(() { + images = fetched; + }); + } + } catch (e) { + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final stats = widget.rankings + .where((t) => t.team_number == widget.teamNumber) + .firstOrNull; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + width: 920, + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Team ${widget.teamNumber} Images', + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + 'Click on an image to enlarge it.', + style: TextStyle( + color: cs.onSurface.withOpacity(0.7), + ), + ), + ], + ), + ), + const Icon(Icons.photo_library_outlined) + ], + ), + const SizedBox(height: 12), + if (isLoading) + const SizedBox( + height: 180, + child: Center(child: CircularProgressIndicator())) + else if (images.isEmpty) + SizedBox( + height: 220, + child: Center(child: Text('No robot images available'))) + else + SizedBox( + height: 430, + child: LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 780 ? 3 : 2; + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.1, + ), + itemCount: images.length, + itemBuilder: (context, i) { + final image = images[i]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) { + return Dialog( + insetPadding: const EdgeInsets.all(24), + backgroundColor: Colors.black87, + child: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + minScale: 0.8, + maxScale: 4.5, + child: Center( + child: Image.network(image.link, + fit: BoxFit.contain), + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close, + color: Colors.white), + ), + ), + ], + ), + ); + }); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network(image.link, fit: BoxFit.cover), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.35) + ], + ), + ), + ), + Positioned( + right: 8, + bottom: 8, + child: Icon( + Icons.zoom_in, + color: Colors.white.withOpacity(0.95), + size: 20, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (stats != null) + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _metricChip('Comp Rank', '#${stats.rank}', cs.primary), + _metricChip('Picklist', '#${widget.picklistIndex}', + Colors.indigo), + _metricChip( + 'OPR', stats.OPR.toStringAsFixed(1), Colors.purple), + ], + ) + else + const SizedBox.shrink(), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close')) + ], + ) + ], + ), + ), + ); + } + + Widget _metricChip(String label, String value, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + '$label: $value', + style: TextStyle( + fontWeight: FontWeight.w700, + color: color, + ), + ), + ); + } } class TeamAvatar extends StatefulWidget { @@ -1485,7 +2614,7 @@ class GlassIconButton extends StatelessWidget { @override Widget build(BuildContext context) { return ClipRRect( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), child: Material( @@ -1496,7 +2625,7 @@ class GlassIconButton extends StatelessWidget { onTap: onPressed, child: Container( width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 6), decoration: BoxDecoration( gradient: LinearGradient( colors: [color.withOpacity(0.12), color.withOpacity(0.06)], @@ -1515,3 +2644,117 @@ class GlassIconButton extends StatelessWidget { ); } } + +class GlassActionButton extends StatelessWidget { + final Widget icon; + final Widget label; + final Color color; + final VoidCallback onPressed; + + const GlassActionButton({ + super.key, + required this.icon, + required this.label, + required this.color, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: Material( + color: color.withOpacity(0.14), + child: InkWell( + onTap: onPressed, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color.withOpacity(0.16), color.withOpacity(0.08)], + ), + border: Border.all(color: color.withOpacity(0.28)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData(color: color), + child: icon, + ), + const SizedBox(width: 8), + DefaultTextStyle.merge( + style: TextStyle( + color: cs.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + child: label, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class GlassRoundButton extends StatelessWidget { + final Color color; + final IconData icon; + final VoidCallback onPressed; + final String tooltip; + + const GlassRoundButton({ + super.key, + required this.color, + required this.icon, + required this.onPressed, + required this.tooltip, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Tooltip( + message: tooltip, + child: ClipOval( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: Material( + color: color.withOpacity(0.18), + child: InkWell( + onTap: onPressed, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [color.withOpacity(0.2), color.withOpacity(0.1)], + ), + border: Border.all(color: color.withOpacity(0.35)), + ), + child: Icon(icon, color: cs.onPrimaryContainer), + ), + ), + ), + ), + ), + ); + } +} + +extension FirstOrNullExtension on Iterable { + E? get firstOrNull { + try { + return isEmpty ? null : first; + } catch (e) { + return null; + } + } +} From 98b891418bbbecca272802712f8a43b592be5cf3 Mon Sep 17 00:00:00 2001 From: Thebest-Fish-Programmer Date: Tue, 17 Mar 2026 15:02:47 -0600 Subject: [PATCH 2/2] Holy Ui improvments and algorithmic sorting --- APP/lib/pages/pick_list_page.dart | 51 ++++++++++++------- .../Flutter/GeneratedPluginRegistrant.swift | 2 + 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/APP/lib/pages/pick_list_page.dart b/APP/lib/pages/pick_list_page.dart index a2c9d41..4b4391a 100644 --- a/APP/lib/pages/pick_list_page.dart +++ b/APP/lib/pages/pick_list_page.dart @@ -834,14 +834,19 @@ class _PicklistPageState extends State { Future _openTeamImages(String teamNumber) async { final eventYear = int.parse(_eventYear); - setState(() {}); + String eventCode = widget.eventCode; + // Extract just the event code without the year + if (eventCode.length > 4) { + eventCode = eventCode.substring(4); + } + showDialog( context: context, builder: (context) { return TeamImagesDialog( teamNumber: teamNumber, eventYear: eventYear, - eventCode: widget.eventCode, + eventCode: eventCode, // Use the cleaned event code rankings: rankings, picklistIndex: picks.indexWhere((p) => p.number == teamNumber) + 1, ); @@ -1370,9 +1375,16 @@ class _QuickCompareDialogState extends State { try { final apiService = Provider.of(context, listen: false); + + // Extract the event code without the year + String eventCode = widget.eventCode; + if (eventCode.length > 4) { + eventCode = eventCode.substring(4); + } + final futures = await Future.wait([ - apiService.fetchTeamImages(widget.eventYear, widget.eventCode, a), - apiService.fetchTeamImages(widget.eventYear, widget.eventCode, b), + apiService.fetchTeamImages(widget.eventYear, eventCode, 'frc$a'), + apiService.fetchTeamImages(widget.eventYear, eventCode, 'frc$b'), apiService.fetchTeamNicknames('frc$a'), apiService.fetchTeamNicknames('frc$b'), ]); @@ -1478,8 +1490,9 @@ class _QuickCompareDialogState extends State { } Future _selectTeam({required bool leftSide}) async { - final currentTeam = - leftSide ? widget.picks[leftIndex].number : widget.picks[rightIndex].number; + final currentTeam = leftSide + ? widget.picks[leftIndex].number + : widget.picks[rightIndex].number; String search = ''; final selected = await showModalBottomSheet( @@ -1534,7 +1547,8 @@ class _QuickCompareDialogState extends State { _teamLabel(team), overflow: TextOverflow.ellipsis, ), - trailing: isSelected ? const Icon(Icons.check) : null, + trailing: + isSelected ? const Icon(Icons.check) : null, onTap: () => Navigator.pop(context, team), ); }, @@ -1562,7 +1576,8 @@ class _QuickCompareDialogState extends State { } else { rightIndex = newIndex; if (leftIndex == rightIndex) { - leftIndex = (rightIndex - 1 + widget.picks.length) % widget.picks.length; + leftIndex = + (rightIndex - 1 + widget.picks.length) % widget.picks.length; } } }); @@ -1607,14 +1622,12 @@ class _QuickCompareDialogState extends State { List<_CompareMetric> _buildMetrics(TeamStats2026? a, TeamStats2026? b) { return [ - _CompareMetric('Comp Rank', (a?.rank ?? 0).toDouble(), - (b?.rank ?? 0).toDouble(), lowerIsBetter: true, integerLike: true), _CompareMetric( - 'Sim Rank', - (a?.simulated_rank ?? 0).toDouble(), + 'Comp Rank', (a?.rank ?? 0).toDouble(), (b?.rank ?? 0).toDouble(), + lowerIsBetter: true, integerLike: true), + _CompareMetric('Sim Rank', (a?.simulated_rank ?? 0).toDouble(), (b?.simulated_rank ?? 0).toDouble(), - lowerIsBetter: true, - integerLike: true), + lowerIsBetter: true, integerLike: true), _CompareMetric('OPR Rank', (a?.OPRRank ?? 0).toDouble(), (b?.OPRRank ?? 0).toDouble(), lowerIsBetter: true, integerLike: true), @@ -1631,8 +1644,8 @@ class _QuickCompareDialogState extends State { _CompareMetric('Teleop Pass', a?.teleop_pass ?? 0, b?.teleop_pass ?? 0), _CompareMetric( 'Auto Fuel', a?.auto_fuel_cycles ?? 0, b?.auto_fuel_cycles ?? 0), - _CompareMetric( - 'Teleop Fuel', a?.teleop_fuel_cycles ?? 0, b?.teleop_fuel_cycles ?? 0), + _CompareMetric('Teleop Fuel', a?.teleop_fuel_cycles ?? 0, + b?.teleop_fuel_cycles ?? 0), _CompareMetric( 'Total Fuel', a?.total_fuel_cycles ?? 0, b?.total_fuel_cycles ?? 0), _CompareMetric('Foul Points', a?.foul_points ?? 0, b?.foul_points ?? 0, @@ -2114,13 +2127,14 @@ class _TeamImagesDialogState extends State { try { final apiService = Provider.of(context, listen: false); final fetched = await apiService.fetchTeamImages( - widget.eventYear, widget.eventCode, widget.teamNumber); + widget.eventYear, widget.eventCode, 'frc${widget.teamNumber}'); if (mounted) { setState(() { images = fetched; }); } } catch (e) { + print(e); } finally { if (mounted) setState(() => isLoading = false); } @@ -2214,7 +2228,8 @@ class _TeamImagesDialogState extends State { top: 8, right: 8, child: IconButton( - onPressed: () => Navigator.pop(context), + onPressed: () => + Navigator.pop(context), icon: const Icon(Icons.close, color: Colors.white), ), diff --git a/APP/macos/Flutter/GeneratedPluginRegistrant.swift b/APP/macos/Flutter/GeneratedPluginRegistrant.swift index 938a345..e386935 100644 --- a/APP/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/APP/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,12 +6,14 @@ import FlutterMacOS import Foundation import flutter_appauth +import path_provider_foundation import shared_preferences_foundation import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))