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,
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'),

View File

@@ -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<LeaderboardPanel> createState() => _LeaderboardPanelState();
}
class _LeaderboardPanelState extends State<LeaderboardPanel> {
_LeaderboardScope _scope = _LeaderboardScope.global;
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
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;
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),

View File

@@ -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<void>.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<void> _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>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList()
: [];
_lastSubmittedSnapshot = null;
final idRaw = data['id'];
if (idRaw != null) {

View File

@@ -58,6 +58,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
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<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) {
return DistanceFormatter(unit)
.parseInputMiles(_mileageController.text.trim()) ??
@@ -1391,16 +1434,52 @@ class _NewEntryPageState extends State<NewEntryPage> {
_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<NewEntryPage> {
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,

View File

@@ -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<String, dynamic> _buildSubmissionSnapshot({
required List<String> routeStations,
required String startVal,
required String endVal,
required double mileageVal,
required List<Map<String, dynamic>> tractionPayload,
Map<String, dynamic> _currentSubmissionSnapshot({
List<String>? routeStations,
String? startVal,
String? endVal,
double? mileageVal,
List<Map<String, dynamic>>? 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
: {