From 69bd6f688ad697b95deb12401b6f31d945ed18c3 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Sat, 3 Jan 2026 13:22:43 +0000 Subject: [PATCH] Add friends leaderboard, reverse button in add page --- lib/components/calculator/calculator.dart | 10 ++ .../dashboard/leaderboard_panel.dart | 52 ++++++- .../new_entry/new_entry_draft_logic.dart | 87 ++++++++---- .../pages/new_entry/new_entry_page.dart | 128 ++++++++++++++---- .../new_entry/new_entry_submit_logic.dart | 55 +++++--- .../data_service/data_service_core.dart | 4 + .../data_service/data_service_stats.dart | 34 +++++ pubspec.yaml | 2 +- 8 files changed, 301 insertions(+), 71 deletions(-) diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index 7375692..a87a738 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -366,6 +366,16 @@ 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'), diff --git a/lib/components/dashboard/leaderboard_panel.dart b/lib/components/dashboard/leaderboard_panel.dart index 5fa6871..a431fd6 100644 --- a/lib/components/dashboard/leaderboard_panel.dart +++ b/lib/components/dashboard/leaderboard_panel.dart @@ -1,19 +1,31 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; - import 'package:provider/provider.dart'; +enum _LeaderboardScope { global, friends } -class LeaderboardPanel extends StatelessWidget { +class LeaderboardPanel extends StatefulWidget { const LeaderboardPanel({super.key}); + @override + State createState() => _LeaderboardPanelState(); +} + +class _LeaderboardPanelState extends State { + _LeaderboardScope _scope = _LeaderboardScope.global; + @override Widget build(BuildContext context) { final data = context.watch(); final distanceUnits = context.watch(); - final leaderboard = data.homepageStats?.leaderboard ?? []; + final leaderboard = _scope == _LeaderboardScope.global + ? (data.homepageStats?.leaderboard ?? []) + : data.friendsLeaderboard; + final loading = _scope == _LeaderboardScope.global + ? data.isHomepageLoading + : data.isFriendsLeaderboardLoading; final textTheme = Theme.of(context).textTheme; - if (data.isHomepageLoading && leaderboard.isEmpty) { + if (loading && leaderboard.isEmpty) { return const Padding( padding: EdgeInsets.all(16.0), child: Center(child: CircularProgressIndicator()), @@ -51,6 +63,38 @@ class LeaderboardPanel extends StatelessWidget { style: textTheme.labelSmall, ), ), + const SizedBox(width: 8), + SegmentedButton<_LeaderboardScope>( + segments: const [ + ButtonSegment( + value: _LeaderboardScope.global, + label: Text('Global'), + ), + ButtonSegment( + value: _LeaderboardScope.friends, + label: Text('Friends'), + ), + ], + selected: {_scope}, + onSelectionChanged: (vals) async { + if (vals.isEmpty) return; + final selected = vals.first; + setState(() => _scope = selected); + if (selected == _LeaderboardScope.friends && + data.friendsLeaderboard.isEmpty && + !data.isFriendsLeaderboardLoading) { + await data.fetchFriendsLeaderboard(); + } else if (selected == _LeaderboardScope.global && + (data.homepageStats?.leaderboard.isEmpty ?? true) && + !data.isHomepageLoading) { + await data.fetchHomepageStats(); + } + }, + style: SegmentedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), ], ), const SizedBox(height: 8), diff --git a/lib/components/pages/new_entry/new_entry_draft_logic.dart b/lib/components/pages/new_entry/new_entry_draft_logic.dart index 5244bfd..fc064c0 100644 --- a/lib/components/pages/new_entry/new_entry_draft_logic.dart +++ b/lib/components/pages/new_entry/new_entry_draft_logic.dart @@ -9,10 +9,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState { if (_activeDraftId != null && !_draftChangedFromBaseline()) { return true; } + final currentSnapshot = _currentSubmissionSnapshot(); + if (_lastSubmittedSnapshot != null && + _snapshotEquality.equals(_lastSubmittedSnapshot, currentSnapshot)) { + return true; + } final choice = await _promptSaveDraft(); if (choice == _ExitChoice.cancel) return false; if (choice == _ExitChoice.save) { - await _saveDraftEntry(draftId: _activeDraftId); + try { + await _saveDraftEntry(draftId: _activeDraftId); + } catch (_) { + return true; + } } else if (choice == _ExitChoice.discard) { // Delay reset to avoid setState during the dialog/build phase. await Future.delayed(Duration.zero); @@ -54,32 +63,37 @@ extension _NewEntryDraftLogic on _NewEntryPageState { Future<_ExitChoice> _promptSaveDraft() async { if (!mounted) return _ExitChoice.cancel; - final result = await showDialog<_ExitChoice>( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (_) => AlertDialog( - title: const Text('Save draft?'), - content: const Text( - 'Do you want to save this entry as a draft before leaving?', + try { + final result = await showDialog<_ExitChoice>( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (_) => AlertDialog( + title: const Text('Save draft?'), + content: const Text( + 'Do you want to save this entry as a draft before leaving?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(_ExitChoice.discard), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_ExitChoice.save), + child: const Text('Yes'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel), + child: const Text('Cancel'), + ), + ], ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(_ExitChoice.discard), - child: const Text('No'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_ExitChoice.save), - child: const Text('Yes'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel), - child: const Text('Cancel'), - ), - ], - ), - ); - return result ?? _ExitChoice.cancel; + ); + if (!mounted) return _ExitChoice.cancel; + return result ?? _ExitChoice.cancel; + } catch (_) { + return _ExitChoice.cancel; + } } Future _openDrafts() async { @@ -174,6 +188,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState { "distance": _routeResult!.distance, }, "tractionItems": _serializeTractionItems(), + "shareUserIds": _shareUserIds.toList(), + "shareUsers": _shareUsers + .map((u) => { + "user_id": u.userId, + "username": u.username, + "full_name": u.fullName, + }) + .toList(), }; await prefs.setString(_kDraftPrefsKey, jsonEncode(draft)); } @@ -449,6 +471,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState { ..clear() ..add(_TractionItem.marker()); } + final shareIdsRaw = data['shareUserIds']; + final shareUsersRaw = data['shareUsers']; + _shareUserIds = shareIdsRaw is List + ? shareIdsRaw.map((e) => e.toString()).toSet() + : {}; + _shareUsers = shareUsersRaw is List + ? shareUsersRaw + .whereType() + .map((e) => UserSummary.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList() + : []; _lastSubmittedSnapshot = null; final idRaw = data['id']; if (idRaw != null) { diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 5b8acdf..3957731 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -58,6 +58,7 @@ class _NewEntryPageState extends State { LegShareData? _activeLegShare; String? _sharedFromUser; int? _shareNotificationId; + bool _routeReversedFlag = false; bool get _isEditing => widget.editLegId != null; bool get _draftPersistenceEnabled => @@ -147,6 +148,48 @@ class _NewEntryPageState extends State { } } + void _reverseRouteAndEndpoints() { + setState(() { + // Swap start/end and origin/destination fields + final startText = _startController.text; + final endText = _endController.text; + _startController.text = endText; + _endController.text = startText; + + final originText = _originController.text; + final destText = _destinationController.text; + _originController.text = destText; + _destinationController.text = originText; + + // Reverse route result if present + if (_routeResult != null) { + final reversedInput = _routeResult!.inputRoute.reversed.toList(); + final reversedCalc = _routeResult!.calculatedRoute.reversed.toList(); + final reversedCosts = _routeResult!.costs.reversed.toList(); + _routeResult = RouteResult( + inputRoute: reversedInput, + calculatedRoute: reversedCalc, + costs: reversedCosts, + distance: _routeResult!.distance, + ); + final units = _distanceUnits(context); + _mileageController.text = _formatDistance( + units, + _routeResult!.distance, + decimals: 2, + includeUnit: false, + ); + _useManualMileage = false; + } else if (_useManualMileage && + _mileageController.text.trim().isNotEmpty) { + // keep manual mileage, just swap endpoints + } + _routeReversedFlag = !_routeReversedFlag; + }); + _saveDraft(); + _scheduleMatchUpdate(); + } + double _milesFromInputWithUnit(DistanceUnit unit) { return DistanceFormatter(unit) .parseInputMiles(_mileageController.text.trim()) ?? @@ -1391,16 +1434,52 @@ class _NewEntryPageState extends State { _buildTractionList(), ], minHeight: balancedHeight); + final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true + ? _routeResult!.calculatedRoute.first + : (_routeResult?.inputRoute.isNotEmpty == true + ? _routeResult!.inputRoute.first + : _startController.text.trim()); + final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true + ? _routeResult!.calculatedRoute.last + : (_routeResult?.inputRoute.isNotEmpty == true + ? _routeResult!.inputRoute.last + : _endController.text.trim()); + final mileagePanel = _section( - 'Mileage', + 'Your Journey', [ if (!_useManualMileage) + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: _openCalculator, + icon: const Icon(Icons.calculate, size: 18), + label: const Text('Open mileage calculator'), + ), + OutlinedButton.icon( + onPressed: _reverseRouteAndEndpoints, + icon: const Icon(Icons.swap_horiz), + label: const Text('Reverse route'), + ), + ], + ) + else Align( alignment: Alignment.centerLeft, - child: ElevatedButton.icon( - onPressed: _openCalculator, - icon: const Icon(Icons.calculate, size: 18), - label: const Text('Open mileage calculator'), + child: OutlinedButton.icon( + onPressed: _reverseRouteAndEndpoints, + icon: const Icon(Icons.swap_horiz), + label: const Text('Reverse route'), + ), + ), + if (routeStart.isNotEmpty || routeEnd.isNotEmpty) + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Route'), + subtitle: Text( + '${routeStart.isEmpty ? 'Start' : routeStart} → ${routeEnd.isEmpty ? 'End' : routeEnd}', ), ), if (_useManualMileage) @@ -1467,24 +1546,27 @@ class _NewEntryPageState extends State { children: [ entryPanel, const SizedBox(height: 16), - trainPanel, - const SizedBox(height: 16), - twoCol - ? Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: tractionPanel), - const SizedBox(width: 16), - Expanded(child: mileagePanel), - ], - ) - : Column( - children: [ - tractionPanel, - const SizedBox(height: 16), - mileagePanel, - ], - ), + if (twoCol) ...[ + trainPanel, + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: tractionPanel), + const SizedBox(width: 16), + Expanded(child: mileagePanel), + ], + ), + ] else + Column( + children: [ + mileagePanel, + const SizedBox(height: 16), + trainPanel, + const SizedBox(height: 16), + tractionPanel, + ], + ), const SizedBox(height: 12), ElevatedButton.icon( onPressed: _submitting ? null : _submit, diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart index 7350287..9c884f3 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -71,12 +71,14 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { final beginDelay = _parseDelayMinutes(_beginDelayController.text); final endDelay = _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; - final snapshot = _buildSubmissionSnapshot( + final snapshot = _currentSubmissionSnapshot( routeStations: routeStations, startVal: startVal, endVal: endVal, mileageVal: mileageVal, tractionPayload: tractionPayload, + beginDelay: beginDelay, + endDelay: endDelay, ); if (_lastSubmittedSnapshot != null && _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { @@ -161,16 +163,35 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { } } - Map _buildSubmissionSnapshot({ - required List routeStations, - required String startVal, - required String endVal, - required double mileageVal, - required List> tractionPayload, + Map _currentSubmissionSnapshot({ + List? routeStations, + String? startVal, + String? endVal, + double? mileageVal, + List>? tractionPayload, + int? beginDelay, + int? endDelay, }) { - final beginDelay = _parseDelayMinutes(_beginDelayController.text); - final endDelay = - _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; + final stations = routeStations ?? (_routeResult?.calculatedRoute ?? []); + final start = startVal ?? + (_useManualMileage + ? _startController.text.trim() + : (stations.isNotEmpty ? stations.first : '')); + final end = endVal ?? + (_useManualMileage + ? _endController.text.trim() + : (stations.isNotEmpty ? stations.last : '')); + final mileage = mileageVal ?? + (_useManualMileage + ? (_distanceUnits(context) + .milesFromInput(_mileageController.text.trim()) ?? + 0) + : (_routeResult?.distance ?? 0)); + final traction = tractionPayload ?? _buildTractionPayload(); + final begin = beginDelay ?? _parseDelayMinutes(_beginDelayController.text); + final endDelayVal = + endDelay ?? (_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0); + return { "legId": widget.editLegId, "useManualMileage": _useManualMileage, @@ -182,20 +203,20 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { "hasOriginTime": _hasOriginTime, "legDestinationTime": _destinationDateTime?.toIso8601String(), "hasDestinationTime": _hasDestinationTime, - "start": startVal, - "end": endVal, + "start": start, + "end": end, "origin": _originController.text.trim(), "destination": _destinationController.text.trim(), - "routeStations": routeStations, - "mileage": mileageVal, + "routeStations": stations, + "mileage": mileage, "network": _networkController.text.trim(), "notes": _notesController.text.trim(), "headcode": _headcodeController.text.trim(), - "beginDelay": beginDelay, - "endDelay": endDelay, + "beginDelay": begin, + "endDelay": endDelayVal, "legShareId": _activeLegShare?.id, "shareUserIds": _shareUserIds.toList(), - "locos": tractionPayload, + "locos": traction, "routeResult": _routeResult == null ? null : { diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 856b864..b8008c8 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -108,6 +108,10 @@ class DataService extends ChangeNotifier { bool get isHomepageLoading => _isHomepageLoading; bool _isOnThisDayLoading = false; bool get isOnThisDayLoading => _isOnThisDayLoading; + List _friendsLeaderboard = []; + List get friendsLeaderboard => _friendsLeaderboard; + bool _isFriendsLeaderboardLoading = false; + bool get isFriendsLeaderboardLoading => _isFriendsLeaderboardLoading; // Notifications List _notifications = []; diff --git a/lib/services/data_service/data_service_stats.dart b/lib/services/data_service/data_service_stats.dart index 58d7f76..e43fd34 100644 --- a/lib/services/data_service/data_service_stats.dart +++ b/lib/services/data_service/data_service_stats.dart @@ -25,4 +25,38 @@ extension DataServiceStats on DataService { _notifyAsync(); } } + + Future fetchFriendsLeaderboard() async { + if (_isFriendsLeaderboardLoading) return; + _isFriendsLeaderboardLoading = true; + _notifyAsync(); + try { + final json = await api.get('/stats/leaderboard/friends'); + 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; + } + } + } + _friendsLeaderboard = list + ?.whereType() + .map((e) => LeaderboardEntry.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList() ?? + const []; + } catch (e) { + debugPrint('Failed to fetch friends leaderboard: $e'); + _friendsLeaderboard = []; + } finally { + _isFriendsLeaderboardLoading = false; + _notifyAsync(); + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index f0cbec7..046a224 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.0+3 +version: 0.6.1+4 environment: sdk: ^3.8.1