From 8ab3f53c0d43ba1da44acc900470f2c345833219 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Mon, 5 Jan 2026 01:09:43 +0000 Subject: [PATCH] Add accepted leg edit notification and class leaderboard --- lib/components/calculator/calculator.dart | 108 ++-- .../dashboard/leaderboard_panel.dart | 42 +- .../pages/more/user_profile_page.dart | 142 ++--- lib/components/pages/profile.dart | 32 +- .../pages/traction/traction_page.dart | 287 ++++++++++ .../leg_share_edit_notification_card.dart | 532 ++++++++++++++++++ .../widgets/leg_share_notification_card.dart | 20 +- .../data_service/data_service_leg_share.dart | 12 + .../data_service/data_service_traction.dart | 35 ++ lib/ui/app_shell.dart | 34 +- pubspec.yaml | 2 +- 11 files changed, 1114 insertions(+), 132 deletions(-) create mode 100644 lib/components/widgets/leg_share_edit_notification_card.dart diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index a87a738..ed3de74 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -55,26 +55,19 @@ class _StationAutocompleteState extends State { }, fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) { - textEditingController.value = _controller.value; + textEditingController.value = _controller.value; - return TextField( - controller: textEditingController, - focusNode: focusNode, - textInputAction: TextInputAction.done, - onSubmitted: (_) { - final matches = _findTopMatches(textEditingController.text); - final firstMatch = matches.isEmpty ? null : matches.first; - if (firstMatch == null) return; - _controller.text = firstMatch; - widget.onChanged(firstMatch); - focusNode.unfocus(); // optionally close keyboard - }, - decoration: const InputDecoration( - labelText: 'Select station', - border: OutlineInputBorder(), - ), - ); - }, + return TextField( + controller: textEditingController, + focusNode: focusNode, + textInputAction: TextInputAction.done, + onSubmitted: (_) => onFieldSubmitted(), + decoration: const InputDecoration( + labelText: 'Select station', + border: OutlineInputBorder(), + ), + ); + }, ); } @@ -181,6 +174,14 @@ class _RouteCalculatorState extends State { } Future _calculateRoute(List stations) async { + final cleaned = stations.where((s) => s.trim().isNotEmpty).toList(); + if (cleaned.length < 2) { + setState(() { + _routeResult = null; + _errorMessage = 'Add at least two stations before calculating.'; + }); + return; + } setState(() { _errorMessage = null; _routeResult = null; @@ -188,7 +189,7 @@ class _RouteCalculatorState extends State { final api = context.read(); // context is valid here try { final res = await api.post('/route/distance2', { - 'route': stations.where((s) => s.trim().isNotEmpty).toList(), + 'route': cleaned, }); if (res is Map && res['error'] == false) { @@ -232,11 +233,28 @@ class _RouteCalculatorState extends State { }); } + void _clearCalculator() { + final data = context.read(); + setState(() { + data.stations = ['']; + _routeResult = null; + _errorMessage = null; + }); + } + @override Widget build(BuildContext context) { final data = context.watch(); return Column( children: [ + Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: 'Clear calculator', + icon: const Icon(Icons.clear_all), + onPressed: _clearCalculator, + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Wrap( @@ -366,28 +384,34 @@ class _RouteCalculatorState extends State { spacing: 12, runSpacing: 8, children: [ - ElevatedButton.icon( - icon: const Icon(Icons.swap_horiz), - label: const Text('Reverse route'), - onPressed: () async { - setState(() { - data.stations = data.stations.reversed.toList(); - }); - await _calculateRoute(data.stations); - }, - ), - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text('Add Station'), - onPressed: _addStation, - ), - ElevatedButton.icon( - icon: const Icon(Icons.route), - label: const Text('Calculate Route'), - onPressed: () async { - await _calculateRoute(data.stations); - }, - ), + ...(() { + final reverseButton = ElevatedButton.icon( + icon: const Icon(Icons.swap_horiz), + label: const Text('Reverse route'), + onPressed: () async { + setState(() { + data.stations = data.stations.reversed.toList(); + }); + await _calculateRoute(data.stations); + }, + ); + final addButton = ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('Add Station'), + onPressed: _addStation, + ); + final calculateButton = ElevatedButton.icon( + icon: const Icon(Icons.route), + label: const Text('Calculate Route'), + onPressed: () async { + await _calculateRoute(data.stations); + }, + ); + final isMobile = MediaQuery.of(context).size.width < 600; + return isMobile + ? [addButton, reverseButton, calculateButton] + : [reverseButton, addButton, calculateButton]; + })(), ], ), ), diff --git a/lib/components/dashboard/leaderboard_panel.dart b/lib/components/dashboard/leaderboard_panel.dart index 9ef1672..88e0b39 100644 --- a/lib/components/dashboard/leaderboard_panel.dart +++ b/lib/components/dashboard/leaderboard_panel.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; @@ -130,14 +132,38 @@ class _LeaderboardPanelState extends State { fontWeight: FontWeight.w700, ), ), - trailing: Text( - distanceUnits.format( - leaderboard[index].mileage, - decimals: 1, - ), - style: textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + trailing: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + Text( + distanceUnits.format( + leaderboard[index].mileage, + decimals: 1, + ), + style: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Builder( + builder: (ctx) => IconButton( + tooltip: 'View profile', + icon: const Icon(Icons.open_in_new, size: 20), + onPressed: () { + final auth = ctx.read(); + final userId = leaderboard[index].userId; + if (auth.userId == userId) { + ctx.go('/more/profile'); + } else { + ctx.pushNamed( + 'user-profile', + queryParameters: {'user_id': userId}, + ); + } + }, + ), + ), + ], ), ), if (index != leaderboard.length - 1) const Divider(height: 12), diff --git a/lib/components/pages/more/user_profile_page.dart b/lib/components/pages/more/user_profile_page.dart index 62d21aa..a052983 100644 --- a/lib/components/pages/more/user_profile_page.dart +++ b/lib/components/pages/more/user_profile_page.dart @@ -17,11 +17,13 @@ class UserProfilePage extends StatefulWidget { } class _UserProfilePageState extends State { + static const int _pageSize = 22; UserProfileDetail? _profile; List _legs = const []; bool _loading = false; bool _loadingMore = false; bool _hasMore = false; + bool _lastFetchReturnedData = true; Friendship? _friendship; bool _actionsLoading = false; @@ -39,9 +41,9 @@ class _UserProfilePageState extends State { final userId = _userId; if (userId == null || userId.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No user selected.')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('No user selected.'))); context.pop(); } return; @@ -66,7 +68,8 @@ class _UserProfilePageState extends State { setState(() { _profile = profile; _legs = legs; - _hasMore = legs.length >= 25; + _lastFetchReturnedData = legs.isNotEmpty; + _hasMore = _lastFetchReturnedData && _legs.length >= _pageSize; _friendship = friendship; }); } finally { @@ -83,11 +86,13 @@ class _UserProfilePageState extends State { final more = await data.fetchUserLegs( userId: userId, offset: _legs.length, + limit: _pageSize, ); if (!mounted) return; setState(() { _legs = [..._legs, ...more]; - _hasMore = more.length >= 25; + _lastFetchReturnedData = more.isNotEmpty; + _hasMore = _lastFetchReturnedData && _legs.length >= _pageSize; }); } finally { if (mounted) setState(() => _loadingMore = false); @@ -110,7 +115,9 @@ class _UserProfilePageState extends State { final mileage = profile?.mileage; final privacy = profile?.privacyInfo; final mileageHidden = - (mileage == null || mileage == 0) && privacy != null && privacy.isNotEmpty; + (mileage == null || mileage == 0) && + privacy != null && + privacy.isNotEmpty; return Card( child: Padding( padding: const EdgeInsets.all(12.0), @@ -152,9 +159,7 @@ class _UserProfilePageState extends State { return const SizedBox.shrink(); } final topTen = [...profile.topLocos] - ..sort( - (a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0), - ); + ..sort((a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0)); final displayLocos = topTen.take(10).toList(); return Card( child: Padding( @@ -177,15 +182,14 @@ class _UserProfilePageState extends State { final mileage = loco.mileage ?? 0; return ListTile( leading: CircleAvatar( - backgroundColor: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.1), + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), child: Text( '${index + 1}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), ), title: Text( @@ -195,10 +199,9 @@ class _UserProfilePageState extends State { subtitle: Text(loco.locoClass), trailing: Text( '${mileage.toStringAsFixed(1)} mi', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(fontWeight: FontWeight.w700), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), ); }, @@ -211,8 +214,9 @@ class _UserProfilePageState extends State { List _buildLegsWithDividers( BuildContext context, - List legs, - ) { + List legs, { + required bool showEditButton, + }) { final widgets = []; String? currentDate; final dayLegs = []; @@ -225,15 +229,21 @@ class _UserProfilePageState extends State { padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text( date, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), ), ), ); widgets.add(const Divider()); widgets.addAll( - dayLegs.map((leg) => LegCard(leg: leg, showDate: false)), + dayLegs.map( + (leg) => LegCard( + leg: leg, + showDate: false, + showEditButton: showEditButton, + ), + ), ); dayLegs.clear(); } @@ -295,7 +305,9 @@ class _UserProfilePageState extends State { break; case 'pending': final isRequester = status.requesterId == auth.userId; - label = isRequester ? 'Pending (you sent)' : 'Pending (needs your reply)'; + label = isRequester + ? 'Pending (you sent)' + : 'Pending (needs your reply)'; color = Colors.orange; break; case 'blocked': @@ -325,10 +337,9 @@ class _UserProfilePageState extends State { } Widget _buildActions(Friendship status, AuthService auth) { - final isSelf = status.addresseeId == auth.userId || status.requesterId == auth.userId; - if (isSelf) { - return const Text('This is you.'); - } + final targetUserId = _userId; + final isSelf = targetUserId != null && targetUserId == auth.userId; + if (isSelf) return const Text('This is you.'); final isRequester = status.requesterId == auth.userId; final id = status.id; final buttons = []; @@ -350,10 +361,12 @@ class _UserProfilePageState extends State { onPressed: _actionsLoading ? null : () => run(() async { - final updated = await data.requestFriendship(status.addresseeId); - if (!mounted) return; - setState(() => _friendship = updated); - }), + final updated = await data.requestFriendship( + status.addresseeId, + ); + if (!mounted) return; + setState(() => _friendship = updated); + }), icon: const Icon(Icons.person_add), label: const Text('Send friend request'), ), @@ -365,10 +378,12 @@ class _UserProfilePageState extends State { onPressed: _actionsLoading || id == null || id.isEmpty ? null : () => run(() async { - await data.cancelFriendship(id); - if (!mounted) return; - setState(() => _friendship = status.copyWith(status: 'none')); - }), + await data.cancelFriendship(id); + if (!mounted) return; + setState( + () => _friendship = status.copyWith(status: 'none'), + ); + }), child: const Text('Cancel request'), ), ); @@ -378,10 +393,10 @@ class _UserProfilePageState extends State { onPressed: _actionsLoading || id == null || id.isEmpty ? null : () => run(() async { - final updated = await data.acceptFriendship(id); - if (!mounted) return; - setState(() => _friendship = updated); - }), + final updated = await data.acceptFriendship(id); + if (!mounted) return; + setState(() => _friendship = updated); + }), child: const Text('Accept'), ), ); @@ -390,10 +405,10 @@ class _UserProfilePageState extends State { onPressed: _actionsLoading || id == null || id.isEmpty ? null : () => run(() async { - final updated = await data.rejectFriendship(id); - if (!mounted) return; - setState(() => _friendship = updated); - }), + final updated = await data.rejectFriendship(id); + if (!mounted) return; + setState(() => _friendship = updated); + }), child: const Text('Reject'), ), ); @@ -404,10 +419,10 @@ class _UserProfilePageState extends State { onPressed: _actionsLoading || id == null || id.isEmpty ? null : () => run(() async { - await data.deleteFriendship(id); - if (!mounted) return; - setState(() => _friendship = status.copyWith(status: 'none')); - }), + await data.deleteFriendship(id); + if (!mounted) return; + setState(() => _friendship = status.copyWith(status: 'none')); + }), icon: const Icon(Icons.person_remove), label: const Text('Unfriend'), ), @@ -416,17 +431,14 @@ class _UserProfilePageState extends State { if (buttons.isEmpty) return const SizedBox.shrink(); - return Wrap( - spacing: 8, - runSpacing: 8, - children: buttons, - ); + return Wrap(spacing: 8, runSpacing: 8, children: buttons); } @override Widget build(BuildContext context) { final auth = context.watch(); final theme = Theme.of(context); + final canEdit = auth.userId != null && auth.userId == _userId; return Scaffold( appBar: AppBar( title: const Text('User profile'), @@ -455,10 +467,7 @@ class _UserProfilePageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Entries', - style: theme.textTheme.titleMedium, - ), + Text('Entries', style: theme.textTheme.titleMedium), if (_loading) const SizedBox( width: 16, @@ -478,13 +487,17 @@ class _UserProfilePageState extends State { else if (_legs.isEmpty) Text( (_profile?.privacyInfo.isNotEmpty ?? false) - ? 'Legs hidden due to privacy settings.' + ? 'Hidden due to privacy settings.' : 'No entries found.', ) else ...[ - ..._buildLegsWithDividers(context, _legs), + ..._buildLegsWithDividers( + context, + _legs, + showEditButton: canEdit, + ), const SizedBox(height: 8), - if (_hasMore || _loadingMore) + if ((_hasMore || _loadingMore) && _legs.isNotEmpty) Align( alignment: Alignment.center, child: OutlinedButton.icon( @@ -493,8 +506,9 @@ class _UserProfilePageState extends State { ? const SizedBox( height: 14, width: 14, - child: - CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + ), ) : const Icon(Icons.expand_more), label: Text( diff --git a/lib/components/pages/profile.dart b/lib/components/pages/profile.dart index 2499d37..02f02c1 100644 --- a/lib/components/pages/profile.dart +++ b/lib/components/pages/profile.dart @@ -465,7 +465,7 @@ class _ProfilePageState extends State { subtitle: user.username.isNotEmpty ? Text('@${user.username}') : null, trailing: TextButton( - onPressed: () => context.goNamed( + onPressed: () => context.pushNamed( 'user-profile', extra: user, queryParameters: {'user_id': user.userId}, @@ -745,16 +745,26 @@ class _ProfilePageState extends State { subtitle: otherUser?.username.isNotEmpty == true ? Text('@${otherUser!.username}') : null, - trailing: TextButton( - onPressed: () { - final user = otherUser; - if (user != null) { - _loadStatus(user); - } - }, - child: const Text('Manage'), - ), - ), + trailing: TextButton( + onPressed: () { + final user = otherUser; + if (user != null) { + final auth = context.read(); + final isSelf = auth.userId == user.userId; + if (isSelf) { + context.go('/more/profile'); + } else { + context.pushNamed( + 'user-profile', + extra: user, + queryParameters: {'user_id': user.userId}, + ); + } + } + }, + child: const Text('View'), + ), + ), ); }), ], diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 0d149f3..e300036 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -33,6 +33,14 @@ class _TractionPageState extends State { String? _classStatsError; String? _classStatsForClass; Map? _classStats; + bool _showClassLeaderboardPanel = false; + bool _classLeaderboardLoading = false; + String? _classLeaderboardError; + String? _classLeaderboardForClass; + String? _classFriendsLeaderboardForClass; + List _classLeaderboard = []; + List _classFriendsLeaderboard = []; + _ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global; final Map _dynamicControllers = {}; final Map _enumSelections = {}; @@ -211,6 +219,13 @@ class _TractionPageState extends State { _classStats = null; _classStatsError = null; _classStatsForClass = null; + _showClassLeaderboardPanel = false; + _classLeaderboard = []; + _classFriendsLeaderboard = []; + _classLeaderboardError = null; + _classLeaderboardForClass = null; + _classFriendsLeaderboardForClass = null; + _classLeaderboardScope = _ClassLeaderboardScope.global; }); _refreshTraction(); } @@ -223,6 +238,7 @@ class _TractionPageState extends State { }); } _refreshClassStatsIfOpen(); + _refreshClassLeaderboardIfOpen(); } List _activeEventFields(List fields) { @@ -405,6 +421,7 @@ class _TractionPageState extends State { }); _refreshTraction(); _refreshClassStatsIfOpen(immediate: true); + _refreshClassLeaderboardIfOpen(immediate: true); }, ), ), @@ -518,6 +535,19 @@ class _TractionPageState extends State { ), ), ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + sliver: SliverToBoxAdapter( + child: AnimatedCrossFade( + crossFadeState: (_showClassLeaderboardPanel && _hasClassQuery) + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: _buildClassLeaderboardCard(context), + secondChild: const SizedBox.shrink(), + ), + ), + ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), sliver: _buildTractionSliver(context, data, traction), @@ -587,6 +617,17 @@ class _TractionPageState extends State { _showClassStatsPanel ? 'Hide class stats' : 'Class stats', ), ); + final classLeaderboardButton = !_hasClassQuery + ? null + : FilledButton.tonalIcon( + onPressed: _toggleClassLeaderboardPanel, + icon: Icon( + _showClassLeaderboardPanel ? Icons.emoji_events : Icons.leaderboard, + ), + label: Text( + _showClassLeaderboardPanel ? 'Hide class leaderboard' : 'Class leaderboard', + ), + ); final newTractionButton = !isElevated ? null @@ -611,12 +652,14 @@ class _TractionPageState extends State { final desktopActions = [ refreshButton, if (classStatsButton != null) classStatsButton, + if (classLeaderboardButton != null) classLeaderboardButton, if (newTractionButton != null) newTractionButton, ]; final mobileActions = [ if (newTractionButton != null) newTractionButton, if (classStatsButton != null) classStatsButton, + if (classLeaderboardButton != null) classLeaderboardButton, refreshButton, ]; @@ -944,6 +987,42 @@ class _TractionPageState extends State { return total; } + Widget _placementBadge(BuildContext context, int index) { + const size = 32.0; + const iconSize = 18.0; + if (index == 0) { + return CircleAvatar( + radius: size / 2, + backgroundColor: Colors.amber.shade400, + child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize), + ); + } + if (index == 1) { + return CircleAvatar( + radius: size / 2, + backgroundColor: Colors.blueGrey.shade200, + child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize), + ); + } + if (index == 2) { + return CircleAvatar( + radius: size / 2, + backgroundColor: Colors.brown.shade300, + child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize), + ); + } + return CircleAvatar( + radius: size / 2, + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + child: Text( + '${index + 1}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ); + } + Color _statusColor(String status, ColorScheme scheme) { final key = status.toLowerCase(); if (key.contains('scrap')) return Colors.red.shade600; @@ -1150,4 +1229,212 @@ class _TractionPageState extends State { ), ); } + + Future _toggleClassLeaderboardPanel() async { + if (!_hasClassQuery) return; + final targetState = !_showClassLeaderboardPanel; + setState(() { + _showClassLeaderboardPanel = targetState; + }); + if (targetState) { + await _loadClassLeaderboard(friends: _classLeaderboardScope == _ClassLeaderboardScope.friends); + } + } + + void _refreshClassLeaderboardIfOpen({bool immediate = false}) { + if (!_showClassLeaderboardPanel || !_hasClassQuery) return; + final query = (_selectedClass ?? _classController.text).trim(); + final scope = _classLeaderboardScope; + final currentData = scope == _ClassLeaderboardScope.global + ? _classLeaderboard + : _classFriendsLeaderboard; + final currentClass = scope == _ClassLeaderboardScope.global + ? _classLeaderboardForClass + : _classFriendsLeaderboardForClass; + if (!immediate && currentClass == query && currentData.isNotEmpty) { + return; + } + _loadClassLeaderboard( + friends: scope == _ClassLeaderboardScope.friends, + ); + } + + Future _loadClassLeaderboard({required bool friends}) async { + final query = (_selectedClass ?? _classController.text).trim(); + if (query.isEmpty) return; + final currentClass = friends ? _classFriendsLeaderboardForClass : _classLeaderboardForClass; + final currentData = friends ? _classFriendsLeaderboard : _classLeaderboard; + if (currentClass == query && currentData.isNotEmpty) return; + setState(() { + _classLeaderboardLoading = true; + _classLeaderboardError = null; + if (friends && _classFriendsLeaderboardForClass != query) { + _classFriendsLeaderboard = []; + } else if (!friends && _classLeaderboardForClass != query) { + _classLeaderboard = []; + } + }); + try { + final data = context.read(); + final leaderboard = await data.fetchClassLeaderboard( + query, + friends: friends, + ); + if (!mounted) return; + setState(() { + if (friends) { + _classFriendsLeaderboard = leaderboard; + _classFriendsLeaderboardForClass = query; + } else { + _classLeaderboard = leaderboard; + _classLeaderboardForClass = query; + } + _classLeaderboardError = null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _classLeaderboardError = 'Failed to load class leaderboard: $e'; + }); + } finally { + if (mounted) { + setState(() { + _classLeaderboardLoading = false; + }); + } + } + } + + Widget _buildClassLeaderboardCard(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final distanceUnits = context.watch(); + final leaderboard = _classLeaderboardScope == _ClassLeaderboardScope.global + ? _classLeaderboard + : _classFriendsLeaderboard; + final loading = _classLeaderboardLoading; + final error = _classLeaderboardError; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + (_selectedClass ?? _classController.text).trim().isEmpty + ? 'Class leaderboard' + : '${(_selectedClass ?? _classController.text).trim()} leaderboard', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + SegmentedButton<_ClassLeaderboardScope>( + segments: const [ + ButtonSegment( + value: _ClassLeaderboardScope.global, + label: Text('Global'), + ), + ButtonSegment( + value: _ClassLeaderboardScope.friends, + label: Text('Friends'), + ), + ], + selected: {_classLeaderboardScope}, + onSelectionChanged: (vals) async { + if (vals.isEmpty) return; + final selected = vals.first; + setState(() => _classLeaderboardScope = selected); + if (selected == _ClassLeaderboardScope.friends && + _classFriendsLeaderboard.isEmpty && + !_classLeaderboardLoading) { + await _loadClassLeaderboard(friends: true); + } else if (selected == _ClassLeaderboardScope.global && + _classLeaderboard.isEmpty && + !_classLeaderboardLoading) { + await _loadClassLeaderboard(friends: false); + } + }, + style: SegmentedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Refresh leaderboard', + icon: const Icon(Icons.refresh), + onPressed: () => _loadClassLeaderboard( + friends: _classLeaderboardScope == _ClassLeaderboardScope.friends, + ), + ), + ], + ), + const SizedBox(height: 12), + if (loading) + Row( + children: const [ + SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Loading leaderboard...'), + ], + ) + else if (error != null) + Text( + error, + style: TextStyle(color: scheme.error), + ) + else if (leaderboard.isEmpty) + const Text('No leaderboard data yet.') + else + Column( + children: [ + for (int i = 0; i < leaderboard.length; i++) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: _placementBadge(context, i), + title: Text( + leaderboard[i].userFullName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + trailing: Text( + distanceUnits.format( + leaderboard[i].mileage, + decimals: 1, + ), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + onTap: () { + final auth = context.read(); + final userId = leaderboard[i].userId; + if (auth.userId == userId) { + context.go('/more/profile'); + } else { + context.pushNamed( + 'user-profile', + queryParameters: {'user_id': userId}, + ); + } + }, + ), + if (i != leaderboard.length - 1) const Divider(height: 12), + ], + ], + ), + ], + ), + ), + ); + } } + +enum _ClassLeaderboardScope { global, friends } diff --git a/lib/components/widgets/leg_share_edit_notification_card.dart b/lib/components/widgets/leg_share_edit_notification_card.dart new file mode 100644 index 0000000..87a94b5 --- /dev/null +++ b/lib/components/widgets/leg_share_edit_notification_card.dart @@ -0,0 +1,532 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class LegShareEditNotificationCard extends StatefulWidget { + const LegShareEditNotificationCard({super.key, required this.notification}); + + final UserNotification notification; + + @override + State createState() => _LegShareEditNotificationCardState(); +} + +class _LegShareEditNotificationCardState extends State { + Map? _changes; + int? _legId; + int? _shareId; + Leg? _currentLeg; + bool _loading = false; + + static const int _summaryLimit = 3; + + @override + void initState() { + super.initState(); + _parseNotification(); + } + + @override + void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.notification != widget.notification) { + _parseNotification(); + } + } + + void _parseNotification() { + final rawBody = widget.notification.body.trim(); + + // Reset + _shareId = null; + _legId = null; + _currentLeg = null; + _changes = null; + + final parsed = _decodeBody(rawBody); + if (parsed != null) { + _shareId = _parseInt(parsed['share_id']); + _legId = _parseInt(parsed['leg_id']); + final accepted = _asStringKeyedMap(parsed['accepted_changes']); + if (accepted != null) { + _changes = accepted; + } + } + + // Fallback: extract share_id from raw string if still missing. + _shareId ??= _extractShareId(rawBody); + } + + int? _parseInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value.trim()); + return null; + } + + Map? _decodeBody(String rawBody) { + final attempts = [ + rawBody, + _stripWrappingQuotes(rawBody), + _replaceSingleQuotes(rawBody), + ].where((s) => s.trim().isNotEmpty).toSet(); + + for (final attempt in attempts) { + final parsed = _decodeJsonToMap(attempt); + if (parsed != null) return parsed; + } + return null; + } + + Map? _decodeJsonToMap(String source) { + dynamic parsed = source; + for (int i = 0; i < 3 && parsed is String; i++) { + try { + parsed = jsonDecode(parsed); + } catch (e) { + parsed = null; + break; + } + } + if (parsed is Map) { + final map = parsed.map((k, v) => MapEntry(k.toString(), v)); + return map; + } + return null; + } + + String _stripWrappingQuotes(String input) { + final trimmed = input.trim(); + if (trimmed.length >= 2 && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")))) { + return trimmed.substring(1, trimmed.length - 1); + } + return input; + } + + String _replaceSingleQuotes(String input) { + if (!input.contains("'")) return input; + return input.replaceAll(RegExp(r"(?? _asStringKeyedMap(dynamic value) { + if (value is Map) { + return value.map((k, v) => MapEntry(k.toString(), v)); + } + if (value is String && value.trim().isNotEmpty) { + for (final attempt in [value, _replaceSingleQuotes(value)]) { + try { + final decoded = jsonDecode(attempt); + if (decoded is Map) { + return decoded.map((k, v) => MapEntry(k.toString(), v)); + } + } catch (_) { + // Ignore; handled by caller. + } + } + } + return null; + } + + int? _extractShareId(String raw) { + final patterns = [ + RegExp(r'"share_id"\s*:\s*(\d+)'), + RegExp(r"'share_id'\s*:\s*(\d+)"), + RegExp(r'share_id\s*:\s*(\d+)'), + RegExp(r'"share_id"\s*:\s*"(\d+)"'), + ]; + for (final pattern in patterns) { + final match = pattern.firstMatch(raw); + if (match != null) { + final parsed = int.tryParse(match.group(1)!); + return parsed; + } + } + return null; + } + + Future _loadLegIdIfNeeded() async { + if (_legId != null) return; + if (_shareId == null) { + return; + } + try { + final share = await context.read().fetchLegShare(_shareId!.toString()); + if (!mounted) return; + _legId = share?.entry.id; + _currentLeg ??= _findCurrentLeg(_legId); + } catch (e) { + } + } + + Leg? _findCurrentLeg(int? legId) { + if (legId == null) return null; + final data = context.read(); + try { + return data.legs.firstWhere((l) => l.id == legId); + } catch (_) { + return null; + } + } + + + @override + Widget build(BuildContext context) { + final changes = _changes; + if (changes == null || changes.isEmpty) { + return const Text('No changes supplied.'); + } + final entries = changes.entries.toList(); + final shown = entries.take(_summaryLimit).toList(); + final remaining = entries.length - shown.length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...shown.map((e) => _changePreview(context, e)), + if (remaining > 0) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text('+$remaining others…', style: Theme.of(context).textTheme.bodySmall), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton( + onPressed: _loading ? null : () => _openDrawer(changes), + child: const Text('View changes'), + ), + TextButton( + onPressed: _loading ? null : _dismiss, + child: const Text('Dismiss changes'), + ), + ], + ), + ], + ); + } + + Widget _changePreview(BuildContext context, MapEntry change) { + final key = _prettyField(change.key); + final value = change.value; + String display; + if (change.key == 'locos' && value is List) { + display = '${value.length} traction update${value.length == 1 ? '' : 's'}'; + } else { + display = _stringify(value); + } + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text('$key: $display'), + ); + } + + String _prettyField(String raw) { + switch (raw) { + case 'leg_notes': + return 'Notes'; + case 'locos': + return 'Traction'; + default: + return raw.replaceAll('_', ' '); + } + } + + dynamic _currentValueForField(Leg leg, String key) { + switch (key) { + case 'leg_begin_time': + return leg.beginTime; + case 'leg_end_time': + return leg.endTime; + case 'leg_origin_time': + return leg.originTime; + case 'leg_destination_time': + return leg.destinationTime; + case 'leg_notes': + return leg.notes; + case 'leg_headcode': + return leg.headcode; + case 'leg_network': + return leg.network; + case 'leg_start': + return leg.start; + case 'leg_end': + return leg.end; + case 'leg_origin': + return leg.origin; + case 'leg_destination': + return leg.destination; + case 'leg_route': + return leg.route; + case 'leg_mileage': + return leg.mileage; + case 'leg_begin_delay': + return leg.beginDelayMinutes; + case 'leg_end_delay': + return leg.endDelayMinutes; + case 'locos': + return leg.locos; + default: + return null; + } + } + + Widget _buildChangeValueWidget( + String key, + dynamic newValue, + Leg? currentLeg, + Widget Function(List) buildLocos, + ) { + final currentValue = currentLeg == null ? null : _currentValueForField(currentLeg, key); + if (key == 'locos' && newValue is List) { + final currentCount = (currentValue is List) ? currentValue.length : 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Current: $currentCount locos'), + const SizedBox(height: 4), + const Text('New:'), + buildLocos(newValue), + ], + ); + } + + final currentStr = _stringify(currentValue); + final newStr = _stringify(newValue); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(currentStr, maxLines: 3, overflow: TextOverflow.ellipsis)), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6.0), + child: Icon(Icons.arrow_right_alt, size: 18), + ), + Expanded(child: Text(newStr, maxLines: 3, overflow: TextOverflow.ellipsis)), + ], + ); + } + + String _stringify(dynamic value) { + if (value is DateTime) return value.toIso8601String(); + if (value == null) return '—'; + if (value is List || value is Map) { + return jsonEncode(value); + } + return value.toString(); + } + + Future _openDrawer(Map changes) async { + setState(() => _loading = true); + await _loadLegIdIfNeeded(); + _currentLeg ??= _findCurrentLeg(_legId); + if (!mounted) return; + setState(() => _loading = false); + final legId = _legId; + if (legId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Unable to load shared leg.')), + ); + return; + } + + final selected = Map.fromEntries( + changes.keys.map((k) => MapEntry(k, false)), + ); + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setSheetState) { + Future apply() async { + final payload = {}; + for (final entry in changes.entries) { + if (selected[entry.key] == true) { + payload[entry.key] = entry.value; + } + } + if (payload.isEmpty) { + Navigator.of(context).pop(); + return; + } + final messenger = ScaffoldMessenger.of(context); + setSheetState(() => _loading = true); + try { + final data = context.read(); + await data.applyLegPartialUpdates( + legId: legId, + updates: payload, + ); + if (!context.mounted) return; + await data.dismissNotifications([widget.notification.id]); + if (!context.mounted) return; + messenger.showSnackBar( + const SnackBar(content: Text('Changes applied.')), + ); + Navigator.of(context).pop(); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to apply changes: $e')), + ); + } finally { + setSheetState(() => _loading = false); + } + } + + Widget buildLocos(List locos) { + final parsed = locos + .whereType() + .map((e) => e.map((k, v) => MapEntry(k.toString(), v))) + .toList(); + parsed.sort((a, b) => (b['alloc_pos'] ?? 0).compareTo(a['alloc_pos'] ?? 0)); + final leading = parsed.where((e) => (e['alloc_pos'] ?? 0) > 0).toList(); + final trailing = parsed.where((e) => (e['alloc_pos'] ?? 0) <= 0).toList(); + + List chipsFor(List> list) { + return list + .map( + (loco) => Chip( + backgroundColor: + (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true) + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) + : Theme.of(context).colorScheme.surfaceContainerHighest, + label: Text('Loco ${loco['loco_id'] ?? '?'} (pos ${loco['alloc_pos'] ?? '?'}'), + avatar: Icon( + Icons.train, + size: 16, + color: (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true) + ? Theme.of(context).colorScheme.primary + : Theme.of(context).hintColor, + ), + ), + ) + .toList(); + } + + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...chipsFor(leading), + if (leading.isNotEmpty && trailing.isNotEmpty) + const SizedBox( + width: 24, + child: Center(child: Divider(height: 16)), + ), + ...chipsFor(trailing), + ], + ); + } + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Review changes', style: Theme.of(context).textTheme.titleMedium), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton( + onPressed: () => setSheetState(() { + for (final key in selected.keys) { + selected[key] = true; + } + }), + child: const Text('Select all'), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ), + const SizedBox(height: 8), + ...changes.entries.map((entry) { + final key = entry.key; + final prettyKey = _prettyField(key); + final value = entry.value; + final currentLeg = _currentLeg ?? _findCurrentLeg(_legId); + final valueWidget = _buildChangeValueWidget( + key, + value, + currentLeg, + buildLocos, + ); + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: selected[key] ?? false, + onChanged: (v) => setSheetState(() { + selected[key] = v ?? false; + }), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(prettyKey, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + valueWidget, + ], + ), + ), + ], + ), + ); + }), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: _loading ? null : apply, + child: _loading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Apply changes'), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Future _dismiss() async { + await context.read().dismissNotifications([widget.notification.id]); + } +} diff --git a/lib/components/widgets/leg_share_notification_card.dart b/lib/components/widgets/leg_share_notification_card.dart index 5e13fe3..7206f4f 100644 --- a/lib/components/widgets/leg_share_notification_card.dart +++ b/lib/components/widgets/leg_share_notification_card.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -14,8 +15,8 @@ class LegShareNotificationCard extends StatelessWidget { @override Widget build(BuildContext context) { final data = context.read(); - final legShareId = notification.body.trim(); - if (legShareId.isEmpty) { + final legShareId = _extractLegShareId(notification.body); + if (legShareId == null) { return const Text('Invalid leg share notification.'); } final future = data.fetchLegShare(legShareId); @@ -144,4 +145,19 @@ class LegShareNotificationCard extends StatelessWidget { final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts'; router.go(path, extra: target); } + + String? _extractLegShareId(String rawBody) { + final trimmed = rawBody.trim(); + if (trimmed.isEmpty) return null; + if (RegExp(r'^[0-9]+$').hasMatch(trimmed)) return trimmed; + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map) { + final id = decoded['share_id'] ?? decoded['leg_share_id']; + final str = id?.toString() ?? ''; + if (RegExp(r'^[0-9]+$').hasMatch(str)) return str; + } + } catch (_) {} + return null; + } } diff --git a/lib/services/data_service/data_service_leg_share.dart b/lib/services/data_service/data_service_leg_share.dart index e55fddd..27dc137 100644 --- a/lib/services/data_service/data_service_leg_share.dart +++ b/lib/services/data_service/data_service_leg_share.dart @@ -88,6 +88,18 @@ extension DataServiceLegShare on DataService { return null; } + Future applyLegPartialUpdates({ + required int legId, + required Map updates, + }) async { + try { + await api.post('/leg/update/$legId/partial', updates); + } catch (e) { + debugPrint('Failed to apply partial updates for leg $legId: $e'); + rethrow; + } + } + int? _parseNullableInt(dynamic value) { if (value is int) return value; if (value is num) return value.toInt(); diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart index f5d5da5..048f792 100644 --- a/lib/services/data_service/data_service_traction.dart +++ b/lib/services/data_service/data_service_traction.dart @@ -179,4 +179,39 @@ extension DataServiceTraction on DataService { } return null; } + + Future> fetchClassLeaderboard( + String locoClass, { + bool friends = false, + }) async { + try { + final path = Uri.encodeComponent(locoClass); + final suffix = friends ? '/friends' : ''; + final json = await api.get('/stats/class/$path/leaderboard$suffix'); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['leaderboard', 'data', 'items', 'results']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + return list + ?.whereType() + .map((e) => LeaderboardEntry.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList() ?? + const []; + } catch (e) { + debugPrint( + 'Failed to fetch class leaderboard for $locoClass (friends=$friends): $e', + ); + return const []; + } + } } diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index a2241b6..b06b5f7 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -22,6 +22,7 @@ import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; +import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; @@ -731,7 +732,8 @@ class _MyHomePageState extends State { final item = notifications[index]; final isFriendRequest = _isFriendRequestNotification(item); final isLegShare = _isLegShareNotification(item); - final isSpecial = isFriendRequest || isLegShare; + final isLegShareEdit = _isLegShareEditNotification(item); + final isSpecial = isFriendRequest || isLegShare || isLegShareEdit; return Card( child: Padding( padding: const EdgeInsets.all(12.0), @@ -754,10 +756,12 @@ class _MyHomePageState extends State { ), const SizedBox(height: 4), Text( - isFriendRequest || isLegShare + isSpecial ? isFriendRequest ? 'Accept to share entries' - : 'Shared entry details below.' + : isLegShareEdit + ? 'Shared leg edits below.' + : 'Shared entry details below.' : item.body, style: Theme.of(context).textTheme.bodyMedium, ), @@ -801,6 +805,13 @@ class _MyHomePageState extends State { notification: item, ), ), + if (_isLegShareEditNotification(item)) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: LegShareEditNotificationCard( + notification: item, + ), + ), if (isLegShare) Padding( padding: const EdgeInsets.only(top: 8.0), @@ -890,7 +901,22 @@ class _MyHomePageState extends State { bool _isLegShareNotification(UserNotification notification) { final channel = notification.channel.trim().toLowerCase(); final type = notification.type.trim().toLowerCase(); - return channel.contains('leg_share') || type.contains('leg_share'); + final isAcceptEdits = + _isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type); + return (channel.contains('leg_share') || type.contains('leg_share')) && + !isAcceptEdits; + } + + bool _isLegShareEditNotification(UserNotification notification) { + final channel = notification.channel.trim().toLowerCase(); + final type = notification.type.trim().toLowerCase(); + return _isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type); + } + + bool _isLegShareAcceptEdits(String value) { + final normalized = value.trim().toLowerCase(); + // Match both singular/plural: leg_share_accept_edit / leg_share_accept_edits + return normalized.contains('leg_share_accept_edit'); } Widget _buildBadge(String label) { diff --git a/pubspec.yaml b/pubspec.yaml index cd358cd..7487ad4 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: 0.6.4+7 +version: 0.6.5+7 environment: sdk: ^3.8.1