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

@@ -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(

View File

@@ -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(() {

View File

@@ -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

View 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(),
],
),
),
],
),
);
}
}

View 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'),
),
],
),
),
],
);
}
}

View File

@@ -120,6 +120,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
}
if (!mounted) return;
dataService.refreshLegs();
await dataService.fetchNotifications();
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(

View 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;
}
}

View File

@@ -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;

View File

@@ -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 =

View File

@@ -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,
);
}
}

View File

@@ -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';

View 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();
}
}
}

View File

@@ -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'),

View 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();
}
}
}

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({

View File

@@ -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