405 lines
13 KiB
Dart
405 lines
13 KiB
Dart
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';
|
|
import 'package:mileograph_flutter/components/pages/calculator_details.dart';
|
|
import 'package:mileograph_flutter/components/pages/dashboard.dart';
|
|
import 'package:mileograph_flutter/components/pages/legs.dart';
|
|
import 'package:mileograph_flutter/components/pages/loco_legs.dart';
|
|
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
|
|
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
|
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
|
import 'package:mileograph_flutter/components/pages/trips.dart';
|
|
import 'package:mileograph_flutter/services/authservice.dart';
|
|
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;
|
|
|
|
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 {
|
|
const MyApp({super.key});
|
|
|
|
@override
|
|
State<MyApp> createState() => _MyAppState();
|
|
}
|
|
|
|
class _MyAppState extends State<MyApp> {
|
|
late final GoRouter _router;
|
|
bool _routerInitialized = false;
|
|
|
|
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
|
|
final ColorScheme defaultDark = ColorScheme.fromSeed(
|
|
seedColor: Colors.red,
|
|
brightness: Brightness.dark,
|
|
);
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_routerInitialized) return;
|
|
_routerInitialized = true;
|
|
final auth = context.read<AuthService>();
|
|
_router = GoRouter(
|
|
refreshListenable: auth,
|
|
redirect: (context, state) {
|
|
final loggedIn = auth.isLoggedIn;
|
|
final loggingIn = state.uri.toString() == '/login';
|
|
final atSettings = state.uri.toString() == '/settings';
|
|
|
|
if (!loggedIn && !loggingIn && !atSettings) return '/login';
|
|
if (loggedIn && loggingIn) return '/';
|
|
return null;
|
|
},
|
|
routes: [
|
|
ShellRoute(
|
|
navigatorKey: _shellNavigatorKey,
|
|
builder: (context, state, child) => MyHomePage(child: child),
|
|
routes: [
|
|
GoRoute(path: '/', builder: (context, state) => const Dashboard()),
|
|
GoRoute(
|
|
path: '/calculator',
|
|
builder: (context, state) => CalculatorPage(),
|
|
),
|
|
GoRoute(
|
|
path: '/calculator/details',
|
|
builder: (context, state) =>
|
|
CalculatorDetailsPage(result: state.extra),
|
|
),
|
|
GoRoute(path: '/legs', builder: (context, state) => LegsPage()),
|
|
GoRoute(
|
|
path: '/traction',
|
|
builder: (context, state) => TractionPage(),
|
|
),
|
|
GoRoute(
|
|
path: '/traction/:id/timeline',
|
|
builder: (_, state) {
|
|
final idParam = state.pathParameters['id'];
|
|
final locoId = int.tryParse(idParam ?? '') ?? 0;
|
|
final extra = state.extra;
|
|
String label = state.uri.queryParameters['label'] ?? '';
|
|
if (extra is Map && extra['label'] is String) {
|
|
label = extra['label'] as String;
|
|
} else if (extra is String && extra.isNotEmpty) {
|
|
label = extra;
|
|
}
|
|
if (label.trim().isEmpty) label = 'Loco $locoId';
|
|
return LocoTimelinePage(locoId: locoId, locoLabel: label);
|
|
},
|
|
),
|
|
GoRoute(
|
|
path: '/traction/:id/legs',
|
|
builder: (_, state) {
|
|
final idParam = state.pathParameters['id'];
|
|
final locoId = int.tryParse(idParam ?? '') ?? 0;
|
|
final extra = state.extra;
|
|
String label = state.uri.queryParameters['label'] ?? '';
|
|
if (extra is Map && extra['label'] is String) {
|
|
label = extra['label'] as String;
|
|
} else if (extra is String && extra.isNotEmpty) {
|
|
label = extra;
|
|
}
|
|
if (label.trim().isEmpty) label = 'Loco $locoId';
|
|
return LocoLegsPage(locoId: locoId, locoLabel: label);
|
|
},
|
|
),
|
|
GoRoute(
|
|
path: '/traction/new',
|
|
builder: (context, state) => const NewTractionPage(),
|
|
),
|
|
GoRoute(path: '/trips', builder: (context, state) => TripsPage()),
|
|
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()),
|
|
GoRoute(
|
|
path: '/legs/edit/:id',
|
|
builder: (_, state) {
|
|
final idParam = state.pathParameters['id'];
|
|
final legId = idParam == null ? null : int.tryParse(idParam);
|
|
return NewEntryPage(editLegId: legId);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
|
|
GoRoute(
|
|
path: '/settings',
|
|
builder: (context, state) => const SettingsPage(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DynamicColorBuilder(
|
|
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
|
return MaterialApp.router(
|
|
title: 'Mileograph',
|
|
routerConfig: _router,
|
|
theme: ThemeData(
|
|
useMaterial3: true,
|
|
colorScheme: lightDynamic ?? defaultLight,
|
|
),
|
|
darkTheme: ThemeData(
|
|
useMaterial3: true,
|
|
colorScheme: darkDynamic ?? defaultDark,
|
|
),
|
|
themeMode: ThemeMode.system,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class MyHomePage extends StatefulWidget {
|
|
final Widget child;
|
|
const MyHomePage({super.key, required this.child});
|
|
|
|
@override
|
|
State<MyHomePage> createState() => _MyHomePageState();
|
|
}
|
|
|
|
class _MyHomePageState extends State<MyHomePage> {
|
|
List<String> get contentPages => _contentPages;
|
|
|
|
Future<void> _onItemTapped(int index, int currentIndex) async {
|
|
if (index < 0 || index >= contentPages.length || index == currentIndex) {
|
|
return;
|
|
}
|
|
await NavigationGuard.attemptNavigation(() async {
|
|
if (!mounted) return;
|
|
context.go(contentPages[index]);
|
|
});
|
|
}
|
|
|
|
int? _lastTabIndex;
|
|
final List<int> _tabHistory = [];
|
|
bool _handlingBackNavigation = false;
|
|
|
|
bool _fetched = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
|
|
if (_fetched) return;
|
|
_fetched = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
Future(() async {
|
|
if (!mounted) return;
|
|
final data = context.read<DataService>();
|
|
final auth = context.read<AuthService>();
|
|
await auth.tryRestoreSession();
|
|
if (!auth.isLoggedIn) return;
|
|
data.fetchEventFields();
|
|
if (data.homepageStats == null) {
|
|
data.fetchHomepageStats();
|
|
}
|
|
if (data.legs.isEmpty) {
|
|
data.fetchLegs();
|
|
}
|
|
if (data.traction.isEmpty) {
|
|
data.fetchHadTraction();
|
|
}
|
|
if (data.latestLocoChanges.isEmpty) {
|
|
data.fetchLatestLocoChanges();
|
|
}
|
|
if (data.onThisDay.isEmpty) {
|
|
data.fetchOnThisDay();
|
|
}
|
|
if (data.tripDetails.isEmpty) {
|
|
data.fetchTripDetails();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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,
|
|
);
|
|
final auth = context.read<AuthService>();
|
|
|
|
final currentPage = homepageReady
|
|
? widget.child
|
|
: 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(
|
|
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(
|
|
tooltip: 'Settings',
|
|
onPressed: () => context.go('/settings'),
|
|
icon: const Icon(Icons.settings),
|
|
),
|
|
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
|
|
],
|
|
),
|
|
bottomNavigationBar: isWide
|
|
? null
|
|
: NavigationBar(
|
|
selectedIndex: pageIndex,
|
|
onDestinationSelected: (int index) =>
|
|
_onItemTapped(index, pageIndex),
|
|
destinations: navBarDestinations,
|
|
),
|
|
body: isWide
|
|
? Row(
|
|
children: [
|
|
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,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|