Files
mileograph_flutter/lib/ui/app_shell.dart
Pete Gregory 559f79b805
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 1m4s
Release / web-build (push) Successful in 2m47s
Release / android-build (push) Successful in 11m45s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 30s
add transfer all button for admins
2026-01-12 17:11:37 +00:00

1157 lines
40 KiB
Dart

import 'dart:async';
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/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/calculator_details.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/badges.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/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_changes_page.dart';
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey =
GlobalKey<NavigatorState>();
const List<String> _contentPages = [
"/dashboard",
"/calculator",
"/logbook",
"/traction",
"/add",
"/more",
];
const List<String> _defaultTabDestinations = [
"/dashboard",
"/calculator",
"/logbook/entries",
"/traction",
"/add",
"/more",
];
const int _addTabIndex = 4;
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("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;
@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: '/calculator',
builder: (context, state) => const CalculatorPage(),
routes: [
GoRoute(
path: 'details',
builder: (context, state) =>
CalculatorDetailsPage(result: state.extra),
),
],
),
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) {
final selectionParam =
state.uri.queryParameters['selection'] ??
(state.extra is Map
? (state.extra as Map)['selection']?.toString()
: null);
final replacementPendingLocoIdStr =
state.uri.queryParameters['replacementPendingLocoId'];
final replacementPendingLocoId = replacementPendingLocoIdStr != null
? int.tryParse(replacementPendingLocoIdStr)
: state.extra is Map
? int.tryParse(
(state.extra as Map)['replacementPendingLocoId']
?.toString() ??
'',
)
: null;
final transferFromLocoIdStr =
state.uri.queryParameters['transferFromLocoId'];
final transferFromLocoId = transferFromLocoIdStr != null
? int.tryParse(transferFromLocoIdStr)
: state.extra is Map
? int.tryParse(
(state.extra as Map)['transferFromLocoId']
?.toString() ??
'',
)
: null;
final transferFromLabel = state.uri.queryParameters['transferFromLabel'] ??
(state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString()
: null);
bool transferAllAllocations = false;
final transferAllParam =
state.uri.queryParameters['transferAll'] ??
(state.extra is Map
? (state.extra as Map)['transferAll']?.toString()
: null);
if (transferAllParam != null) {
transferAllAllocations = transferAllParam.toLowerCase() == 'true' ||
transferAllParam == '1';
}
if (!transferAllAllocations && state.extra is Map) {
final raw = (state.extra as Map)['transferAll'];
if (raw is bool) {
transferAllAllocations = raw;
}
}
final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null ||
transferFromLocoId != null;
final selectionSingle = replacementPendingLocoId != null ||
transferFromLocoId != null ||
selectionParam?.toLowerCase() == 'single' ||
selectionParam == '1' ||
selectionParam?.toLowerCase() == 'true';
return TractionPage(
selectionMode: selectionMode,
selectionSingle: selectionSingle,
replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
);
},
),
GoRoute(
path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(),
),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
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';
bool showPending = false;
if (extra is Map && extra['showPending'] is bool) {
showPending = extra['showPending'] as bool;
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
forceShowPending: showPending,
);
},
),
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) {
final extra = state.extra;
return NewEntryPage(
legShare: extra is LegShareData ? extra : null,
);
},
),
GoRoute(
path: '/more',
builder: (context, state) => const MorePage(),
),
GoRoute(
path: '/more/profile',
builder: (context, state) => const ProfilePage(),
),
GoRoute(
path: '/more/user-profile',
name: 'user-profile',
builder: (context, state) {
final extra = state.extra;
UserSummary? user;
String? userId;
if (extra is UserSummary) {
user = extra;
userId = extra.userId;
} else if (extra is Map) {
final value = extra['user'];
if (value is UserSummary) user = value;
userId = extra['userId']?.toString();
}
userId ??= state.uri.queryParameters['user_id'];
return UserProfilePage(
userId: userId,
initialUser: user,
);
},
),
GoRoute(
path: '/more/badges',
builder: (context, state) => const BadgesPage(),
),
GoRoute(
path: '/more/stats',
builder: (context, state) => const StatsPage(),
),
GoRoute(
path: '/more/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/more/admin',
builder: (context, state) => const AdminPage(),
),
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) {
final accent = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
final seedColor =
accent.hasSavedSeed ? accent.seedColor : AccentColorService.defaultSeed;
final useSystemColors = accent.useSystem;
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final colorSchemeLight = useSystemColors && lightDynamic != null
? lightDynamic
: ColorScheme.fromSeed(seedColor: seedColor);
final colorSchemeDark = useSystemColors && darkDynamic != null
? darkDynamic
: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
return MaterialApp.router(
title: 'Mileograph',
routerConfig: _router,
theme: ThemeData(
useMaterial3: true,
colorScheme: colorSchemeLight,
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: colorSchemeDark,
),
themeMode: themeModeService.mode,
);
},
);
}
}
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;
Timer? _notificationsTimer;
@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();
}
if (data.friendships.isEmpty && !data.isFriendshipsLoading) {
data.fetchFriendships();
}
if ((data.pendingIncoming.isEmpty && data.pendingOutgoing.isEmpty) &&
!data.isPendingFriendshipsLoading) {
data.fetchPendingFriendships();
}
_startNotificationPolling();
});
});
}
void _startNotificationPolling() {
_notificationsTimer?.cancel();
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
_notificationsTimer = Timer.periodic(const Duration(minutes: 2), (_) async {
if (!mounted) return;
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
final data = context.read<DataService>();
try {
await data.fetchNotifications();
} catch (_) {
// Errors already logged inside data service.
}
});
}
@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();
final logo = 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 Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: isWide
? logo
: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: logo,
),
actions: [
_buildNotificationAction(context, data),
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/more/profile'),
icon: const Icon(Icons.person),
),
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 dismissibleIds = notifications
.where(
(n) =>
!_isFriendRequestNotification(n) && !_isLegShareNotification(n),
)
.map((e) => e.id)
.toList();
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];
final isFriendRequest = _isFriendRequestNotification(item);
final isLegShare = _isLegShareNotification(item);
final isLegShareEdit = _isLegShareEditNotification(item);
final isSpecial = isFriendRequest || isLegShare || isLegShareEdit;
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(
isSpecial
? isFriendRequest
? 'Accept to share entries'
: isLegShareEdit
? 'Shared leg edits below.'
: 'Shared entry details below.'
: 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,
);
}(),
),
),
],
],
),
),
if (!isSpecial) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () =>
_dismissNotifications(context, [item.id]),
child: const Text('Dismiss'),
),
],
],
),
if (isFriendRequest)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: FriendRequestNotificationCard(
notification: item,
),
),
if (_isLegShareEditNotification(item))
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LegShareEditNotificationCard(
notification: item,
),
),
if (isLegShare)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LegShareNotificationCard(notification: item),
),
],
),
),
);
},
),
);
}
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, dismissibleIds),
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';
}
bool _isFriendRequestNotification(UserNotification notification) {
final type = notification.type.trim().toLowerCase();
final channel = notification.channel.trim().toLowerCase();
final title = notification.title.trim().toLowerCase();
bool matchesChannel =
channel == 'friend_request' || channel == 'friend-request';
if (!matchesChannel) {
matchesChannel = channel.contains('friend_request') ||
channel.contains('friend-request') ||
channel.contains('friend');
}
return matchesChannel ||
type == 'friend_request' ||
type == 'friend-request' ||
title.contains('friend request');
}
bool _isLegShareNotification(UserNotification notification) {
final channel = notification.channel.trim().toLowerCase();
final type = notification.type.trim().toLowerCase();
final isAcceptEdits =
_isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
return (channel.contains('leg_share') || type.contains('leg_share')) &&
!isAcceptEdits;
}
bool _isLegShareEditNotification(UserNotification notification) {
final channel = notification.channel.trim().toLowerCase();
final type = notification.type.trim().toLowerCase();
return _isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
}
bool _isLegShareAcceptEdits(String value) {
final normalized = value.trim().toLowerCase();
// Match both singular/plural: leg_share_accept_edit / leg_share_accept_edits
return normalized.contains('leg_share_accept_edit');
}
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]);
}
@override
void dispose() {
_notificationsTimer?.cancel();
super.dispose();
}
}