Compare commits
2 Commits
v0.2.3-dev
...
63b545c7a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 63b545c7a3 | |||
| 587933fa50 |
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -40,7 +41,8 @@ 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;
|
||||||
@@ -58,7 +60,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();
|
||||||
@@ -586,7 +588,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 +604,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'),
|
||||||
|
|||||||
@@ -209,22 +209,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 @@ 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;
|
||||||
|
|
||||||
|
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 +75,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 +177,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 +189,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _lastTabIndex;
|
||||||
|
final List<int> _tabHistory = [];
|
||||||
|
bool _handlingBackNavigation = false;
|
||||||
|
|
||||||
bool _fetched = false;
|
bool _fetched = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -221,8 +230,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,7 +245,35 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
? widget.child
|
? widget.child
|
||||||
: const Center(child: CircularProgressIndicator());
|
: const Center(child: CircularProgressIndicator());
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
appBar: AppBar(
|
||||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
title: Text.rich(
|
title: Text.rich(
|
||||||
@@ -267,7 +308,27 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.2.4+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
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