From 4bd6f0bbed0f690624b5240ed631735b1a926934 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Fri, 26 Dec 2025 18:36:37 +0000 Subject: [PATCH] add support for badges and notifications, adjust nav pages --- .../dashboard/latest_loco_changes_panel.dart | 1 - lib/components/login/login.dart | 13 +- lib/components/pages/dashboard.dart | 2 +- lib/components/pages/logbook.dart | 48 ++ lib/components/pages/more.dart | 68 +++ .../new_entry/new_entry_submit_logic.dart | 1 + lib/components/pages/profile.dart | 207 +++++++++ .../pages/traction/traction_page.dart | 114 +++-- lib/components/pages/trips.dart | 226 ++++++--- lib/objects/objects.dart | 77 ++++ lib/services/data_service/data_service.dart | 3 +- .../data_service/data_service_badges.dart | 42 ++ .../data_service/data_service_core.dart | 12 + .../data_service_notifications.dart | 62 +++ lib/ui/app_shell.dart | 427 ++++++++++++++++-- pubspec.yaml | 2 +- 16 files changed, 1161 insertions(+), 144 deletions(-) create mode 100644 lib/components/pages/logbook.dart create mode 100644 lib/components/pages/more.dart create mode 100644 lib/components/pages/profile.dart create mode 100644 lib/services/data_service/data_service_badges.dart create mode 100644 lib/services/data_service/data_service_notifications.dart diff --git a/lib/components/dashboard/latest_loco_changes_panel.dart b/lib/components/dashboard/latest_loco_changes_panel.dart index fc1d355..dabc4ba 100644 --- a/lib/components/dashboard/latest_loco_changes_panel.dart +++ b/lib/components/dashboard/latest_loco_changes_panel.dart @@ -36,7 +36,6 @@ class _LatestLocoChangesPanelState extends State { final data = context.watch(); final changes = data.latestLocoChanges; final isLoading = data.isLatestLocoChangesLoading; - final hasMore = data.latestLocoChangesHasMore; final textTheme = Theme.of(context).textTheme; return Card( diff --git a/lib/components/login/login.dart b/lib/components/login/login.dart index 5bef8c3..3bf3e97 100644 --- a/lib/components/login/login.dart +++ b/lib/components/login/login.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/services/authservice.dart'; +import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:provider/provider.dart'; class LoginScreen extends StatefulWidget { @@ -26,7 +27,7 @@ class _LoginScreenState extends State { if (!valid) return; await auth.tryRestoreSession(); if (!mounted) return; - context.go('/'); + context.go('/dashboard'); } finally { if (mounted) setState(() => _checkingSession = false); } @@ -85,7 +86,14 @@ class _LoginScreenState extends State { IconButton( icon: const Icon(Icons.settings, color: Colors.grey), tooltip: 'Settings', - onPressed: () => context.go('/settings'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => const SettingsPage(), + ), + ); + }, ), ], ), @@ -179,6 +187,7 @@ class _LoginPanelContentState extends State { setState(() { _loggingIn = false; }); + context.go('/dashboard'); } catch (e) { if (!mounted) return; setState(() { diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index b5a8470..b5fdfed 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -509,7 +509,7 @@ class _DashboardState extends State { icon: Icons.bookmark, title: 'Trips', action: TextButton( - onPressed: () => context.push('/trips'), + onPressed: () => context.push('/logbook/trips'), child: const Text('View all'), ), child: trips.isEmpty diff --git a/lib/components/pages/logbook.dart b/lib/components/pages/logbook.dart new file mode 100644 index 0000000..546c2d6 --- /dev/null +++ b/lib/components/pages/logbook.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/components/pages/legs.dart'; +import 'package:mileograph_flutter/components/pages/trips.dart'; + +enum LogbookTab { entries, trips } + +class LogbookPage extends StatelessWidget { + const LogbookPage({super.key, this.initialTab = LogbookTab.entries}); + + final LogbookTab initialTab; + + @override + Widget build(BuildContext context) { + final initialIndex = initialTab == LogbookTab.trips ? 1 : 0; + return DefaultTabController( + key: ValueKey(initialTab), + initialIndex: initialIndex, + length: 2, + child: Column( + children: [ + TabBar( + onTap: (index) { + final dest = + index == 0 ? '/logbook/entries' : '/logbook/trips'; + final current = GoRouterState.of(context).uri.path; + if (current != dest) { + context.go(dest); + } + }, + tabs: const [ + Tab(text: 'Entries'), + Tab(text: 'Trips'), + ], + ), + Expanded( + child: TabBarView( + children: const [ + LegsPage(), + TripsPage(), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/pages/more.dart b/lib/components/pages/more.dart new file mode 100644 index 0000000..c22389f --- /dev/null +++ b/lib/components/pages/more.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/components/pages/profile.dart'; +import 'package:mileograph_flutter/components/pages/settings.dart'; + +class MorePage extends StatelessWidget { + const MorePage({super.key}); + + @override + Widget build(BuildContext context) { + return Navigator( + onGenerateRoute: (settings) { + final name = settings.name ?? '/'; + Widget page; + switch (name) { + case '/settings': + page = const SettingsPage(); + break; + case '/profile': + page = const ProfilePage(); + break; + case '/more/settings': + page = const SettingsPage(); + break; + case '/more/profile': + page = const ProfilePage(); + break; + case '/': + default: + page = _MoreHome(); + } + return MaterialPageRoute(builder: (_) => page, settings: settings); + }, + ); + } +} + +class _MoreHome extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'More', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 12), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.emoji_events), + title: const Text('Badges'), + onTap: () => Navigator.of(context).pushNamed('/more/profile'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Settings'), + onTap: () => Navigator.of(context).pushNamed('/more/settings'), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart index 166cff3..db1b542 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -120,6 +120,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { } if (!mounted) return; dataService.refreshLegs(); + await dataService.fetchNotifications(); if (!mounted) return; messenger?.showSnackBar( SnackBar( diff --git a/lib/components/pages/profile.dart b/lib/components/pages/profile.dart new file mode 100644 index 0000000..5f2895a --- /dev/null +++ b/lib/components/pages/profile.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({super.key}); + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + bool _initialised = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_initialised) return; + _initialised = true; + _refreshAwards(); + } + + Future _refreshAwards() { + return context.read().fetchBadgeAwards(); + } + + @override + Widget build(BuildContext context) { + final data = context.watch(); + final awards = data.badgeAwards; + final loading = data.isBadgeAwardsLoading; + + return Scaffold( + appBar: AppBar( + title: const Text('Badges'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } else { + context.go('/'); + } + }, + ), + ), + body: RefreshIndicator( + onRefresh: _refreshAwards, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (loading && awards.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: CircularProgressIndicator(), + ), + ) + else if (awards.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: Text('No badges awarded yet.'), + ) + else + ...awards.map((award) => _buildAwardCard(context, award)), + ], + ), + ), + ); + } + + Widget _buildAwardCard(BuildContext context, BadgeAward award) { + final badgeName = _formatBadgeName(award.badgeCode); + final tier = award.badgeTier.isNotEmpty + ? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1) + : ''; + final tierIcon = _buildTierIcon(award.badgeTier); + final scope = _scopeToShow(award); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (tierIcon != null) ...[ + tierIcon, + const SizedBox(width: 8), + ], + Expanded( + child: Text( + '$badgeName • $tier', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (award.awardedAt != null) + Text( + _formatAwardDate(award.awardedAt!), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + if (scope != null && scope.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + scope, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + if (award.loco != null) ...[ + const SizedBox(height: 8), + _buildLocoInfo(context, award.loco!), + ], + ], + ), + ), + ); + } + + Widget _buildLocoInfo(BuildContext context, LocoSummary loco) { + final lines = []; + final classNum = [ + if (loco.locoClass.isNotEmpty) loco.locoClass, + if (loco.number.isNotEmpty) loco.number, + ].join(' '); + if (classNum.isNotEmpty) lines.add(classNum); + if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!); + if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!); + if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.train, size: 20), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: lines.map((line) { + return Text( + line, + style: Theme.of(context).textTheme.bodyMedium, + ); + }).toList(), + ), + ), + ], + ); + } + + String _formatBadgeName(String code) { + if (code.isEmpty) return 'Badge'; + const known = { + 'class_clearance': 'Class Clearance', + 'loco_clearance': 'Loco Clearance', + }; + final lower = code.toLowerCase(); + if (known.containsKey(lower)) return known[lower]!; + final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty); + return parts + .map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase()) + .join(' '); + } + + String _formatAwardDate(DateTime date) { + final y = date.year.toString().padLeft(4, '0'); + final m = date.month.toString().padLeft(2, '0'); + final d = date.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; + } + + Widget? _buildTierIcon(String tier) { + final lower = tier.toLowerCase(); + Color? color; + switch (lower) { + case 'bronze': + color = const Color(0xFFCD7F32); + break; + case 'silver': + color = const Color(0xFFC0C0C0); + break; + case 'gold': + color = const Color(0xFFFFD700); + break; + } + if (color == null) return null; + return Icon(Icons.emoji_events, color: color); + } + + String? _scopeToShow(BadgeAward award) { + final scope = award.scopeValue?.trim() ?? ''; + if (scope.isEmpty) return null; + final code = award.badgeCode.toLowerCase(); + if (code == 'loco_clearance') { + // Hide numeric loco IDs; loco details are shown separately. + if (int.tryParse(scope) != null) return null; + } + return scope; + } +} diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 36f69ba..00143b2 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -244,47 +244,7 @@ class _TractionPageState extends State { ], ), ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Refresh', - onPressed: _refreshTraction, - icon: const Icon(Icons.refresh), - ), - if (_hasClassQuery) ...[ - const SizedBox(width: 8), - FilledButton.tonalIcon( - onPressed: _toggleClassStatsPanel, - icon: Icon( - _showClassStatsPanel ? Icons.bar_chart : Icons.insights, - ), - label: Text( - _showClassStatsPanel ? 'Hide class stats' : 'Class stats', - ), - ), - ], - const SizedBox(width: 8), - FilledButton.icon( - onPressed: () async { - final createdClass = await context.push( - '/traction/new', - ); - if (createdClass != null && createdClass.isNotEmpty) { - _classController.text = createdClass; - _selectedClass = createdClass; - if (mounted) { - _refreshTraction(); - } - } else if (mounted && createdClass == '') { - _refreshTraction(); - } - }, - icon: const Icon(Icons.add), - label: const Text('New Traction'), - ), - ], - ), + _buildHeaderActions(context, isMobile), ], ), const SizedBox(height: 12), @@ -546,6 +506,78 @@ class _TractionPageState extends State { return (_selectedClass ?? _classController.text).trim().isNotEmpty; } + Widget _buildHeaderActions(BuildContext context, bool isMobile) { + final refreshButton = IconButton( + tooltip: 'Refresh', + onPressed: _refreshTraction, + icon: const Icon(Icons.refresh), + ); + + final classStatsButton = !_hasClassQuery + ? null + : FilledButton.tonalIcon( + onPressed: _toggleClassStatsPanel, + icon: Icon( + _showClassStatsPanel ? Icons.bar_chart : Icons.insights, + ), + label: Text( + _showClassStatsPanel ? 'Hide class stats' : 'Class stats', + ), + ); + + final newTractionButton = FilledButton.icon( + onPressed: () async { + final createdClass = await context.push( + '/traction/new', + ); + if (!mounted) return; + if (createdClass != null && createdClass.isNotEmpty) { + _classController.text = createdClass; + _selectedClass = createdClass; + _refreshTraction(); + } else if (createdClass == '') { + _refreshTraction(); + } + }, + icon: const Icon(Icons.add), + label: const Text('New Traction'), + ); + + final desktopActions = [ + refreshButton, + if (classStatsButton != null) classStatsButton, + newTractionButton, + ]; + + final mobileActions = [ + newTractionButton, + if (classStatsButton != null) classStatsButton, + refreshButton, + ]; + + if (isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = 0; i < mobileActions.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: mobileActions[i], + ), + ], + ], + ); + } + + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: desktopActions, + ); + } + Future _toggleClassStatsPanel() async { if (!_hasClassQuery) return; final targetState = !_showClassStatsPanel; diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index d9608f6..f9aa921 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -12,6 +12,7 @@ class TripsPage extends StatefulWidget { class _TripsPageState extends State { bool _initialised = false; + final Map>> _tripLocoStatsFutures = {}; @override void didChangeDependencies() { @@ -23,7 +24,13 @@ class _TripsPageState extends State { } Future _refreshTrips() async { - await context.read().fetchTripDetails(); + _tripLocoStatsFutures.clear(); + final data = context.read(); + await data.fetchTripDetails(); + if (!mounted) return; + for (final trip in data.tripDetails) { + _tripStatsFuture(trip.id); + } } Future _renameTrip(TripDetail trip, String newName) async { @@ -47,6 +54,13 @@ class _TripsPageState extends State { } } + Future> _tripStatsFuture(int tripId) { + return _tripLocoStatsFutures.putIfAbsent( + tripId, + () => context.read().fetchTripLocoStats(tripId), + ); + } + Future _promptTripName(BuildContext context, String initial) async { final controller = TextEditingController(text: initial); final newName = await showDialog( @@ -80,7 +94,6 @@ class _TripsPageState extends State { final data = context.watch(); final tripDetails = data.tripDetails; final tripSummaries = data.trips; - final isMobile = MediaQuery.of(context).size.width < 700; final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty; return RefreshIndicator( @@ -171,92 +184,191 @@ class _TripsPageState extends State { } final trip = tripDetails[index - 1]; - return _buildTripCard(context, trip, isMobile); + return _buildTripCard(context, trip); }, ), ); } - Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) { + Widget _buildTripCard(BuildContext context, TripDetail trip) { final legs = trip.legs; + final legCount = trip.legCount > 0 ? trip.legCount : legs.length; + final dateRange = _formatDateRange(legs); + final endpoints = _formatEndpoints(legs); + final statsFuture = _tripStatsFuture(trip.id); + return Card( child: Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trip', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 4), + Text( + trip.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - trip.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + trip.mileage.toStringAsFixed(1), + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, ), ), Text( - '${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconButton( - icon: const Icon(Icons.train), - tooltip: 'Traction', - onPressed: () => _showTripWinners(context, trip), - ), - IconButton( - icon: const Icon(Icons.open_in_new), - tooltip: 'Details', - onPressed: () => _showTripDetail(context, trip), + 'miles', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), ), ], ), ], ), - const SizedBox(height: 8), - if (legs.isNotEmpty) - Column( - children: legs.take(isMobile ? 2 : 3).map((leg) { - return ListTile( - dense: isMobile, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.train), - title: Text('${leg.start} → ${leg.end}'), - subtitle: Text( - _formatDate(leg.beginTime), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Text( - leg.mileage?.toStringAsFixed(1) ?? '-', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + const SizedBox(height: 12), + FutureBuilder>( + future: statsFuture, + builder: (context, snapshot) { + final chips = [ + _buildMetaChip(context, Icons.timeline, '$legCount legs'), + if (dateRange != null) + _buildMetaChip(context, Icons.calendar_month, dateRange), + if (endpoints != null) + _buildMetaChip(context, Icons.route, endpoints), + ]; + + final stats = snapshot.data ?? const []; + final hasStats = stats.isNotEmpty; + final loading = + snapshot.connectionState == ConnectionState.waiting; + + if (loading && !hasStats) { + chips.add( + _buildMetaChip(context, Icons.train, 'Loading traction...'), + ); + } else if (hasStats) { + final winnerCount = stats.where((e) => e.won).length; + chips.add( + _buildMetaChip(context, Icons.train, '${stats.length} had'), + ); + chips.add( + _buildMetaChip( + context, + Icons.emoji_events_outlined, + '$winnerCount winners', ), ); - }).toList(), - ), - if (legs.length > 3) - Padding( - padding: const EdgeInsets.only(top: 6.0), - child: Text( - '+${legs.length - 3} more legs', - style: Theme.of(context).textTheme.bodySmall, - ), + } else if (snapshot.connectionState == ConnectionState.done) { + chips.add( + _buildMetaChip(context, Icons.train, 'No traction yet'), + ); + } + + return Wrap( + spacing: 8, + runSpacing: 8, + children: chips, + ); + }, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.train), + label: const Text('Locos'), + onPressed: () => _showTripWinners(context, trip), + ), + FilledButton.icon( + icon: const Icon(Icons.open_in_new), + label: const Text('Details'), + onPressed: () => _showTripDetail(context, trip), + ), + ], ), + ), ], ), ), ); } + Widget _buildMetaChip(BuildContext context, IconData icon, String label) { + return Chip( + avatar: Icon(icon, size: 16), + label: Text(label), + visualDensity: const VisualDensity(horizontal: -2, vertical: -2), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } + + String? _formatDateRange(List legs) { + final beginTimes = + legs.map((e) => e.beginTime).whereType().toList(); + if (beginTimes.isEmpty) return null; + final start = beginTimes.first; + final end = beginTimes.last; + final startStr = _formatFriendlyDate(start); + final endStr = _formatFriendlyDate(end); + if (startStr == endStr) return startStr; + return '$startStr - $endStr'; + } + + String _formatFriendlyDate(DateTime date) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final day = date.day.toString().padLeft(2, '0'); + final monthIndex = (date.month - 1).clamp(0, months.length - 1).toInt(); + final month = months[monthIndex]; + return '$day $month ${date.year}'; + } + + String? _formatEndpoints(List legs) { + if (legs.isEmpty) return null; + final start = legs.first.start; + final end = legs.last.end; + if (start.isEmpty && end.isEmpty) return null; + final startLabel = start.isNotEmpty ? start : '—'; + final endLabel = end.isNotEmpty ? end : '—'; + return '$startLabel → $endLabel'; + } + String _formatDate(DateTime? date) { if (date == null) return ''; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; @@ -323,6 +435,7 @@ class _TripsPageState extends State { data.fetchTripDetails(), data.fetchTrips(), ]); + _tripLocoStatsFutures.remove(trip.id); if (!mounted) return; messenger?.showSnackBar( SnackBar(content: Text('Deleted "${trip.name}"')), @@ -424,10 +537,9 @@ class _TripsPageState extends State { context: context, isScrollControlled: true, builder: (_) { - final data = context.read(); return SafeArea( child: FutureBuilder>( - future: data.fetchTripLocoStats(trip.id), + future: _tripStatsFuture(trip.id), builder: (ctx, snapshot) { final items = snapshot.data ?? []; final loading = diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 1d1b01e..b03e540 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -750,3 +750,80 @@ class EventField { ); } } + +class UserNotification { + final int id; + final String title; + final String body; + final DateTime? createdAt; + final bool dismissed; + + UserNotification({ + required this.id, + required this.title, + required this.body, + required this.createdAt, + required this.dismissed, + }); + + factory UserNotification.fromJson(Map json) { + final created = json['created_at'] ?? json['createdAt']; + DateTime? createdAt; + if (created is String) { + createdAt = DateTime.tryParse(created); + } else if (created is DateTime) { + createdAt = created; + } + return UserNotification( + id: _asInt(json['notification_id'] ?? json['id']), + title: _asString(json['title']), + body: _asString(json['body']), + createdAt: createdAt, + dismissed: _asBool(json['dismissed'] ?? false, false), + ); + } +} + +class BadgeAward { + final int id; + final int badgeId; + final String badgeCode; + final String badgeTier; + final String? scopeValue; + final DateTime? awardedAt; + final LocoSummary? loco; + + BadgeAward({ + required this.id, + required this.badgeId, + required this.badgeCode, + required this.badgeTier, + this.scopeValue, + this.awardedAt, + this.loco, + }); + + factory BadgeAward.fromJson(Map json) { + final awarded = json['awarded_at'] ?? json['awardedAt']; + DateTime? awardedAt; + if (awarded is String) { + awardedAt = DateTime.tryParse(awarded); + } else if (awarded is DateTime) { + awardedAt = awarded; + } + final locoJson = json['loco']; + LocoSummary? loco; + if (locoJson is Map) { + loco = LocoSummary.fromJson(Map.from(locoJson)); + } + return BadgeAward( + id: _asInt(json['award_id'] ?? json['id']), + badgeId: _asInt(json['badge_id'] ?? 0), + badgeCode: _asString(json['badge_code']), + badgeTier: _asString(json['badge_tier']), + scopeValue: _asString(json['scope_value']), + awardedAt: awardedAt, + loco: loco, + ); + } +} diff --git a/lib/services/data_service/data_service.dart b/lib/services/data_service/data_service.dart index b843319..2acfbfa 100644 --- a/lib/services/data_service/data_service.dart +++ b/lib/services/data_service/data_service.dart @@ -9,4 +9,5 @@ import 'package:mileograph_flutter/services/api_service.dart'; part 'data_service_core.dart'; part 'data_service_traction.dart'; part 'data_service_trips.dart'; - +part 'data_service_notifications.dart'; +part 'data_service_badges.dart'; diff --git a/lib/services/data_service/data_service_badges.dart b/lib/services/data_service/data_service_badges.dart new file mode 100644 index 0000000..e20cb33 --- /dev/null +++ b/lib/services/data_service/data_service_badges.dart @@ -0,0 +1,42 @@ +part of 'data_service.dart'; + +extension DataServiceBadges on DataService { + Future fetchBadgeAwards() async { + _isBadgeAwardsLoading = true; + try { + final json = await api.get('/badge/awards/me'); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['awards', 'badge_awards', 'data']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + final parsed = list + ?.whereType>() + .map(BadgeAward.fromJson) + .toList(); + if (parsed != null) { + parsed.sort((a, b) { + final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0; + final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0; + return bTs.compareTo(aTs); + }); + _badgeAwards = parsed; + } else { + _badgeAwards = []; + } + } catch (e) { + debugPrint('Failed to fetch badge awards: $e'); + _badgeAwards = []; + } finally { + _isBadgeAwardsLoading = false; + _notifyAsync(); + } + } +} diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index f4df126..717d1ac 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -91,6 +91,18 @@ class DataService extends ChangeNotifier { bool _isOnThisDayLoading = false; bool get isOnThisDayLoading => _isOnThisDayLoading; + // Notifications + List _notifications = []; + List get notifications => _notifications; + bool _isNotificationsLoading = false; + bool get isNotificationsLoading => _isNotificationsLoading; + + // Badges + List _badgeAwards = []; + List get badgeAwards => _badgeAwards; + bool _isBadgeAwardsLoading = false; + bool get isBadgeAwardsLoading => _isBadgeAwardsLoading; + static const List _fallbackEventFields = [ EventField(name: 'operator', display: 'Operator'), EventField(name: 'status', display: 'Status'), diff --git a/lib/services/data_service/data_service_notifications.dart b/lib/services/data_service/data_service_notifications.dart new file mode 100644 index 0000000..d146294 --- /dev/null +++ b/lib/services/data_service/data_service_notifications.dart @@ -0,0 +1,62 @@ +part of 'data_service.dart'; + +extension DataServiceNotifications on DataService { + Future fetchNotifications() async { + _isNotificationsLoading = true; + try { + final json = await api.get('/notifications'); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['notifications', 'data', 'items']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + final parsed = list + ?.whereType>() + .map(UserNotification.fromJson) + .where((n) => !n.dismissed) + .toList(); + + if (parsed != null) { + parsed.sort((a, b) { + final aTs = a.createdAt?.millisecondsSinceEpoch ?? 0; + final bTs = b.createdAt?.millisecondsSinceEpoch ?? 0; + return bTs.compareTo(aTs); + }); + _notifications = parsed; + } else { + _notifications = []; + } + } catch (e) { + debugPrint('Failed to fetch notifications: $e'); + _notifications = []; + } finally { + _isNotificationsLoading = false; + _notifyAsync(); + } + } + + Future dismissNotifications(List notificationIds) async { + if (notificationIds.isEmpty) return; + try { + await api.put('/notifications/dismiss', { + "notification_ids": notificationIds, + "payload": {"dismissed": true}, + }); + _notifications = _notifications + .where((n) => !notificationIds.contains(n.id)) + .toList(); + } catch (e) { + debugPrint('Failed to dismiss notifications: $e'); + rethrow; + } finally { + _notifyAsync(); + } + } +} diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index b0df8cb..0fc38ee 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -4,17 +4,16 @@ 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/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/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'; @@ -23,12 +22,11 @@ import 'package:provider/provider.dart'; final GlobalKey _shellNavigatorKey = GlobalKey(); const List _contentPages = [ - "/", - "/calculator", - "/legs", + "/dashboard", + "/logbook/entries", "/traction", - "/trips", "/add", + "/more", ]; const int _addTabIndex = 5; @@ -41,19 +39,33 @@ class _NavItem { const List<_NavItem> _navItems = [ _NavItem("Home", Icons.home), - _NavItem("Calculator", Icons.route), - _NavItem("Entries", Icons.list), + _NavItem("Logbook", Icons.menu_book), _NavItem("Traction", Icons.train), - _NavItem("Trips", Icons.book), _NavItem("Add", Icons.add), + _NavItem("More", Icons.more_horiz), ]; int tabIndexForPath(String path) { - final newIndex = _contentPages.indexWhere((routePath) { - if (path == routePath) return true; - if (routePath == '/') return path == '/'; - return path.startsWith('$routePath/'); - }); + 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'; + } else if (matchPath == '/logbook') { + matchPath = '/logbook/entries'; + } else if (matchPath.startsWith('/logbook/trips')) { + matchPath = '/logbook/entries'; + } 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; } @@ -81,6 +93,7 @@ class _MyAppState extends State { _routerInitialized = true; final auth = context.read(); _router = GoRouter( + initialLocation: '/dashboard', refreshListenable: auth, redirect: (context, state) { final loggedIn = auth.isLoggedIn; @@ -88,29 +101,52 @@ class _MyAppState extends State { final atSettings = state.uri.toString() == '/settings'; if (!loggedIn && !loggingIn && !atSettings) return '/login'; - if (loggedIn && loggingIn) return '/'; + if (loggedIn && loggingIn) return '/dashboard'; return null; }, routes: [ + GoRoute( + path: '/', + redirect: (_, __) => '/dashboard', + ), 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(), + path: '/dashboard', + builder: (context, state) => const Dashboard(), ), GoRoute( - path: '/calculator/details', + path: '/logbook', + builder: (context, state) => const LogbookPage(), + ), + GoRoute( + path: '/logbook/entries', + builder: (context, state) => const LogbookPage(), + ), + GoRoute( + path: '/logbook/trips', builder: (context, state) => - CalculatorDetailsPage(result: state.extra), + const LogbookPage(initialTab: LogbookTab.trips), + ), + GoRoute( + path: '/trips', + builder: (context, state) => + const LogbookPage(initialTab: LogbookTab.trips), + ), + GoRoute( + path: '/legs', + builder: (context, state) => const LogbookPage(), ), - GoRoute(path: '/legs', builder: (context, state) => LegsPage()), GoRoute( path: '/traction', builder: (context, state) => TractionPage(), ), + GoRoute( + path: '/profile', + builder: (context, state) => const ProfilePage(), + ), GoRoute( path: '/traction/:id/timeline', builder: (_, state) { @@ -147,8 +183,19 @@ class _MyAppState extends State { path: '/traction/new', builder: (context, state) => const NewTractionPage(), ), - GoRoute(path: '/trips', builder: (context, state) => TripsPage()), 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) { @@ -210,9 +257,15 @@ class _MyHomePageState extends State { List get contentPages => _contentPages; Future _onItemTapped(int index, int currentIndex) async { - if (index < 0 || index >= contentPages.length || index == currentIndex) { + if (index < 0 || index >= contentPages.length) { return; } + final currentPath = GoRouterState.of(context).uri.path; + final targetPath = contentPages[index]; + final alreadyAtTarget = + currentPath == targetPath || currentPath.startsWith('$targetPath/'); + if (index == currentIndex && alreadyAtTarget) return; + await NavigationGuard.attemptNavigation(() async { if (!mounted) return; _navigateToIndex(index); @@ -225,6 +278,7 @@ class _MyHomePageState extends State { bool _suppressRecord = false; bool _fetched = false; + bool _railCollapsed = false; @override void didChangeDependencies() { @@ -258,6 +312,9 @@ class _MyHomePageState extends State { if (data.tripDetails.isEmpty) { data.fetchTripDetails(); } + if (data.notifications.isEmpty) { + data.fetchNotifications(); + } }); }); } @@ -273,6 +330,7 @@ class _MyHomePageState extends State { final homepageReady = context.select( (data) => data.homepageStats != null || !data.isHomepageLoading, ); + final data = context.watch(); final auth = context.read(); final currentPage = homepageReady @@ -282,7 +340,9 @@ class _MyHomePageState extends State { final scaffold = LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 900; - final railExtended = constraints.maxWidth >= 1400; + final defaultRailExtended = constraints.maxWidth >= 1400; + final railExtended = defaultRailExtended && !_railCollapsed; + final showRailToggle = defaultRailExtended; final navRailDestinations = _navItems .map( (item) => NavigationRailDestination( @@ -318,13 +378,10 @@ class _MyHomePageState extends State { ), ), actions: [ - const IconButton( - onPressed: null, - icon: Icon(Icons.account_circle), - ), + _buildNotificationAction(context, data), IconButton( tooltip: 'Settings', - onPressed: () => context.go('/settings'), + onPressed: () => context.go('/more/settings'), icon: const Icon(Icons.settings), ), IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), @@ -342,15 +399,35 @@ class _MyHomePageState extends State { ? Row( children: [ SafeArea( - child: NavigationRail( - selectedIndex: pageIndex, - extended: railExtended, - labelType: railExtended - ? NavigationRailLabelType.none - : NavigationRailLabelType.selected, - onDestinationSelected: (int index) => - _onItemTapped(index, pageIndex), - destinations: navRailDestinations, + 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), @@ -410,6 +487,276 @@ class _MyHomePageState extends State { } } + 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 _openNotificationsPanel(BuildContext context) async { + final data = context.read(); + try { + await data.fetchNotifications(); + } catch (_) { + // Already logged inside data service. + } + if (!mounted) return; + final isWide = MediaQuery.of(context).size.width >= 900; + + final panelBuilder = (BuildContext ctx) { + return _buildNotificationsContent(ctx, isWide); + }; + + if (isWide) { + await showDialog( + context: context, + builder: (dialogCtx) => Dialog( + insetPadding: const EdgeInsets.all(16), + child: panelBuilder(dialogCtx), + ), + ); + } else { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetCtx) { + final height = MediaQuery.of(context).size.height * 0.9; + return SizedBox( + height: height, + child: SafeArea( + child: panelBuilder(sheetCtx), + ), + ); + }, + ); + } + } + + Widget _buildNotificationsContent(BuildContext context, bool isWide) { + final data = context.watch(); + 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: (_, __) => 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: Theme.of(context) + .textTheme + .bodySmall + ?.color + ?.withOpacity(0.7), + ), + ), + ], + ], + ), + ), + 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 _dismissNotifications( + BuildContext context, + List ids, + ) async { + if (ids.isEmpty) return; + final messenger = ScaffoldMessenger.maybeOf(context); + try { + await context.read().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, + ), + ); + } + int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path); Future _handleBackNavigation({ diff --git a/pubspec.yaml b/pubspec.yaml index 5765666..c83e5a3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.3.4+1 +version: 0.4.0+1 environment: sdk: ^3.8.1