add support for badges and notifications, adjust nav pages
All checks were successful
All checks were successful
This commit is contained in:
@@ -36,7 +36,6 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
||||
final data = context.watch<DataService>();
|
||||
final changes = data.latestLocoChanges;
|
||||
final isLoading = data.isLatestLocoChangesLoading;
|
||||
final hasMore = data.latestLocoChangesHasMore;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
|
||||
@@ -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<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
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<LoginPanelContent> {
|
||||
setState(() {
|
||||
_loggingIn = false;
|
||||
});
|
||||
context.go('/dashboard');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
||||
@@ -509,7 +509,7 @@ class _DashboardState extends State<Dashboard> {
|
||||
icon: Icons.bookmark,
|
||||
title: 'Trips',
|
||||
action: TextButton(
|
||||
onPressed: () => context.push('/trips'),
|
||||
onPressed: () => context.push('/logbook/trips'),
|
||||
child: const Text('View all'),
|
||||
),
|
||||
child: trips.isEmpty
|
||||
|
||||
48
lib/components/pages/logbook.dart
Normal file
48
lib/components/pages/logbook.dart
Normal file
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/components/pages/more.dart
Normal file
68
lib/components/pages/more.dart
Normal file
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
}
|
||||
if (!mounted) return;
|
||||
dataService.refreshLegs();
|
||||
await dataService.fetchNotifications();
|
||||
if (!mounted) return;
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
207
lib/components/pages/profile.dart
Normal file
207
lib/components/pages/profile.dart
Normal file
@@ -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<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
bool _initialised = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_initialised) return;
|
||||
_initialised = true;
|
||||
_refreshAwards();
|
||||
}
|
||||
|
||||
Future<void> _refreshAwards() {
|
||||
return context.read<DataService>().fetchBadgeAwards();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
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 = <String>[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -244,47 +244,7 @@ class _TractionPageState extends State<TractionPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
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<String>(
|
||||
'/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<TractionPage> {
|
||||
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<String>(
|
||||
'/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<void> _toggleClassStatsPanel() async {
|
||||
if (!_hasClassQuery) return;
|
||||
final targetState = !_showClassStatsPanel;
|
||||
|
||||
@@ -12,6 +12,7 @@ class TripsPage extends StatefulWidget {
|
||||
|
||||
class _TripsPageState extends State<TripsPage> {
|
||||
bool _initialised = false;
|
||||
final Map<int, Future<List<TripLocoStat>>> _tripLocoStatsFutures = {};
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -23,7 +24,13 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
|
||||
Future<void> _refreshTrips() async {
|
||||
await context.read<DataService>().fetchTripDetails();
|
||||
_tripLocoStatsFutures.clear();
|
||||
final data = context.read<DataService>();
|
||||
await data.fetchTripDetails();
|
||||
if (!mounted) return;
|
||||
for (final trip in data.tripDetails) {
|
||||
_tripStatsFuture(trip.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renameTrip(TripDetail trip, String newName) async {
|
||||
@@ -47,6 +54,13 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TripLocoStat>> _tripStatsFuture(int tripId) {
|
||||
return _tripLocoStatsFutures.putIfAbsent(
|
||||
tripId,
|
||||
() => context.read<DataService>().fetchTripLocoStats(tripId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _promptTripName(BuildContext context, String initial) async {
|
||||
final controller = TextEditingController(text: initial);
|
||||
final newName = await showDialog<String>(
|
||||
@@ -80,7 +94,6 @@ class _TripsPageState extends State<TripsPage> {
|
||||
final data = context.watch<DataService>();
|
||||
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<TripsPage> {
|
||||
}
|
||||
|
||||
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<List<TripLocoStat>>(
|
||||
future: statsFuture,
|
||||
builder: (context, snapshot) {
|
||||
final chips = <Widget>[
|
||||
_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<TripLeg> legs) {
|
||||
final beginTimes =
|
||||
legs.map((e) => e.beginTime).whereType<DateTime>().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<TripLeg> 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<TripsPage> {
|
||||
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<TripsPage> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) {
|
||||
final data = context.read<DataService>();
|
||||
return SafeArea(
|
||||
child: FutureBuilder<List<TripLocoStat>>(
|
||||
future: data.fetchTripLocoStats(trip.id),
|
||||
future: _tripStatsFuture(trip.id),
|
||||
builder: (ctx, snapshot) {
|
||||
final items = snapshot.data ?? [];
|
||||
final loading =
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic>) {
|
||||
loco = LocoSummary.fromJson(Map<String, dynamic>.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
42
lib/services/data_service/data_service_badges.dart
Normal file
42
lib/services/data_service/data_service_badges.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
part of 'data_service.dart';
|
||||
|
||||
extension DataServiceBadges on DataService {
|
||||
Future<void> fetchBadgeAwards() async {
|
||||
_isBadgeAwardsLoading = true;
|
||||
try {
|
||||
final json = await api.get('/badge/awards/me');
|
||||
List<dynamic>? 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<String, dynamic>>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,18 @@ class DataService extends ChangeNotifier {
|
||||
bool _isOnThisDayLoading = false;
|
||||
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
||||
|
||||
// Notifications
|
||||
List<UserNotification> _notifications = [];
|
||||
List<UserNotification> get notifications => _notifications;
|
||||
bool _isNotificationsLoading = false;
|
||||
bool get isNotificationsLoading => _isNotificationsLoading;
|
||||
|
||||
// Badges
|
||||
List<BadgeAward> _badgeAwards = [];
|
||||
List<BadgeAward> get badgeAwards => _badgeAwards;
|
||||
bool _isBadgeAwardsLoading = false;
|
||||
bool get isBadgeAwardsLoading => _isBadgeAwardsLoading;
|
||||
|
||||
static const List<EventField> _fallbackEventFields = [
|
||||
EventField(name: 'operator', display: 'Operator'),
|
||||
EventField(name: 'status', display: 'Status'),
|
||||
|
||||
62
lib/services/data_service/data_service_notifications.dart
Normal file
62
lib/services/data_service/data_service_notifications.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
part of 'data_service.dart';
|
||||
|
||||
extension DataServiceNotifications on DataService {
|
||||
Future<void> fetchNotifications() async {
|
||||
_isNotificationsLoading = true;
|
||||
try {
|
||||
final json = await api.get('/notifications');
|
||||
List<dynamic>? 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<String, dynamic>>()
|
||||
.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<void> dismissNotifications(List<int> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user