add support for badges and notifications, adjust nav pages
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m49s
Release / android-build (push) Successful in 15m55s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 26s

This commit is contained in:
2025-12-26 18:36:37 +00:00
parent 44d79e7c28
commit 4bd6f0bbed
16 changed files with 1161 additions and 144 deletions

View File

@@ -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<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
const List<String> _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<MyApp> {
_routerInitialized = true;
final auth = context.read<AuthService>();
_router = GoRouter(
initialLocation: '/dashboard',
refreshListenable: auth,
redirect: (context, state) {
final loggedIn = auth.isLoggedIn;
@@ -88,29 +101,52 @@ class _MyAppState extends State<MyApp> {
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<MyApp> {
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<MyHomePage> {
List<String> get contentPages => _contentPages;
Future<void> _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<MyHomePage> {
bool _suppressRecord = false;
bool _fetched = false;
bool _railCollapsed = false;
@override
void didChangeDependencies() {
@@ -258,6 +312,9 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.tripDetails.isEmpty) {
data.fetchTripDetails();
}
if (data.notifications.isEmpty) {
data.fetchNotifications();
}
});
});
}
@@ -273,6 +330,7 @@ class _MyHomePageState extends State<MyHomePage> {
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
@@ -282,7 +340,9 @@ class _MyHomePageState extends State<MyHomePage> {
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<MyHomePage> {
),
),
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<MyHomePage> {
? 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<MyHomePage> {
}
}
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>();
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<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: (_, __) => 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<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,
),
);
}
int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path);
Future<bool> _handleBackNavigation({