4 Commits

Author SHA1 Message Date
7feb672e7e fix timeline popover
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m56s
Release / android-build (push) Successful in 16m36s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 26s
2025-12-22 17:33:33 +00:00
45d543498f Layout changes, fix bugs in new entry page 2025-12-22 17:23:21 +00:00
63b545c7a3 increment version
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m22s
Release / android-build (push) Successful in 17m22s
Release / release-master (push) Successful in 33s
Release / release-dev (push) Successful in 35s
2025-12-17 17:42:54 +00:00
587933fa50 fix navbar freezing fix
Some checks failed
Release / meta (push) Failing after 9s
Release / android-build (push) Has been skipped
Release / linux-build (push) Has been skipped
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-17 17:41:09 +00:00
24 changed files with 928 additions and 224 deletions

View File

@@ -1 +1,3 @@
{} {
"cmake.ignoreCMakeListsMissing": true
}

View File

@@ -45,7 +45,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.mileograph_flutter" applicationId = "com.petegregoryy.mileograph_flutter"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View File

@@ -13,7 +13,7 @@ class App extends StatelessWidget {
return MultiProvider( return MultiProvider(
providers: [ providers: [
Provider<ApiService>( Provider<ApiService>(
create: (_) => ApiService(baseUrl: 'https://mileograph.co.uk/api/v1'), create: (_) => ApiService(baseUrl: 'http://localhost:8000/api/v1'),
), ),
ChangeNotifierProvider<AuthService>( ChangeNotifierProvider<AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()), create: (context) => AuthService(api: context.read<ApiService>()),

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LatestLocoChangesPanel extends StatefulWidget {
const LatestLocoChangesPanel({super.key});
@override
State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState();
}
class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
late final ScrollController _controller;
@override
void initState() {
super.initState();
_controller = ScrollController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final changes = data.latestLocoChanges;
final isLoading = data.isLatestLocoChangesLoading;
final textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Latest loco changes',
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
if (isLoading && changes.isEmpty)
const Padding(
padding: EdgeInsets.all(12.0),
child: Center(child: CircularProgressIndicator()),
)
else if (changes.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'No recent loco changes yet.',
style: textTheme.bodyMedium,
),
)
else
SizedBox(
height: 260,
child: Scrollbar(
controller: _controller,
child: ListView.separated(
controller: _controller,
itemCount: changes.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final change = changes[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(
change.locoLabel,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${change.changeLabel}: ${change.valueLabel}'),
Text(
change.approvedDateLabel,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
],
),
trailing: change.approvedBy.isEmpty
? null
: Text(
change.approvedBy,
style: textTheme.labelSmall,
),
);
},
),
),
),
],
),
),
);
}
}

View File

