Compare commits
2 Commits
v0.2.3-dev
...
63b545c7a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 63b545c7a3 | |||
| 587933fa50 |
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -10,6 +10,7 @@ class NewEntryPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NewEntryPageState extends State<NewEntryPage> {
|
||||
late final NavigationGuardCallback _exitGuard;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
TimeOfDay _selectedTime = TimeOfDay.now();
|
||||
@@ -40,7 +41,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
@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<NewEntryPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
NavigationGuard.unregister(_handleExitIntent);
|
||||
NavigationGuard.unregister(_exitGuard);
|
||||
_startController.dispose();
|
||||
_endController.dispose();
|
||||
_headcodeController.dispose();
|
||||
@@ -586,7 +588,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
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<NewEntryPage> {
|
||||
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'),
|
||||
|
||||
@@ -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<void>.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<void> fetchEventFields({bool force = false}) async {
|
||||
if (_eventFields.isNotEmpty && !force) return;
|
||||
_isEventFieldsLoading = true;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
const List<String> _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<MyApp> {
|
||||
},
|
||||
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<MyHomePage> {
|
||||
final List<String> 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<String> get contentPages => _contentPages;
|
||||
|
||||
Future<void> _onItemTapped(int index, int currentIndex) async {
|
||||
if (index < 0 || index >= contentPages.length || index == currentIndex) {
|
||||
@@ -184,6 +189,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
});
|
||||
}
|
||||
|
||||
int? _lastTabIndex;
|
||||
final List<int> _tabHistory = [];
|
||||
bool _handlingBackNavigation = false;
|
||||
|
||||
bool _fetched = false;
|
||||
|
||||
@override
|
||||
@@ -221,8 +230,12 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
|
||||
@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<DataService, bool>(
|
||||
(data) => data.homepageStats != null || !data.isHomepageLoading,
|
||||
);
|
||||
@@ -232,42 +245,90 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
? 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# 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.
|
||||
version: 0.2.3+1
|
||||
version: 0.2.4+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
20
test/app_shell_test.dart
Normal file
20
test/app_shell_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user