Add friends leaderboard, reverse button in add page
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m11s
Release / web-build (push) Successful in 5m3s
Release / android-build (push) Successful in 18m23s
Release / release-master (push) Successful in 22s
Release / release-dev (push) Successful in 25s

This commit is contained in:
2026-01-03 13:22:43 +00:00
parent 89b760508d
commit 69bd6f688a
8 changed files with 301 additions and 71 deletions

View File

@@ -366,6 +366,16 @@ class _RouteCalculatorState extends State<RouteCalculator> {
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: [ 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( ElevatedButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add Station'), label: const Text('Add Station'),

View File

@@ -1,19 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
enum _LeaderboardScope { global, friends }
class LeaderboardPanel extends StatelessWidget { class LeaderboardPanel extends StatefulWidget {
const LeaderboardPanel({super.key}); const LeaderboardPanel({super.key});
@override
State<LeaderboardPanel> createState() => _LeaderboardPanelState();
}
class _LeaderboardPanelState extends State<LeaderboardPanel> {
_LeaderboardScope _scope = _LeaderboardScope.global;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
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; final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (loading && leaderboard.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
@@ -51,6 +63,38 @@ class LeaderboardPanel extends StatelessWidget {
style: textTheme.labelSmall, 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), const SizedBox(height: 8),

View File

@@ -9,10 +9,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
if (_activeDraftId != null && !_draftChangedFromBaseline()) { if (_activeDraftId != null && !_draftChangedFromBaseline()) {
return true; return true;
} }
final currentSnapshot = _currentSubmissionSnapshot();
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, currentSnapshot)) {
return true;
}
final choice = await _promptSaveDraft(); final choice = await _promptSaveDraft();
if (choice == _ExitChoice.cancel) return false; if (choice == _ExitChoice.cancel) return false;
if (choice == _ExitChoice.save) { if (choice == _ExitChoice.save) {
await _saveDraftEntry(draftId: _activeDraftId); try {
await _saveDraftEntry(draftId: _activeDraftId);
} catch (_) {
return true;
}
} else if (choice == _ExitChoice.discard) { } else if (choice == _ExitChoice.discard) {
// Delay reset to avoid setState during the dialog/build phase. // Delay reset to avoid setState during the dialog/build phase.
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
@@ -54,32 +63,37 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
Future<_ExitChoice> _promptSaveDraft() async { Future<_ExitChoice> _promptSaveDraft() async {
if (!mounted) return _ExitChoice.cancel; if (!mounted) return _ExitChoice.cancel;
final result = await showDialog<_ExitChoice>( try {
context: context, final result = await showDialog<_ExitChoice>(
barrierDismissible: false, context: context,
useRootNavigator: false, barrierDismissible: false,
builder: (_) => AlertDialog( useRootNavigator: false,
title: const Text('Save draft?'), builder: (_) => AlertDialog(
content: const Text( title: const Text('Save draft?'),
'Do you want to save this entry as a draft before leaving?', 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( if (!mounted) return _ExitChoice.cancel;
onPressed: () => Navigator.of(context).pop(_ExitChoice.discard), return result ?? _ExitChoice.cancel;
child: const Text('No'), } catch (_) {
), return _ExitChoice.cancel;
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;
} }
Future<void> _openDrafts() async { Future<void> _openDrafts() async {
@@ -174,6 +188,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance, "distance": _routeResult!.distance,
}, },
"tractionItems": _serializeTractionItems(), "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)); await prefs.setString(_kDraftPrefsKey, jsonEncode(draft));
} }
@@ -449,6 +471,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
..clear() ..clear()
..add(_TractionItem.marker()); ..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>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList()
: [];
_lastSubmittedSnapshot = null; _lastSubmittedSnapshot = null;
final idRaw = data['id']; final idRaw = data['id'];
if (idRaw != null) { if (idRaw != null) {

View File

@@ -58,6 +58,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
LegShareData? _activeLegShare; LegShareData? _activeLegShare;
String? _sharedFromUser; String? _sharedFromUser;
int? _shareNotificationId; int? _shareNotificationId;
bool _routeReversedFlag = false;
bool get _isEditing => widget.editLegId != null; bool get _isEditing => widget.editLegId != null;
bool get _draftPersistenceEnabled => bool get _draftPersistenceEnabled =>
@@ -147,6 +148,48 @@ class _NewEntryPageState extends State<NewEntryPage> {
} }
} }
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) { double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(unit) return DistanceFormatter(unit)
.parseInputMiles(_mileageController.text.trim()) ?? .parseInputMiles(_mileageController.text.trim()) ??
@@ -1391,16 +1434,52 @@ class _NewEntryPageState extends State<NewEntryPage> {
_buildTractionList(), _buildTractionList(),
], minHeight: balancedHeight); ], 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( final mileagePanel = _section(
'Mileage', 'Your Journey',
[ [
if (!_useManualMileage) 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( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: ElevatedButton.icon( child: OutlinedButton.icon(
onPressed: _openCalculator, onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.calculate, size: 18), icon: const Icon(Icons.swap_horiz),
label: const Text('Open mileage calculator'), 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) if (_useManualMileage)
@@ -1467,24 +1546,27 @@ class _NewEntryPageState extends State<NewEntryPage> {
children: [ children: [
entryPanel, entryPanel,
const SizedBox(height: 16), const SizedBox(height: 16),
trainPanel, if (twoCol) ...[
const SizedBox(height: 16), trainPanel,
twoCol const SizedBox(height: 16),
? Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: tractionPanel), Expanded(child: tractionPanel),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(child: mileagePanel), Expanded(child: mileagePanel),
], ],
) ),
: Column( ] else
children: [ Column(
tractionPanel, children: [
const SizedBox(height: 16), mileagePanel,
mileagePanel, const SizedBox(height: 16),
], trainPanel,
), const SizedBox(height: 16),
tractionPanel,
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _submitting ? null : _submit, onPressed: _submitting ? null : _submit,

View File

@@ -71,12 +71,14 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final beginDelay = _parseDelayMinutes(_beginDelayController.text); final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay = final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _buildSubmissionSnapshot( final snapshot = _currentSubmissionSnapshot(
routeStations: routeStations, routeStations: routeStations,
startVal: startVal, startVal: startVal,
endVal: endVal, endVal: endVal,
mileageVal: mileageVal, mileageVal: mileageVal,
tractionPayload: tractionPayload, tractionPayload: tractionPayload,
beginDelay: beginDelay,
endDelay: endDelay,
); );
if (_lastSubmittedSnapshot != null && if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) {
@@ -161,16 +163,35 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
} }
Map<String, dynamic> _buildSubmissionSnapshot({ Map<String, dynamic> _currentSubmissionSnapshot({
required List<String> routeStations, List<String>? routeStations,
required String startVal, String? startVal,
required String endVal, String? endVal,
required double mileageVal, double? mileageVal,
required List<Map<String, dynamic>> tractionPayload, List<Map<String, dynamic>>? tractionPayload,
int? beginDelay,
int? endDelay,
}) { }) {
final beginDelay = _parseDelayMinutes(_beginDelayController.text); final stations = routeStations ?? (_routeResult?.calculatedRoute ?? []);
final endDelay = final start = startVal ??
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; (_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 { return {
"legId": widget.editLegId, "legId": widget.editLegId,
"useManualMileage": _useManualMileage, "useManualMileage": _useManualMileage,
@@ -182,20 +203,20 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"hasOriginTime": _hasOriginTime, "hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(), "legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime, "hasDestinationTime": _hasDestinationTime,
"start": startVal, "start": start,
"end": endVal, "end": end,
"origin": _originController.text.trim(), "origin": _originController.text.trim(),
"destination": _destinationController.text.trim(), "destination": _destinationController.text.trim(),
"routeStations": routeStations, "routeStations": stations,
"mileage": mileageVal, "mileage": mileage,
"network": _networkController.text.trim(), "network": _networkController.text.trim(),
"notes": _notesController.text.trim(), "notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(), "headcode": _headcodeController.text.trim(),
"beginDelay": beginDelay, "beginDelay": begin,
"endDelay": endDelay, "endDelay": endDelayVal,
"legShareId": _activeLegShare?.id, "legShareId": _activeLegShare?.id,
"shareUserIds": _shareUserIds.toList(), "shareUserIds": _shareUserIds.toList(),
"locos": tractionPayload, "locos": traction,
"routeResult": _routeResult == null "routeResult": _routeResult == null
? null ? null
: { : {

View File

@@ -108,6 +108,10 @@ class DataService extends ChangeNotifier {
bool get isHomepageLoading => _isHomepageLoading; bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false; bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading; bool get isOnThisDayLoading => _isOnThisDayLoading;
List<LeaderboardEntry> _friendsLeaderboard = [];
List<LeaderboardEntry> get friendsLeaderboard => _friendsLeaderboard;
bool _isFriendsLeaderboardLoading = false;
bool get isFriendsLeaderboardLoading => _isFriendsLeaderboardLoading;
// Notifications // Notifications
List<UserNotification> _notifications = []; List<UserNotification> _notifications = [];

View File

@@ -25,4 +25,38 @@ extension DataServiceStats on DataService {
_notifyAsync(); _notifyAsync();
} }
} }
Future<void> fetchFriendsLeaderboard() async {
if (_isFriendsLeaderboardLoading) return;
_isFriendsLeaderboardLoading = true;
_notifyAsync();
try {
final json = await api.get('/stats/leaderboard/friends');
List<dynamic>? 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>()
.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();
}
}
} }

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.8.1 sdk: ^3.8.1