876 lines
28 KiB
Dart
876 lines
28 KiB
Dart
import 'package:dynamic_color/dynamic_color.dart';
|
|
import 'package:flutter/gestures.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/dashboard.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/logbook.dart';
|
|
import 'package:mileograph_flutter/components/pages/more.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/profile.dart';
|
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
|
import 'package:mileograph_flutter/components/pages/traction.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 = [
|
|
"/dashboard",
|
|
"/logbook",
|
|
"/traction",
|
|
"/add",
|
|
"/more",
|
|
];
|
|
|
|
const List<String> _defaultTabDestinations = [
|
|
"/dashboard",
|
|
"/logbook/entries",
|
|
"/traction",
|
|
"/add",
|
|
"/more",
|
|
];
|
|
|
|
const int _addTabIndex = 3;
|
|
|
|
class _NavItem {
|
|
final String label;
|
|
final IconData icon;
|
|
const _NavItem(this.label, this.icon);
|
|
}
|
|
|
|
const List<_NavItem> _navItems = [
|
|
_NavItem("Home", Icons.home),
|
|
_NavItem("Logbook", Icons.menu_book),
|
|
_NavItem("Traction", Icons.train),
|
|
_NavItem("Add", Icons.add),
|
|
_NavItem("More", Icons.more_horiz),
|
|
];
|
|
|
|
int tabIndexForPath(String path) {
|
|
var matchPath = path;
|
|
if (matchPath == '/') matchPath = '/dashboard';
|
|
if (matchPath.startsWith('/dashboard')) return 0;
|
|
if (matchPath.startsWith('/legs')) {
|
|
matchPath = '/logbook/entries';
|
|
} else if (matchPath.startsWith('/trips')) {
|
|
matchPath = '/logbook/trips';
|
|
}
|
|
if (matchPath.startsWith('/logbook')) {
|
|
matchPath = '/logbook';
|
|
} else if (matchPath.startsWith('/profile') ||
|
|
matchPath.startsWith('/settings') ||
|
|
matchPath.startsWith('/more')) {
|
|
matchPath = '/more';
|
|
}
|
|
final newIndex = _contentPages.indexWhere(
|
|
(routePath) =>
|
|
matchPath == routePath || matchPath.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(
|
|
initialLocation: '/dashboard',
|
|
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 '/dashboard';
|
|
return null;
|
|
},
|
|
routes: [
|
|
GoRoute(
|
|
path: '/',
|
|
redirect: (context, state) => '/dashboard',
|
|
),
|
|
ShellRoute(
|
|
navigatorKey: _shellNavigatorKey,
|
|
builder: (context, state, child) => MyHomePage(child: child),
|
|
routes: [
|
|
GoRoute(
|
|
path: '/dashboard',
|
|
builder: (context, state) => const Dashboard(),
|
|
),
|
|
GoRoute(
|
|
path: '/logbook',
|
|
redirect: (context, state) => '/logbook/entries',
|
|
),
|
|
GoRoute(
|
|
path: '/logbook/entries',
|
|
builder: (context, state) => const LogbookPage(),
|
|
),
|
|
GoRoute(
|
|
path: '/logbook/trips',
|
|
builder: (context, state) =>
|
|
const LogbookPage(initialTab: LogbookTab.trips),
|
|
),
|
|
GoRoute(
|
|
path: '/trips',
|
|
redirect: (context, state) => '/logbook/trips',
|
|
),
|
|
GoRoute(
|
|
path: '/legs',
|
|
redirect: (context, state) => '/logbook/entries',
|
|
),
|
|
GoRoute(
|
|
path: '/traction',
|
|
builder: (context, state) => TractionPage(),
|
|
),
|
|
GoRoute(
|
|
path: '/profile',
|
|
builder: (context, state) => const ProfilePage(),
|
|
),
|
|
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: '/add', builder: (context, state) => NewEntryPage()),
|
|
GoRoute(
|
|
path: '/more',
|
|
builder: (context, state) => const MorePage(),
|
|
),
|
|
GoRoute(
|
|
path: '/more/profile',
|
|
builder: (context, state) => const ProfilePage(),
|
|
),
|
|
GoRoute(
|
|
path: '/more/settings',
|
|
builder: (context, state) => const SettingsPage(),
|
|
),
|
|
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 _BackIntent extends Intent {
|
|
const _BackIntent();
|
|
}
|
|
|
|
class _ForwardIntent extends Intent {
|
|
const _ForwardIntent();
|
|
}
|
|
|
|
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 tabDestinations => _defaultTabDestinations;
|
|
|
|
Future<void> _onItemTapped(int index, int currentIndex) async {
|
|
if (index < 0 || index >= tabDestinations.length) {
|
|
return;
|
|
}
|
|
final currentPath = GoRouterState.of(context).uri.path;
|
|
final targetPath = tabDestinations[index];
|
|
final alreadyAtTarget =
|
|
currentPath == targetPath || currentPath.startsWith('$targetPath/');
|
|
if (index == currentIndex && alreadyAtTarget) return;
|
|
|
|
await NavigationGuard.attemptNavigation(() async {
|
|
if (!mounted) return;
|
|
_navigateToIndex(index);
|
|
});
|
|
}
|
|
|
|
final List<String> _history = [];
|
|
int _historyPosition = -1;
|
|
final List<String> _forwardHistory = [];
|
|
bool _suppressRecord = false;
|
|
|
|
bool _fetched = false;
|
|
bool _railCollapsed = 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();
|
|
}
|
|
if (data.notifications.isEmpty) {
|
|
data.fetchNotifications();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final uri = GoRouterState.of(context).uri;
|
|
final pageIndex = tabIndexForPath(uri.path);
|
|
_syncHistory(uri.path);
|
|
if (pageIndex != _addTabIndex) {
|
|
NavigationGuard.unregister();
|
|
}
|
|
final homepageReady = context.select<DataService, bool>(
|
|
(data) => data.homepageStats != null || !data.isHomepageLoading,
|
|
);
|
|
final data = context.watch<DataService>();
|
|
final auth = context.read<AuthService>();
|
|
|
|
final currentPage = homepageReady
|
|
? widget.child
|
|
: const Center(child: CircularProgressIndicator());
|
|
|
|
final scaffold = LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isWide = constraints.maxWidth >= 900;
|
|
final defaultRailExtended = constraints.maxWidth >= 1400;
|
|
final railExtended = defaultRailExtended && !_railCollapsed;
|
|
final showRailToggle = defaultRailExtended;
|
|
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: [
|
|
_buildNotificationAction(context, data),
|
|
IconButton(
|
|
tooltip: 'Settings',
|
|
onPressed: () => context.go('/more/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: LayoutBuilder(
|
|
builder: (ctx, _) {
|
|
return Stack(
|
|
children: [
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: showRailToggle ? 56.0 : 0.0,
|
|
),
|
|
child: NavigationRail(
|
|
selectedIndex: pageIndex,
|
|
extended: railExtended,
|
|
labelType: railExtended
|
|
? NavigationRailLabelType.none
|
|
: NavigationRailLabelType.selected,
|
|
onDestinationSelected: (int index) =>
|
|
_onItemTapped(index, pageIndex),
|
|
destinations: navRailDestinations,
|
|
),
|
|
),
|
|
if (showRailToggle)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 8,
|
|
child: _buildRailToggleButton(railExtended),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(child: currentPage),
|
|
],
|
|
)
|
|
: currentPage,
|
|
);
|
|
},
|
|
);
|
|
|
|
return Shortcuts(
|
|
shortcuts: <LogicalKeySet, Intent>{
|
|
LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(),
|
|
LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(),
|
|
},
|
|
child: Actions(
|
|
actions: {
|
|
_BackIntent: CallbackAction<_BackIntent>(
|
|
onInvoke: (_) {
|
|
_handleBackNavigation(allowExit: false, recordForward: true);
|
|
return null;
|
|
},
|
|
),
|
|
_ForwardIntent: CallbackAction<_ForwardIntent>(
|
|
onInvoke: (_) {
|
|
_handleForwardNavigation();
|
|
return null;
|
|
},
|
|
),
|
|
},
|
|
child: Focus(
|
|
autofocus: true,
|
|
child: Listener(
|
|
onPointerDown: _handlePointerButtons,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, _) async {
|
|
if (didPop) return;
|
|
await _handleBackNavigation(allowExit: true, recordForward: false);
|
|
},
|
|
child: scaffold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _handlePointerButtons(PointerDownEvent event) {
|
|
// Support mouse back/forward buttons.
|
|
if (event.buttons == kBackMouseButton) {
|
|
_handleBackNavigation(allowExit: false, recordForward: true);
|
|
} else if (event.buttons == kForwardMouseButton) {
|
|
_handleForwardNavigation();
|
|
}
|
|
}
|
|
|
|
Widget _buildRailToggleButton(bool railExtended) {
|
|
final collapseIcon = railExtended ? Icons.chevron_left : Icons.chevron_right;
|
|
final collapseLabel = railExtended ? 'Collapse' : 'Expand';
|
|
|
|
if (railExtended) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
child: TextButton.icon(
|
|
onPressed: () => setState(() => _railCollapsed = !_railCollapsed),
|
|
icon: Icon(collapseIcon),
|
|
label: Text(collapseLabel),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
child: IconButton(
|
|
icon: Icon(collapseIcon),
|
|
tooltip: collapseLabel,
|
|
onPressed: () => setState(() => _railCollapsed = !_railCollapsed),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNotificationAction(BuildContext context, DataService data) {
|
|
final count = data.notifications.length;
|
|
final hasBadge = count > 0;
|
|
final badgeText = count > 9 ? '9+' : '$count';
|
|
final isLoading = data.isNotificationsLoading;
|
|
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Notifications',
|
|
onPressed: () => _openNotificationsPanel(context),
|
|
icon: isLoading
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.notifications_none),
|
|
),
|
|
if (hasBadge)
|
|
Positioned(
|
|
right: 6,
|
|
top: 8,
|
|
child: IgnorePointer(child: _buildBadge(badgeText)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _openNotificationsPanel(BuildContext context) async {
|
|
final data = context.read<DataService>();
|
|
final isWide = MediaQuery.sizeOf(context).width >= 900;
|
|
final sheetHeight = MediaQuery.sizeOf(context).height * 0.9;
|
|
try {
|
|
await data.fetchNotifications();
|
|
} catch (_) {
|
|
// Already logged inside data service.
|
|
}
|
|
if (!context.mounted) return;
|
|
|
|
if (isWide) {
|
|
await showDialog(
|
|
context: context,
|
|
builder: (dialogCtx) => Dialog(
|
|
insetPadding: const EdgeInsets.all(16),
|
|
child: _buildNotificationsContent(dialogCtx, isWide),
|
|
),
|
|
);
|
|
} else {
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (sheetCtx) {
|
|
return SizedBox(
|
|
height: sheetHeight,
|
|
child: SafeArea(
|
|
child: _buildNotificationsContent(sheetCtx, isWide),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildNotificationsContent(BuildContext context, bool isWide) {
|
|
final data = context.watch<DataService>();
|
|
final notifications = data.notifications;
|
|
final loading = data.isNotificationsLoading;
|
|
final listHeight =
|
|
isWide ? 380.0 : MediaQuery.of(context).size.height * 0.6;
|
|
|
|
Widget body;
|
|
if (loading && notifications.isEmpty) {
|
|
body = const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
);
|
|
} else if (notifications.isEmpty) {
|
|
body = const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 12.0),
|
|
child: Text('No notifications right now.'),
|
|
);
|
|
} else {
|
|
body = SizedBox(
|
|
height: listHeight,
|
|
child: ListView.separated(
|
|
itemCount: notifications.length,
|
|
separatorBuilder: (_, index) => const SizedBox(height: 8),
|
|
itemBuilder: (ctx, index) {
|
|
final item = notifications[index];
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.title.isNotEmpty
|
|
? item.title
|
|
: 'Notification',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
item.body,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
if (item.createdAt != null) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
_formatNotificationTime(item.createdAt!),
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(
|
|
color: () {
|
|
final baseColor = Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.color;
|
|
if (baseColor == null) return null;
|
|
final newAlpha =
|
|
(baseColor.a * 0.7).clamp(0.0, 1.0);
|
|
return baseColor.withValues(
|
|
alpha: newAlpha,
|
|
);
|
|
}(),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton(
|
|
onPressed: () => _dismissNotifications(
|
|
context,
|
|
[item.id],
|
|
),
|
|
child: const Text('Dismiss'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return SizedBox(
|
|
width: isWide ? 420 : double.infinity,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Notifications',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleLarge
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: notifications.isEmpty
|
|
? null
|
|
: () => _dismissNotifications(
|
|
context,
|
|
notifications.map((e) => e.id).toList(),
|
|
),
|
|
child: const Text('Dismiss all'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
body,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _dismissNotifications(
|
|
BuildContext context,
|
|
List<int> ids,
|
|
) async {
|
|
if (ids.isEmpty) return;
|
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
try {
|
|
await context.read<DataService>().dismissNotifications(ids);
|
|
} catch (e) {
|
|
messenger?.showSnackBar(
|
|
SnackBar(content: Text('Failed to dismiss: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatNotificationTime(DateTime dateTime) {
|
|
final y = dateTime.year.toString().padLeft(4, '0');
|
|
final m = dateTime.month.toString().padLeft(2, '0');
|
|
final d = dateTime.day.toString().padLeft(2, '0');
|
|
final hh = dateTime.hour.toString().padLeft(2, '0');
|
|
final mm = dateTime.minute.toString().padLeft(2, '0');
|
|
return '$y-$m-$d $hh:$mm';
|
|
}
|
|
|
|
Widget _buildBadge(String label) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.redAccent,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
constraints: const BoxConstraints(
|
|
minWidth: 20,
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<bool> _handleBackNavigation({
|
|
bool allowExit = false,
|
|
bool recordForward = false,
|
|
}) async {
|
|
final currentPath = GoRouterState.of(context).uri.path;
|
|
final shellNav = _shellNavigatorKey.currentState;
|
|
if (shellNav != null && shellNav.canPop()) {
|
|
if (recordForward) _pushForward(currentPath);
|
|
_alignHistoryAfterPop(currentPath);
|
|
shellNav.pop();
|
|
return true;
|
|
}
|
|
|
|
if (_historyPosition > 0) {
|
|
if (recordForward) _pushForward(currentPath);
|
|
_historyPosition -= 1;
|
|
_suppressRecord = true;
|
|
context.go(_history[_historyPosition]);
|
|
return true;
|
|
}
|
|
|
|
final homePath = tabDestinations.first;
|
|
if (currentPath != homePath) {
|
|
if (recordForward) _pushForward(currentPath);
|
|
_suppressRecord = true;
|
|
context.go(homePath);
|
|
return true;
|
|
}
|
|
|
|
if (allowExit) {
|
|
SystemNavigator.pop();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
Future<bool> _handleForwardNavigation() async {
|
|
if (_forwardHistory.isEmpty) return false;
|
|
final nextPath = _forwardHistory.removeLast();
|
|
|
|
// Move cursor forward, keeping history in sync.
|
|
if (_historyPosition < _history.length - 1) {
|
|
_historyPosition += 1;
|
|
_history[_historyPosition] = nextPath;
|
|
if (_historyPosition < _history.length - 1) {
|
|
_history.removeRange(_historyPosition + 1, _history.length);
|
|
}
|
|
} else {
|
|
_history.add(nextPath);
|
|
_historyPosition = _history.length - 1;
|
|
}
|
|
|
|
_suppressRecord = true;
|
|
if (!mounted) return false;
|
|
context.go(nextPath);
|
|
return true;
|
|
}
|
|
|
|
void _pushForward(String path) {
|
|
if (_forwardHistory.isEmpty || _forwardHistory.last != path) {
|
|
_forwardHistory.add(path);
|
|
}
|
|
}
|
|
|
|
void _alignHistoryAfterPop(String currentPath) {
|
|
if (_history.isEmpty) return;
|
|
if (_historyPosition >= 0 &&
|
|
_historyPosition < _history.length &&
|
|
_history[_historyPosition] == currentPath) {
|
|
if (_historyPosition > 0) {
|
|
_historyPosition -= 1;
|
|
}
|
|
_history.removeRange(_historyPosition + 1, _history.length);
|
|
_suppressRecord = true;
|
|
}
|
|
}
|
|
|
|
void _syncHistory(String path) {
|
|
if (_history.isEmpty) {
|
|
_history.add(path);
|
|
_historyPosition = 0;
|
|
return;
|
|
}
|
|
if (_suppressRecord) {
|
|
_suppressRecord = false;
|
|
return;
|
|
}
|
|
if (_historyPosition >= 0 &&
|
|
_historyPosition < _history.length &&
|
|
_history[_historyPosition] == path) {
|
|
return;
|
|
}
|
|
if (_historyPosition < _history.length - 1) {
|
|
_history.removeRange(_historyPosition + 1, _history.length);
|
|
}
|
|
_history.add(path);
|
|
_historyPosition = _history.length - 1;
|
|
_forwardHistory.clear();
|
|
}
|
|
|
|
void _navigateToIndex(int index) {
|
|
_suppressRecord = false;
|
|
_forwardHistory.clear();
|
|
context.go(tabDestinations[index]);
|
|
}
|
|
}
|