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
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:
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
: {
|
||||
|
||||
Reference in New Issue
Block a user