@@ -16,23 +16,24 @@ class LeaderboardPanel extends StatelessWidget {
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
return Padding( return Card(
padding: const EdgeInsets.all(10.0), clipBehavior: Clip.antiAlias,
child: Card( child: Padding(
padding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
"Leaderboard", "Leaderboard",
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
), ),
), ),
const SizedBox(height: 8),
if (leaderboard.isEmpty) if (leaderboard.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(8.0),
child: Text('No leaderboard data yet'), child: Text('No leaderboard data yet'),
) )
else else
@@ -46,7 +47,7 @@ class LeaderboardPanel extends StatelessWidget {
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 0, vertical: 8), horizontal: 0, vertical: 8),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [

View File

@@ -17,23 +17,24 @@ class TopTractionPanel extends StatelessWidget {
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
return Padding( return Card(
padding: const EdgeInsets.all(10.0), clipBehavior: Clip.antiAlias,
child: Card( child: Padding(
padding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
"Top Traction", "Top Traction",
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
), ),
), ),
const SizedBox(height: 8),
if (locos.isEmpty) if (locos.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(8.0),
child: Text('No traction data yet'), child: Text('No traction data yet'),
) )
else else
@@ -47,7 +48,7 @@ class TopTractionPanel extends StatelessWidget {
margin: margin:
const EdgeInsets.symmetric(horizontal: 0, vertical: 8), const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [

View File

@@ -9,10 +9,12 @@ class LegCard extends StatelessWidget {
super.key, super.key,
required this.leg, required this.leg,
this.showEditButton = true, this.showEditButton = true,
this.showDate = true,
}); });
final Leg leg; final Leg leg;
final bool showEditButton; final bool showEditButton;
final bool showDate;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -26,7 +28,7 @@ class LegCard extends StatelessWidget {
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(_formatDateTime(leg.beginTime)), if (showDate) Text(_formatDateTime(leg.beginTime)),
if (leg.headcode.isNotEmpty) if (leg.headcode.isNotEmpty)
Text( Text(
'Headcode: ${leg.headcode}', 'Headcode: ${leg.headcode}',
@@ -126,13 +128,32 @@ class LegCard extends StatelessWidget {
List<Widget> _buildLocoChips(BuildContext context, Leg leg) { List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
final theme = Theme.of(context); final theme = Theme.of(context);
final textTheme = theme.textTheme;
return leg.locos return leg.locos
.map( .map(
(loco) => Chip( (loco) {
label: Text('${loco.locoClass} ${loco.number}'), final powering = loco.powering == true;
avatar: const Icon(Icons.directions_railway, size: 16), final iconColor =
backgroundColor: theme.colorScheme.surfaceContainerHighest, powering ? theme.colorScheme.primary : theme.disabledColor;
final labelStyle = powering
? null
: textTheme.bodyMedium?.copyWith(color: theme.disabledColor);
final background = powering
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceVariant;
return Chip(
label: Text(
'${loco.locoClass} ${loco.number}',
style: labelStyle,
), ),
avatar: Icon(
Icons.directions_railway,
size: 16,
color: iconColor,
),
backgroundColor: background,
);
},
) )
.toList(); .toList();
} }
@@ -192,4 +213,3 @@ class LegCard extends StatelessWidget {
return [trimmed]; return [trimmed];
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart'; import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
@@ -32,6 +33,7 @@ class _DashboardState extends State<Dashboard> {
data.fetchOnThisDay(), data.fetchOnThisDay(),
data.fetchTripDetails(), data.fetchTripDetails(),
data.fetchHadTraction(), data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
]); ]);
}, },
child: LayoutBuilder( child: LayoutBuilder(
@@ -41,7 +43,7 @@ class _DashboardState extends State<Dashboard> {
context, context,
totalMileage: stats?.totalMileage ?? 0, totalMileage: stats?.totalMileage ?? 0,
currentYearMileage: data.getMileageForCurrentYear(), currentYearMileage: data.getMileageForCurrentYear(),
trips: data.trips.length, legCount: stats?.legCount ?? data.trips.length,
); );
return Stack( return Stack(
children: [ children: [
@@ -138,7 +140,7 @@ class _DashboardState extends State<Dashboard> {
BuildContext context, { BuildContext context, {
required double totalMileage, required double totalMileage,
required double currentYearMileage, required double currentYearMileage,
required int trips, required int legCount,
}) { }) {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
Widget metricCard(String label, String value) { Widget metricCard(String label, String value) {
@@ -173,7 +175,7 @@ class _DashboardState extends State<Dashboard> {
return [ return [
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'), metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'),
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'), metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'),
metricCard('Trips logged', trips.toString()), metricCard('Entries logged', legCount.toString()),
]; ];
} }
@@ -211,8 +213,6 @@ class _DashboardState extends State<Dashboard> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildQuickCalcCard(context),
const SizedBox(height: 12),
_buildTripsCard(context, data), _buildTripsCard(context, data),
], ],
); );
@@ -220,10 +220,13 @@ class _DashboardState extends State<Dashboard> {
Widget _buildSidebar(BuildContext context, DataService data) { Widget _buildSidebar(BuildContext context, DataService data) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TopTractionPanel(), const TopTractionPanel(),
const SizedBox(height: 12), const SizedBox(height: 12),
LeaderboardPanel(), const LeaderboardPanel(),
const SizedBox(height: 12),
const LatestLocoChangesPanel(),
], ],
); );
} }
@@ -313,22 +316,6 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildQuickCalcCard(BuildContext context) {
return _buildCard(
context,
title: 'Quick mileage calculator',
action: TextButton.icon(
onPressed: () => context.push('/calculator'),
icon: const Icon(Icons.open_in_new),
label: const Text('Open calculator'),
),
child: Text(
'Jump into the route calculator to quickly total a journey before saving it.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
Widget _buildTripsCard(BuildContext context, DataService data) { Widget _buildTripsCard(BuildContext context, DataService data) {
final tripsUnsorted = data.trips; final tripsUnsorted = data.trips;
List trips = []; List trips = [];
@@ -353,7 +340,6 @@ class _DashboardState extends State<Dashboard> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(trip.tripName), title: Text(trip.tripName),
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
trailing: const Icon(Icons.chevron_right),
); );
}).toList(), }).toList(),
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -211,7 +212,7 @@ class _LegsPageState extends State<LegsPage> {
else else
Column( Column(
children: [ children: [
...legs.map((leg) => LegCard(leg: leg)), ..._buildLegsWithDividers(context, legs),
const SizedBox(height: 8), const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading) if (data.legsHasMore || data.isLegsLoading)
Align( Align(
@@ -238,6 +239,57 @@ class _LegsPageState extends State<LegsPage> {
); );
} }
List<Widget> _buildLegsWithDividers(BuildContext context, List<Leg> legs) {
final widgets = <Widget>[];
String? currentDate;
double dayMileage = 0;
final dayLegs = <Leg>[];
void flushDay() {
if (currentDate == null) return;
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Expanded(
child: Text(
currentDate!,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
'${dayMileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
);
widgets.add(const Divider());
widgets.addAll(
dayLegs.map((leg) => LegCard(leg: leg, showDate: false)),
);
dayLegs.clear();
}
for (final leg in legs) {
final dateStr = _formatDate(leg.beginTime) ?? '';
if (currentDate != null && dateStr != currentDate) {
flushDay();
dayMileage = 0;
}
currentDate = dateStr;
dayLegs.add(leg);
dayMileage += leg.mileage;
}
flushDay();
return widgets;
}
String? _formatDate(DateTime? date) { String? _formatDate(DateTime? date) {
if (date == null) return null; if (date == null) return null;
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';

View File

@@ -443,23 +443,15 @@ class _ValueBlockMenu extends StatelessWidget {
return _ValueBlockView(block: block); return _ValueBlockView(block: block);
} }
return GestureDetector( Future<void> showContextMenuAt(Offset globalPosition) async {
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) async {
final overlay = Overlay.of(context); final overlay = Overlay.of(context);
final renderBox = overlay.context.findRenderObject() as RenderBox?; final renderBox = overlay?.context.findRenderObject() as RenderBox?;
if (renderBox == null) return; if (renderBox == null) return;
if (defaultTargetPlatform == TargetPlatform.android) { // Translate from global screen coordinates into the overlay's local space
HapticFeedback.lightImpact(); // so the menu appears where the gesture happened.
} final localPosition = renderBox.globalToLocal(globalPosition);
final anchor = details.globalPosition + const Offset(0, -8);
final position = RelativeRect.fromRect( final position = RelativeRect.fromRect(
Rect.fromLTWH( Rect.fromLTWH(localPosition.dx, localPosition.dy, 1, 1),
anchor.dx,
anchor.dy,
1,
1,
),
Offset.zero & renderBox.size, Offset.zero & renderBox.size,
); );
@@ -490,6 +482,18 @@ class _ValueBlockMenu extends StatelessWidget {
onDeleteEntry?.call(entry); onDeleteEntry?.call(entry);
break; break;
} }
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) async {
if (defaultTargetPlatform == TargetPlatform.android) {
HapticFeedback.lightImpact();
}
await showContextMenuAt(details.globalPosition);
},
onSecondaryTapDown: (details) async {
await showContextMenuAt(details.globalPosition);
}, },
child: _ValueBlockView(block: block), child: _ValueBlockView(block: block),
); );

View File

@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/calculator/calculator.dart'; import 'package:mileograph_flutter/components/calculator/calculator.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';

View File

@@ -84,6 +84,32 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
} }
} }
Future<void> _saveDraftManually() async {
if (_savingDraft) return;
if (_formIsEmpty()) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Nothing to save yet.')),
);
return;
}
final hadDraft = _activeDraftId != null;
_setState(() => _savingDraft = true);
try {
await _saveDraftEntry(draftId: _activeDraftId);
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(hadDraft ? 'Draft updated' : 'Draft saved')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Failed to save draft: $e')),
);
} finally {
if (mounted) _setState(() => _savingDraft = false);
}
}
Future<void> _saveDraft() async { Future<void> _saveDraft() async {
if (_restoringDraft || !_draftPersistenceEnabled) return; if (_restoringDraft || !_draftPersistenceEnabled) return;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -212,6 +238,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(), if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(),
"mode": _useManualMileage ? 'manual' : 'auto', "mode": _useManualMileage ? 'manual' : 'auto',
"payload": payload, "payload": payload,
"mileageText": _mileageController.text.trim(),
"routeResult": _routeResult == null "routeResult": _routeResult == null
? null ? null
: { : {

View File

@@ -10,6 +10,7 @@ class NewEntryPage extends StatefulWidget {
} }
class _NewEntryPageState extends State<NewEntryPage> { class _NewEntryPageState extends State<NewEntryPage> {
late final NavigationGuardCallback _exitGuard;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
TimeOfDay _selectedTime = TimeOfDay.now(); TimeOfDay _selectedTime = TimeOfDay.now();
@@ -26,6 +27,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
int? _selectedTripId; int? _selectedTripId;
bool _restoringDraft = false; bool _restoringDraft = false;
bool _loadingEdit = false; bool _loadingEdit = false;
bool _savingDraft = false;
String? _loadError; String? _loadError;
Map<String, dynamic>? _lastSubmittedSnapshot; Map<String, dynamic>? _lastSubmittedSnapshot;
Map<String, dynamic>? _loadedDraftSnapshot; Map<String, dynamic>? _loadedDraftSnapshot;
@@ -40,13 +42,14 @@ class _NewEntryPageState extends State<NewEntryPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
NavigationGuard.register(_handleExitIntent); _exitGuard = _handleExitIntent;
NavigationGuard.register(_exitGuard);
// legacy single-draft auto-save listeners removed in favor of explicit multi-draft flow // legacy single-draft auto-save listeners removed in favor of explicit multi-draft flow
Future.microtask(() { Future.microtask(() {
if (!mounted) return; if (!mounted) return;
final data = context.read<DataService>(); final data = context.read<DataService>();
data.fetchClassList(); data.fetchClassList();
data.fetchTrips(); data.fetchTripOptions();
if (_draftPersistenceEnabled) { if (_draftPersistenceEnabled) {
_loadDraft(); _loadDraft();
} }
@@ -58,7 +61,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
@override @override
void dispose() { void dispose() {
NavigationGuard.unregister(_handleExitIntent); NavigationGuard.unregister(_exitGuard);
_startController.dispose(); _startController.dispose();
_endController.dispose(); _endController.dispose();
_headcodeController.dispose(); _headcodeController.dispose();
@@ -148,16 +151,27 @@ class _NewEntryPageState extends State<NewEntryPage> {
final data = context.read<DataService>(); final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
try { try {
await api.put('/trips/new', {"trip_name": result}); final encoded = Uri.encodeComponent(result);
await data.fetchTrips(); final res = await api.put('/trips/new?trip_name=$encoded', {});
await data.fetchTripOptions();
if (!context.mounted) return; if (!context.mounted) return;
final trips = data.tripList; final trips = data.tripList;
final match = trips.firstWhere( final apiTripId = res is Map ? res['trip_id'] as int? : null;
(t) => t.tripName == result, TripSummary match;
orElse: () => trips.isNotEmpty try {
? trips.first match = trips.firstWhere(
: TripSummary(tripId: 0, tripName: result, tripMileage: 0), (t) =>
(apiTripId != null && t.tripId == apiTripId) ||
t.tripName == result,
); );
} catch (_) {
match = TripSummary(
tripId: apiTripId ?? 0,
tripName: result,
tripMileage: 0,
);
data.upsertTripSummary(match);
}
setState(() => _selectedTripId = match.tripId); setState(() => _selectedTripId = match.tripId);
_saveDraft(); _saveDraft();
} catch (e) { } catch (e) {
@@ -174,9 +188,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
} }
Future<void> _openCalculator() async { Future<void> _openCalculator() async {
final initialStations = _routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute
: (_routeResult?.calculatedRoute ?? const []);
final result = await Navigator.of(context).push<RouteResult>( final result = await Navigator.of(context).push<RouteResult>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => _CalculatorPickerPage( builder: (_) => _CalculatorPickerPage(
initialStations: initialStations.isEmpty ? null : initialStations,
onResult: (res) => Navigator.of(context).pop(res), onResult: (res) => Navigator.of(context).pop(res),
), ),
), ),
@@ -371,6 +389,25 @@ class _NewEntryPageState extends State<NewEntryPage> {
icon: const Icon(Icons.list_alt, size: 16), icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'), label: const Text('Drafts'),
), ),
const SizedBox(width: 12),
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing || _savingDraft || _submitting
? null
: _saveDraftManually,
icon: _savingDraft
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_alt, size: 16),
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
),
const Spacer(), const Spacer(),
TextButton.icon( TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
@@ -586,7 +623,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (didPop) return; if (didPop) return;
final allow = await _handleExitIntent(); final allow = await _handleExitIntent();
if (allow && context.mounted) { if (allow && context.mounted) {
Navigator.of(context).maybePop(); final router = GoRouter.of(context);
if (router.canPop()) {
context.pop();
} else {
context.go('/');
}
} }
}, },
child: Scaffold( child: Scaffold(
@@ -597,7 +639,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
onPressed: () async { onPressed: () async {
if (!await _handleExitIntent()) return; if (!await _handleExitIntent()) return;
if (!context.mounted) return; if (!context.mounted) return;
Navigator.of(context).maybePop(); final router = GoRouter.of(context);
if (router.canPop()) {
context.pop();
} else {
context.go('/');
}
}, },
), ),
title: const Text('Edit entry'), title: const Text('Edit entry'),

View File

@@ -1,8 +1,12 @@
part of 'new_entry.dart'; part of 'new_entry.dart';
class _CalculatorPickerPage extends StatelessWidget { class _CalculatorPickerPage extends StatelessWidget {
const _CalculatorPickerPage({required this.onResult}); const _CalculatorPickerPage({
required this.onResult,
this.initialStations,
});
final ValueChanged<RouteResult> onResult; final ValueChanged<RouteResult> onResult;
final List<String>? initialStations;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -14,8 +18,10 @@ class _CalculatorPickerPage extends StatelessWidget {
), ),
title: const Text('Mileage calculator'), title: const Text('Mileage calculator'),
), ),
body: RouteCalculator(onApplyRoute: onResult), body: RouteCalculator(
onApplyRoute: onResult,
initialStations: initialStations,
),
); );
} }
} }

View File

@@ -27,7 +27,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final fieldList = missing.join(', '); final fieldList = missing.join(', ');
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (_) => AlertDialog( useRootNavigator: false,
builder: (dialogCtx) => AlertDialog(
title: const Text('Required field missing'), title: const Text('Required field missing'),
content: Text( content: Text(
missing.length == 1 missing.length == 1
@@ -36,7 +37,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('OK'), child: const Text('OK'),
), ),
], ],
@@ -46,7 +47,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
Future<void> _submit() async { Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return; final form = _formKey.currentState;
if (form == null) return;
if (!form.validate()) return;
if (!await _validateRequiredFields()) return; if (!await _validateRequiredFields()) return;
final routeStations = _routeResult?.calculatedRoute ?? []; final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage final startVal = _useManualMileage
@@ -208,6 +211,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_selectedTripId = null; _selectedTripId = null;
_submitting = false; _submitting = false;
_activeDraftId = null; _activeDraftId = null;
_savingDraft = false;
_loadedDraftSnapshot = null;
}); });
if (clearDraft) { if (clearDraft) {
await _clearDraft(); await _clearDraft();

View File

@@ -26,6 +26,55 @@ class _TripsPageState extends State<TripsPage> {
await context.read<DataService>().fetchTripDetails(); await context.read<DataService>().fetchTripDetails();
} }
Future<void> _renameTrip(TripDetail trip, String newName) async {
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await api.post('/trips/rename', {
"trip_id": trip.id,
"trip_name": newName,
});
await Future.wait([
data.fetchTripDetails(),
data.fetchTrips(),
]);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to rename trip: $e')),
);
rethrow;
}
}
Future<String?> _promptTripName(BuildContext context, String initial) async {
final controller = TextEditingController(text: initial);
final newName = await showDialog<String>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Rename trip'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Trip name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.of(dialogCtx).pop(controller.text.trim()),
child: const Text('Save'),
),
],
),
);
controller.dispose();
return newName;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
@@ -218,6 +267,24 @@ class _TripsPageState extends State<TripsPage> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
bool renaming = false;
String tripName = trip.name;
return StatefulBuilder(
builder: (sheetCtx, setSheetState) {
Future<void> handleRename() async {
final newName =
await _promptTripName(sheetCtx, tripName) ?? tripName;
if (newName.isEmpty || newName == tripName) return;
setSheetState(() => renaming = true);
try {
await _renameTrip(trip, newName);
tripName = newName;
setSheetState(() {});
} finally {
if (mounted) setSheetState(() => renaming = false);
}
}
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -229,15 +296,28 @@ class _TripsPageState extends State<TripsPage> {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(sheetCtx).pop(),
), ),
Text( Expanded(
trip.name, child: Text(
style: Theme.of(context).textTheme.titleMedium?.copyWith( tripName,
fontWeight: FontWeight.bold, style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
const Spacer(), IconButton(
icon: renaming
? const SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
tooltip: 'Rename trip',
onPressed: renaming ? null : handleRename,
),
const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text('${trip.mileage.toStringAsFixed(1)} mi'),
], ],
), ),
@@ -267,6 +347,8 @@ class _TripsPageState extends State<TripsPage> {
); );
}, },
); );
},
);
} }
void _showTripWinners(BuildContext context, TripDetail trip) { void _showTripWinners(BuildContext context, TripDetail trip) {

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
int _asInt(dynamic value, [int fallback = 0]) { int _asInt(dynamic value, [int? fallback]) {
if (value is int) return value; if (value is int) return value;
if (value is num) return value.toInt(); if (value is num) return value.toInt();
final parsed = int.tryParse(value?.toString() ?? ''); final parsed = int.tryParse(value?.toString() ?? '');
return parsed ?? fallback; return parsed ?? fallback ?? 0;
} }
double _asDouble(dynamic value, [double fallback = 0]) { double _asDouble(dynamic value, [double fallback = 0]) {
@@ -20,6 +20,15 @@ String _asString(dynamic value, [String fallback = '']) {
return (str == null) ? fallback : str; return (str == null) ? fallback : str;
} }
bool _asBool(dynamic value, [bool fallback = false]) {
if (value is bool) return value;
if (value is num) return value != 0;
final lower = value?.toString().toLowerCase();
if (lower == 'true' || lower == 'yes' || lower == '1') return true;
if (lower == 'false' || lower == 'no' || lower == '0') return false;
return fallback;
}
DateTime _asDateTime(dynamic value, [DateTime? fallback]) { DateTime _asDateTime(dynamic value, [DateTime? fallback]) {
if (value is DateTime) return value; if (value is DateTime) return value;
final parsed = DateTime.tryParse(value?.toString() ?? ''); final parsed = DateTime.tryParse(value?.toString() ?? '');
@@ -67,6 +76,7 @@ class HomepageStats {
final List<LocoSummary> topLocos; final List<LocoSummary> topLocos;
final List<LeaderboardEntry> leaderboard; final List<LeaderboardEntry> leaderboard;
final List<TripSummary> trips; final List<TripSummary> trips;
final int legCount;
final UserData? user; final UserData? user;
HomepageStats({ HomepageStats({
@@ -75,6 +85,7 @@ class HomepageStats {
required this.topLocos, required this.topLocos,
required this.leaderboard, required this.leaderboard,
required this.trips, required this.trips,
required this.legCount,
this.user, this.user,
}); });
@@ -98,6 +109,10 @@ class HomepageStats {
trips: (json['trip_data'] as List? ?? []) trips: (json['trip_data'] as List? ?? [])
.map((e) => TripSummary.fromJson(e)) .map((e) => TripSummary.fromJson(e))
.toList(), .toList(),
legCount: _asInt(
json['leg_count'],
(json['trip_legs'] as List?)?.length ?? 0,
),
user: userData == null user: userData == null
? null ? null
: UserData( : UserData(
@@ -126,6 +141,7 @@ class Loco {
final int id; final int id;
final String type, number, locoClass; final String type, number, locoClass;
final String? name, operator, notes, evn; final String? name, operator, notes, evn;
final bool powering;
Loco({ Loco({
required this.id, required this.id,
@@ -136,6 +152,7 @@ class Loco {
required this.operator, required this.operator,
this.notes, this.notes,
this.evn, this.evn,
this.powering = true,
}); });
factory Loco.fromJson(Map<String, dynamic> json) => Loco( factory Loco.fromJson(Map<String, dynamic> json) => Loco(
@@ -147,6 +164,7 @@ class Loco {
operator: json['operator'], operator: json['operator'],
notes: json['notes'], notes: json['notes'],
evn: json['evn'], evn: json['evn'],
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
); );
} }
@@ -179,6 +197,7 @@ class LocoSummary extends Loco {
this.livery, this.livery,
this.location, this.location,
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
bool powering = true,
}) : extra = extra ?? const {}, }) : extra = extra ?? const {},
super( super(
id: locoId, id: locoId,
@@ -188,6 +207,7 @@ class LocoSummary extends Loco {
operator: locoOperator, operator: locoOperator,
notes: locoNotes, notes: locoNotes,
evn: locoEvn, evn: locoEvn,
powering: powering,
); );
factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary( factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary(
@@ -213,6 +233,7 @@ class LocoSummary extends Loco {
livery: json['livery'], livery: json['livery'],
location: json['location'], location: json['location'],
extra: Map<String, dynamic>.from(json), extra: Map<String, dynamic>.from(json),
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
); );
} }
@@ -353,6 +374,96 @@ class LocoAttrVersion {
} }
} }
class LocoChange {
final int locoId;
final String locoClass;
final String locoNumber;
final String locoName;
final String attrCode;
final String attrDisplay;
final String valueDisplay;
final DateTime? validFrom;
final DateTime? approvedAt;
final String approvedBy;
const LocoChange({
required this.locoId,
required this.locoClass,
required this.locoNumber,
required this.locoName,
required this.attrCode,
required this.attrDisplay,
required this.valueDisplay,
required this.validFrom,
required this.approvedAt,
required this.approvedBy,
});
factory LocoChange.fromJson(Map<String, dynamic> json) {
String _clean(dynamic value) {
final str = value?.toString().trim() ?? '';
if (str.isEmpty || str == '-' || str == '?') return '';
return str;
}
final valueLabel = json['value_norm'] ??
json['value_display'] ??
json['value_label'] ??
json['value_str'] ??
json['value_enum'] ??
json['value_norm'] ??
json['value'];
final approvedRaw = json['approved_at'] ?? json['approvedAt'];
final validFromRaw = json['valid_from'] ?? json['validFrom'];
return LocoChange(
locoId: _asInt(json['loco_id']),
locoClass: _clean(json['loco_class']),
locoNumber: _clean(json['loco_number']),
locoName: _clean(json['loco_name']),
attrCode: _asString(json['attr_code']),
attrDisplay: _clean(json['attr_display']),
valueDisplay: _clean(valueLabel),
validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''),
approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''),
approvedBy: _clean(json['approved_by']),
);
}
String get locoLabel {
final parts = [locoClass, locoNumber]
.map((e) => e.trim())
.where((e) => e.isNotEmpty && e != '-')
.toList();
final label = parts.join(' ');
if (label.isEmpty) return locoName.isNotEmpty ? locoName : 'Loco $locoId';
return locoName.trim().isEmpty ? label : '$label${locoName.trim()}';
}
String get changeLabel =>
_cleanLabel(attrDisplay).isNotEmpty
? _cleanLabel(attrDisplay)
: _cleanLabel(attrCode).toUpperCase();
String get approvedDateLabel {
final date = approvedAt ?? validFrom;
if (date == null) return 'Pending date';
return DateFormat('yyyy-MM-dd').format(date);
}
String get valueLabel {
final value = _cleanLabel(valueDisplay);
if (value.isNotEmpty) return value;
return 'Unknown value';
}
String _cleanLabel(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return '';
if (trimmed == '-' || trimmed == '?') return '';
return trimmed;
}
}
class LeaderboardEntry { class LeaderboardEntry {
final String userId, username, userFullName; final String userId, username, userFullName;
final double mileage; final double mileage;

View File

@@ -44,6 +44,10 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading; bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false; bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore; bool get tractionHasMore => _tractionHasMore;
List<LocoChange> _latestLocoChanges = [];
List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false;
bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading;
final Map<int, List<LocoAttrVersion>> _locoTimelines = {}; final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
final Map<int, bool> _isLocoTimelineLoading = {}; final Map<int, bool> _isLocoTimelineLoading = {};
List<LocoAttrVersion> timelineForLoco(int locoId) => List<LocoAttrVersion> timelineForLoco(int locoId) =>
@@ -148,7 +152,8 @@ class DataService extends ChangeNotifier {
if (json is List) { if (json is List) {
final newLegs = json.map((e) => Leg.fromJson(e)).toList(); final newLegs = json.map((e) => Leg.fromJson(e)).toList();
_legs = append ? [..._legs, ...newLegs] : newLegs; _legs = append ? [..._legs, ...newLegs] : newLegs;
_legsHasMore = newLegs.length >= limit; // Keep "load more" available as long as the server returns items; hide only on empty.
_legsHasMore = newLegs.isNotEmpty;
} else { } else {
throw Exception('Unexpected legs response: $json'); throw Exception('Unexpected legs response: $json');
} }
@@ -180,7 +185,7 @@ class DataService extends ChangeNotifier {
final params = final params =
includeNonPowering ? '?include_non_powering=true' : ''; includeNonPowering ? '?include_non_powering=true' : '';
try { try {
final json = await api.get('/legs/$locoId$params'); final json = await api.get('/legs/by-loco/$locoId$params');
dynamic list = json; dynamic list = json;
if (json is Map) { if (json is Map) {
for (final key in ['legs', 'data', 'results']) { for (final key in ['legs', 'data', 'results']) {
@@ -209,22 +214,46 @@ class DataService extends ChangeNotifier {
final target = date ?? DateTime.now(); final target = date ?? DateTime.now();
final formatted = final formatted =
"${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}"; "${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}";
final endpoint = '/legs/on-this-day?date=$formatted';
dynamic json;
Object? lastError;
for (int attempt = 0; attempt < 2; attempt++) {
try {
json = await api.get(endpoint);
lastError = null;
break;
} catch (e) {
lastError = e;
if (!_looksLikeOnThisDayRoutingConflict(e)) break;
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
try { try {
final json = await api.get('/legs/on-this-day?date=$formatted');
if (json is List) { if (json is List) {
_onThisDay = json.map((e) => Leg.fromJson(e)).toList(); _onThisDay = json.map((e) => Leg.fromJson(e)).toList();
} else { } else {
_onThisDay = []; _onThisDay = [];
} }
} catch (e) { } catch (e) {
debugPrint('Failed to fetch on-this-day legs: $e'); lastError ??= e;
_onThisDay = []; _onThisDay = [];
} finally { } finally {
if (lastError != null) {
debugPrint('Failed to fetch on-this-day legs ($endpoint): $lastError');
}
_isOnThisDayLoading = false; _isOnThisDayLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
bool _looksLikeOnThisDayRoutingConflict(Object error) {
final msg = error.toString();
return msg.contains('API error 422') &&
msg.contains('[path, loco_id]') &&
msg.contains('input: on-this-day');
}
Future<void> fetchEventFields({bool force = false}) async { Future<void> fetchEventFields({bool force = false}) async {
if (_eventFields.isNotEmpty && !force) return; if (_eventFields.isNotEmpty && !force) return;
_isEventFieldsLoading = true; _isEventFieldsLoading = true;
@@ -316,6 +345,8 @@ class DataService extends ChangeNotifier {
_eventFields = []; _eventFields = [];
_locoTimelines.clear(); _locoTimelines.clear();
_isLocoTimelineLoading.clear(); _isLocoTimelineLoading.clear();
_latestLocoChanges = [];
_isLatestLocoChangesLoading = false;
_notifyAsync(); _notifyAsync();
} }

View File

@@ -114,5 +114,40 @@ extension DataServiceTraction on DataService {
} }
return _locoClasses; return _locoClasses;
} }
}
Future<void> fetchLatestLocoChanges({int limit = 25, int offset = 0}) async {
_isLatestLocoChangesLoading = true;
_notifyAsync();
try {
final json =
await api.get('/loco/changes/latest?limit=$limit&offset=$offset');
dynamic results = json;
if (json is Map && json['data'] is List) {
results = json['data'];
}
if (results is List) {
final parsed = <LocoChange>[];
for (final item in results) {
if (item is Map<String, dynamic>) {
parsed.add(LocoChange.fromJson(item));
} else if (item is Map) {
parsed.add(
LocoChange.fromJson(
item.map((key, value) => MapEntry(key.toString(), value)),
),
);
}
}
_latestLocoChanges = parsed;
} else {
throw Exception('Unexpected latest loco changes response: $json');
}
} catch (e) {
debugPrint('Failed to fetch latest loco changes: $e');
_latestLocoChanges = [];
} finally {
_isLatestLocoChangesLoading = false;
_notifyAsync();
}
}
}

View File

@@ -83,5 +83,50 @@ extension DataServiceTrips on DataService {
_notifyAsync(); _notifyAsync();
} }
} }
}
Future<void> fetchTripOptions() async {
try {
final json = await api.get('/trips');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['trips', 'trip_data', 'data']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw != null) {
final tripMap = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
}
} catch (e) {
debugPrint('Failed to fetch trip list: $e');
_tripList = [];
} finally {
_notifyAsync();
}
}
void upsertTripSummary(TripSummary trip) {
final existingIndex =
_tripList.indexWhere((element) => element.tripId == trip.tripId);
if (existingIndex >= 0) {
_tripList[existingIndex] = trip;
} else {
_tripList = [trip, ..._tripList];
}
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
_notifyAsync();
}
}

View File

@@ -7,9 +7,10 @@ class NavigationGuard {
_callback = callback; _callback = callback;
} }
static void unregister(NavigationGuardCallback callback) { static void unregister([NavigationGuardCallback? callback]) {
if (_callback == callback) { if (callback == null || identical(_callback, callback)) {
_callback = null; _callback = null;
_promptActive = false;
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/login/login.dart'; import 'package:mileograph_flutter/components/login/login.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart';
@@ -17,6 +18,43 @@ import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
const List<String> _contentPages = [
"/",
"/calculator",
"/legs",
"/traction",
"/trips",
"/add",
];
const int _addTabIndex = 5;
class _NavItem {
final String label;
final IconData icon;
const _NavItem(this.label, this.icon);
}
const List<_NavItem> _navItems = [
_NavItem("Home", Icons.home),
_NavItem("Calculator", Icons.route),
_NavItem("Entries", Icons.list),
_NavItem("Traction", Icons.train),
_NavItem("Trips", Icons.book),
_NavItem("Add", Icons.add),
];
int tabIndexForPath(String path) {
final newIndex = _contentPages.indexWhere((routePath) {
if (path == routePath) return true;
if (routePath == '/') return path == '/';
return path.startsWith('$routePath/');
});
return newIndex < 0 ? 0 : newIndex;
}
class MyApp extends StatefulWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@@ -52,6 +90,7 @@ class _MyAppState extends State<MyApp> {
}, },
routes: [ routes: [
ShellRoute( ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => MyHomePage(child: child), builder: (context, state, child) => MyHomePage(child: child),
routes: [ routes: [
GoRoute(path: '/', builder: (context, state) => const Dashboard()), GoRoute(path: '/', builder: (context, state) => const Dashboard()),
@@ -153,26 +192,7 @@ class MyHomePage extends StatefulWidget {
} }
class _MyHomePageState extends State<MyHomePage> { class _MyHomePageState extends State<MyHomePage> {
final List<String> contentPages = [ List<String> get contentPages => _contentPages;
"/",
"/calculator",
"/legs",
"/traction",
"/trips",
"/add",
];
int _getIndexFromLocation(String location) {
int newIndex = contentPages.indexWhere((path) {
if (location == path) return true;
if (path == '/') return location == '/';
return location.startsWith('$path/');
});
if (newIndex < 0) {
return 0;
}
return newIndex;
}
Future<void> _onItemTapped(int index, int currentIndex) async { Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length || index == currentIndex) { if (index < 0 || index >= contentPages.length || index == currentIndex) {
@@ -184,6 +204,10 @@ class _MyHomePageState extends State<MyHomePage> {
}); });
} }
int? _lastTabIndex;
final List<int> _tabHistory = [];
bool _handlingBackNavigation = false;
bool _fetched = false; bool _fetched = false;
@override @override
@@ -209,6 +233,9 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.traction.isEmpty) { if (data.traction.isEmpty) {
data.fetchHadTraction(); data.fetchHadTraction();
} }
if (data.latestLocoChanges.isEmpty) {
data.fetchLatestLocoChanges();
}
if (data.onThisDay.isEmpty) { if (data.onThisDay.isEmpty) {
data.fetchOnThisDay(); data.fetchOnThisDay();
} }
@@ -221,8 +248,12 @@ class _MyHomePageState extends State<MyHomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.toString(); final uri = GoRouterState.of(context).uri;
final pageIndex = _getIndexFromLocation(location); final pageIndex = tabIndexForPath(uri.path);
_recordTabChange(pageIndex);
if (pageIndex != _addTabIndex) {
NavigationGuard.unregister();
}
final homepageReady = context.select<DataService, bool>( final homepageReady = context.select<DataService, bool>(
(data) => data.homepageStats != null || !data.isHomepageLoading, (data) => data.homepageStats != null || !data.isHomepageLoading,
); );
@@ -232,6 +263,55 @@ class _MyHomePageState extends State<MyHomePage> {
? widget.child ? widget.child
: const Center(child: CircularProgressIndicator()); : const Center(child: CircularProgressIndicator());
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
final shellNav = _shellNavigatorKey.currentState;
if (shellNav != null && shellNav.canPop()) {
shellNav.pop();
return;
}
if (_tabHistory.isNotEmpty) {
final previousTab = _tabHistory.removeLast();
if (!mounted) return;
_handlingBackNavigation = true;
context.go(contentPages[previousTab]);
return;
}
if (pageIndex != 0) {
if (!mounted) return;
_handlingBackNavigation = true;
context.go(contentPages[0]);
return;
}
SystemNavigator.pop();
},
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 900;
final railExtended = constraints.maxWidth >= 1400;
final navRailDestinations = _navItems
.map(
(item) => NavigationRailDestination(
icon: Icon(item.icon),
label: Text(item.label),
),
)
.toList();
final navBarDestinations = _navItems
.map(
(item) => NavigationDestination(
icon: Icon(item.icon),
label: item.label,
),
)
.toList();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
@@ -250,24 +330,64 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
), ),
actions: [ actions: [
const IconButton(onPressed: null, icon: Icon(Icons.account_circle)), const IconButton(
onPressed: null,
icon: Icon(Icons.account_circle),
),
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
], ],
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: isWide
? null
: NavigationBar(
selectedIndex: pageIndex, selectedIndex: pageIndex,
onDestinationSelected: (int index) => _onItemTapped(index, pageIndex), onDestinationSelected: (int index) =>
destinations: const [ _onItemTapped(index, pageIndex),
NavigationDestination(icon: Icon(Icons.home), label: "Home"), destinations: navBarDestinations,
NavigationDestination(icon: Icon(Icons.route), label: "Calculator"), ),
NavigationDestination(icon: Icon(Icons.list), label: "Entries"), body: isWide
NavigationDestination(icon: Icon(Icons.train), label: "Traction"), ? Row(
NavigationDestination(icon: Icon(Icons.book), label: "Trips"), children: [
NavigationDestination(icon: Icon(Icons.add), label: "Add"), SafeArea(
], child: NavigationRail(
selectedIndex: pageIndex,
extended: railExtended,
labelType: railExtended
? NavigationRailLabelType.none
: NavigationRailLabelType.selected,
onDestinationSelected: (int index) =>
_onItemTapped(index, pageIndex),
destinations: navRailDestinations,
),
),
const VerticalDivider(width: 1),
Expanded(child: currentPage),
],
)
: currentPage,
);
},
), ),
body: currentPage,
); );
} }
}
void _recordTabChange(int pageIndex) {
final last = _lastTabIndex;
if (last == null) {
_lastTabIndex = pageIndex;
return;
}
if (last == pageIndex) return;
if (_handlingBackNavigation) {
_handlingBackNavigation = false;
_lastTabIndex = pageIndex;
return;
}
if (_tabHistory.isEmpty || _tabHistory.last != last) {
_tabHistory.add(last);
}
_lastTabIndex = pageIndex;
}
}

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.2.3+1 version: 0.3.0+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1

20
test/app_shell_test.dart Normal file
View File

@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mileograph_flutter/ui/app_shell.dart';
void main() {
test('tabIndexForPath maps nested routes', () {
expect(tabIndexForPath('/'), 0);
expect(tabIndexForPath('/calculator'), 1);
expect(tabIndexForPath('/calculator/details'), 1);
expect(tabIndexForPath('/legs'), 2);
expect(tabIndexForPath('/traction/12/timeline'), 3);
expect(tabIndexForPath('/trips'), 4);
expect(tabIndexForPath('/add'), 5);
});
test('tabIndexForPath ignores query when parsing uri', () {
expect(tabIndexForPath(Uri.parse('/trips?sort=desc').path), 4);
expect(tabIndexForPath(Uri.parse('/calculator/details?x=1').path), 1);
});
}