diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85b7ac8..6ff2b6a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -45,7 +45,7 @@ android { defaultConfig { // 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. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/lib/components/pages/new_entry/new_entry.dart b/lib/components/pages/new_entry/new_entry.dart index 9bb48a1..7d2124d 100644 --- a/lib/components/pages/new_entry/new_entry.dart +++ b/lib/components/pages/new_entry/new_entry.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/components/calculator/calculator.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index e59e2d2..672f9c1 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -10,6 +10,7 @@ class NewEntryPage extends StatefulWidget { } class _NewEntryPageState extends State { + late final NavigationGuardCallback _exitGuard; final _formKey = GlobalKey(); DateTime _selectedDate = DateTime.now(); TimeOfDay _selectedTime = TimeOfDay.now(); @@ -40,7 +41,8 @@ class _NewEntryPageState extends State { @override void 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 Future.microtask(() { if (!mounted) return; @@ -58,7 +60,7 @@ class _NewEntryPageState extends State { @override void dispose() { - NavigationGuard.unregister(_handleExitIntent); + NavigationGuard.unregister(_exitGuard); _startController.dispose(); _endController.dispose(); _headcodeController.dispose(); @@ -586,7 +588,12 @@ class _NewEntryPageState extends State { if (didPop) return; final allow = await _handleExitIntent(); if (allow && context.mounted) { - Navigator.of(context).maybePop(); + final router = GoRouter.of(context); + if (router.canPop()) { + context.pop(); + } else { + context.go('/'); + } } }, child: Scaffold( @@ -597,7 +604,12 @@ class _NewEntryPageState extends State { onPressed: () async { if (!await _handleExitIntent()) 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'), diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 7e50ed1..9e652d4 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -209,22 +209,46 @@ class DataService extends ChangeNotifier { final target = date ?? DateTime.now(); final formatted = "${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.delayed(const Duration(milliseconds: 250)); + } + } + try { - final json = await api.get('/legs/on-this-day?date=$formatted'); if (json is List) { _onThisDay = json.map((e) => Leg.fromJson(e)).toList(); } else { _onThisDay = []; } } catch (e) { - debugPrint('Failed to fetch on-this-day legs: $e'); + lastError ??= e; _onThisDay = []; } finally { + if (lastError != null) { + debugPrint('Failed to fetch on-this-day legs ($endpoint): $lastError'); + } _isOnThisDayLoading = false; _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 fetchEventFields({bool force = false}) async { if (_eventFields.isNotEmpty && !force) return; _isEventFieldsLoading = true; diff --git a/lib/services/navigation_guard.dart b/lib/services/navigation_guard.dart index 6f9c7de..b062dfc 100644 --- a/lib/services/navigation_guard.dart +++ b/lib/services/navigation_guard.dart @@ -7,9 +7,10 @@ class NavigationGuard { _callback = callback; } - static void unregister(NavigationGuardCallback callback) { - if (_callback == callback) { + static void unregister([NavigationGuardCallback? callback]) { + if (callback == null || identical(_callback, callback)) { _callback = null; + _promptActive = false; } } diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index 34e3d1c..eafe85a 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -1,5 +1,6 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/login/login.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart'; @@ -17,6 +18,28 @@ import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:provider/provider.dart'; +final GlobalKey _shellNavigatorKey = GlobalKey(); + +const List _contentPages = [ + "/", + "/calculator", + "/legs", + "/traction", + "/trips", + "/add", +]; + +const int _addTabIndex = 5; + +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 { const MyApp({super.key}); @@ -52,6 +75,7 @@ class _MyAppState extends State { }, routes: [ ShellRoute( + navigatorKey: _shellNavigatorKey, builder: (context, state, child) => MyHomePage(child: child), routes: [ GoRoute(path: '/', builder: (context, state) => const Dashboard()), @@ -153,26 +177,7 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - final List 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; - } + List get contentPages => _contentPages; Future _onItemTapped(int index, int currentIndex) async { if (index < 0 || index >= contentPages.length || index == currentIndex) { @@ -184,6 +189,10 @@ class _MyHomePageState extends State { }); } + int? _lastTabIndex; + final List _tabHistory = []; + bool _handlingBackNavigation = false; + bool _fetched = false; @override @@ -221,8 +230,12 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { - final location = GoRouterState.of(context).uri.toString(); - final pageIndex = _getIndexFromLocation(location); + final uri = GoRouterState.of(context).uri; + final pageIndex = tabIndexForPath(uri.path); + _recordTabChange(pageIndex); + if (pageIndex != _addTabIndex) { + NavigationGuard.unregister(); + } final homepageReady = context.select( (data) => data.homepageStats != null || !data.isHomepageLoading, ); @@ -232,42 +245,90 @@ class _MyHomePageState extends State { ? widget.child : const Center(child: CircularProgressIndicator()); - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text.rich( - TextSpan( - children: const [ - TextSpan(text: "Mile"), - TextSpan(text: "O", style: TextStyle(color: Colors.red)), - TextSpan(text: "graph"), - ], - style: const TextStyle( - decoration: TextDecoration.none, - color: Colors.white, - fontFamily: "Tomatoes", + 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: Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text.rich( + TextSpan( + children: const [ + TextSpan(text: "Mile"), + TextSpan(text: "O", style: TextStyle(color: Colors.red)), + TextSpan(text: "graph"), + ], + style: const TextStyle( + decoration: TextDecoration.none, + color: Colors.white, + fontFamily: "Tomatoes", + ), ), ), + actions: [ + const IconButton(onPressed: null, icon: Icon(Icons.account_circle)), + IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), + ], ), - actions: [ - const IconButton(onPressed: null, icon: Icon(Icons.account_circle)), - IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), - ], + bottomNavigationBar: NavigationBar( + selectedIndex: pageIndex, + onDestinationSelected: (int index) => _onItemTapped(index, pageIndex), + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: "Home"), + NavigationDestination(icon: Icon(Icons.route), label: "Calculator"), + NavigationDestination(icon: Icon(Icons.list), label: "Entries"), + NavigationDestination(icon: Icon(Icons.train), label: "Traction"), + NavigationDestination(icon: Icon(Icons.book), label: "Trips"), + NavigationDestination(icon: Icon(Icons.add), label: "Add"), + ], + ), + body: currentPage, ), - bottomNavigationBar: NavigationBar( - selectedIndex: pageIndex, - onDestinationSelected: (int index) => _onItemTapped(index, pageIndex), - destinations: const [ - NavigationDestination(icon: Icon(Icons.home), label: "Home"), - NavigationDestination(icon: Icon(Icons.route), label: "Calculator"), - NavigationDestination(icon: Icon(Icons.list), label: "Entries"), - NavigationDestination(icon: Icon(Icons.train), label: "Traction"), - NavigationDestination(icon: Icon(Icons.book), label: "Trips"), - NavigationDestination(icon: Icon(Icons.add), label: "Add"), - ], - ), - 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; + } +} diff --git a/test/app_shell_test.dart b/test/app_shell_test.dart new file mode 100644 index 0000000..1f4d523 --- /dev/null +++ b/test/app_shell_test.dart @@ -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); + }); +} +