diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e26dfe..e665764 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,3 @@ -{} \ No newline at end of file +{ + "cmake.ignoreCMakeListsMissing": true +} diff --git a/lib/app.dart b/lib/app.dart index 8183d9b..4dceb53 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,7 +13,7 @@ class App extends StatelessWidget { return MultiProvider( providers: [ Provider( - create: (_) => ApiService(baseUrl: 'https://mileograph.co.uk/api/v1'), + create: (_) => ApiService(baseUrl: 'http://localhost:8000/api/v1'), ), ChangeNotifierProvider( create: (context) => AuthService(api: context.read()), diff --git a/lib/components/dashboard/latest_loco_changes_panel.dart b/lib/components/dashboard/latest_loco_changes_panel.dart new file mode 100644 index 0000000..6108122 --- /dev/null +++ b/lib/components/dashboard/latest_loco_changes_panel.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class LatestLocoChangesPanel extends StatefulWidget { + const LatestLocoChangesPanel({super.key}); + + @override + State createState() => _LatestLocoChangesPanelState(); +} + +class _LatestLocoChangesPanelState extends State { + late final ScrollController _controller; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final data = context.watch(); + final changes = data.latestLocoChanges; + final isLoading = data.isLatestLocoChangesLoading; + final textTheme = Theme.of(context).textTheme; + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Latest loco changes', + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + if (isLoading && changes.isEmpty) + const Padding( + padding: EdgeInsets.all(12.0), + child: Center(child: CircularProgressIndicator()), + ) + else if (changes.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'No recent loco changes yet.', + style: textTheme.bodyMedium, + ), + ) + else + SizedBox( + height: 260, + child: Scrollbar( + controller: _controller, + child: ListView.separated( + controller: _controller, + itemCount: changes.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final change = changes[index]; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + change.locoLabel, + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${change.changeLabel}: ${change.valueLabel}'), + Text( + change.approvedDateLabel, + style: textTheme.labelSmall?.copyWith( + color: textTheme.bodySmall?.color?.withValues(alpha: 0.7), + ), + ), + ], + ), + trailing: change.approvedBy.isEmpty + ? null + : Text( + change.approvedBy, + style: textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/dashboard/leaderboard_panel.dart b/lib/components/dashboard/leaderboard_panel.dart index 88b559e..52c0f67 100644 --- a/lib/components/dashboard/leaderboard_panel.dart +++ b/lib/components/dashboard/leaderboard_panel.dart @@ -16,23 +16,24 @@ class LeaderboardPanel extends StatelessWidget { child: Center(child: CircularProgressIndicator()), ); } - return Padding( - padding: const EdgeInsets.all(10.0), - child: Card( + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( "Leaderboard", style: TextStyle( - fontSize: 24, + fontSize: 18, fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, ), ), + const SizedBox(height: 8), if (leaderboard.isEmpty) const Padding( - padding: EdgeInsets.all(16.0), + padding: EdgeInsets.all(8.0), child: Text('No leaderboard data yet'), ) else @@ -46,7 +47,7 @@ class LeaderboardPanel extends StatelessWidget { margin: const EdgeInsets.symmetric( horizontal: 0, vertical: 8), child: Padding( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(vertical: 6), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/components/dashboard/top_traction_panel.dart b/lib/components/dashboard/top_traction_panel.dart index 4ad6b8d..a8570ae 100644 --- a/lib/components/dashboard/top_traction_panel.dart +++ b/lib/components/dashboard/top_traction_panel.dart @@ -17,23 +17,24 @@ class TopTractionPanel extends StatelessWidget { child: Center(child: CircularProgressIndicator()), ); } - return Padding( - padding: const EdgeInsets.all(10.0), - child: Card( + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( "Top Traction", style: TextStyle( - fontSize: 24, + fontSize: 18, fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, ), ), + const SizedBox(height: 8), if (locos.isEmpty) const Padding( - padding: EdgeInsets.all(16.0), + padding: EdgeInsets.all(8.0), child: Text('No traction data yet'), ) else @@ -47,7 +48,7 @@ class TopTractionPanel extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), child: Padding( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(vertical: 6), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index f4894d4..37bf897 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -9,10 +9,12 @@ class LegCard extends StatelessWidget { super.key, required this.leg, this.showEditButton = true, + this.showDate = true, }); final Leg leg; final bool showEditButton; + final bool showDate; @override Widget build(BuildContext context) { @@ -26,7 +28,7 @@ class LegCard extends StatelessWidget { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(_formatDateTime(leg.beginTime)), + if (showDate) Text(_formatDateTime(leg.beginTime)), if (leg.headcode.isNotEmpty) Text( 'Headcode: ${leg.headcode}', @@ -126,13 +128,32 @@ class LegCard extends StatelessWidget { List _buildLocoChips(BuildContext context, Leg leg) { final theme = Theme.of(context); + final textTheme = theme.textTheme; return leg.locos .map( - (loco) => Chip( - label: Text('${loco.locoClass} ${loco.number}'), - avatar: const Icon(Icons.directions_railway, size: 16), - backgroundColor: theme.colorScheme.surfaceContainerHighest, - ), + (loco) { + final powering = loco.powering == true; + final iconColor = + powering ? theme.colorScheme.primary : theme.disabledColor; + final labelStyle = powering + ? null + : textTheme.bodyMedium?.copyWith(color: theme.disabledColor); + final background = powering + ? theme.colorScheme.surfaceContainerHighest + : theme.colorScheme.surfaceVariant; + return Chip( + label: Text( + '${loco.locoClass} ${loco.number}', + style: labelStyle, + ), + avatar: Icon( + Icons.directions_railway, + size: 16, + color: iconColor, + ), + backgroundColor: background, + ); + }, ) .toList(); } @@ -192,4 +213,3 @@ class LegCard extends StatelessWidget { return [trimmed]; } } - diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index 9171806..ad28a16 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart'; import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart'; import 'package:mileograph_flutter/objects/objects.dart'; @@ -32,6 +33,7 @@ class _DashboardState extends State { data.fetchOnThisDay(), data.fetchTripDetails(), data.fetchHadTraction(), + data.fetchLatestLocoChanges(), ]); }, child: LayoutBuilder( @@ -41,7 +43,7 @@ class _DashboardState extends State { context, totalMileage: stats?.totalMileage ?? 0, currentYearMileage: data.getMileageForCurrentYear(), - trips: data.trips.length, + legCount: stats?.legCount ?? data.trips.length, ); return Stack( children: [ @@ -138,7 +140,7 @@ class _DashboardState extends State { BuildContext context, { required double totalMileage, required double currentYearMileage, - required int trips, + required int legCount, }) { final textTheme = Theme.of(context).textTheme; Widget metricCard(String label, String value) { @@ -173,7 +175,7 @@ class _DashboardState extends State { return [ metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'), metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'), - metricCard('Trips logged', trips.toString()), + metricCard('Entries logged', legCount.toString()), ]; } @@ -211,8 +213,6 @@ class _DashboardState extends State { ), ), const SizedBox(height: 12), - _buildQuickCalcCard(context), - const SizedBox(height: 12), _buildTripsCard(context, data), ], ); @@ -220,10 +220,13 @@ class _DashboardState extends State { Widget _buildSidebar(BuildContext context, DataService data) { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TopTractionPanel(), + const TopTractionPanel(), const SizedBox(height: 12), - LeaderboardPanel(), + const LeaderboardPanel(), + const SizedBox(height: 12), + const LatestLocoChangesPanel(), ], ); } @@ -313,22 +316,6 @@ class _DashboardState extends State { ); } - Widget _buildQuickCalcCard(BuildContext context) { - return _buildCard( - context, - title: 'Quick mileage calculator', - action: TextButton.icon( - onPressed: () => context.push('/calculator'), - icon: const Icon(Icons.open_in_new), - label: const Text('Open calculator'), - ), - child: Text( - 'Jump into the route calculator to quickly total a journey before saving it.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ); - } - Widget _buildTripsCard(BuildContext context, DataService data) { final tripsUnsorted = data.trips; List trips = []; @@ -353,7 +340,6 @@ class _DashboardState extends State { contentPadding: EdgeInsets.zero, title: Text(trip.tripName), subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), - trailing: const Icon(Icons.chevron_right), ); }).toList(), ), diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index a49bebb..fc151f4 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; @@ -211,7 +212,7 @@ class _LegsPageState extends State { else Column( children: [ - ...legs.map((leg) => LegCard(leg: leg)), + ..._buildLegsWithDividers(context, legs), const SizedBox(height: 8), if (data.legsHasMore || data.isLegsLoading) Align( @@ -238,6 +239,57 @@ class _LegsPageState extends State { ); } + List _buildLegsWithDividers(BuildContext context, List legs) { + final widgets = []; + String? currentDate; + double dayMileage = 0; + final dayLegs = []; + + void flushDay() { + if (currentDate == null) return; + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Expanded( + child: Text( + currentDate!, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '${dayMileage.toStringAsFixed(1)} mi', + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ), + ); + widgets.add(const Divider()); + widgets.addAll( + dayLegs.map((leg) => LegCard(leg: leg, showDate: false)), + ); + dayLegs.clear(); + } + + for (final leg in legs) { + final dateStr = _formatDate(leg.beginTime) ?? ''; + if (currentDate != null && dateStr != currentDate) { + flushDay(); + dayMileage = 0; + } + currentDate = dateStr; + dayLegs.add(leg); + dayMileage += leg.mileage; + } + + flushDay(); + return widgets; + } + String? _formatDate(DateTime? date) { if (date == null) return null; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart index ae71de2..c9383ff 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -443,53 +443,59 @@ class _ValueBlockMenu extends StatelessWidget { return _ValueBlockView(block: block); } + Future showContextMenuAt(Offset globalPosition) async { + final overlay = Overlay.of(context); + final renderBox = overlay?.context.findRenderObject() as RenderBox?; + if (renderBox == null) return; + final position = RelativeRect.fromRect( + Rect.fromLTWH( + globalPosition.dx, + globalPosition.dy, + 1, + 1, + ), + Offset.zero & renderBox.size, + ); + + final action = await showMenu<_TimelineBlockAction>( + context: context, + position: position, + items: [ + if (onEditEntry != null) + const PopupMenuItem( + value: _TimelineBlockAction.edit, + child: Text('Edit'), + ), + if (onDeleteEntry != null) + const PopupMenuItem( + value: _TimelineBlockAction.delete, + child: Text('Delete'), + ), + ], + ); + + final entry = block.entry; + if (action == null || entry == null) return; + switch (action) { + case _TimelineBlockAction.edit: + onEditEntry?.call(entry); + break; + case _TimelineBlockAction.delete: + onDeleteEntry?.call(entry); + break; + } + } + return GestureDetector( behavior: HitTestBehavior.opaque, onLongPressStart: (details) async { - final overlay = Overlay.of(context); - final renderBox = overlay.context.findRenderObject() as RenderBox?; - if (renderBox == null) return; if (defaultTargetPlatform == TargetPlatform.android) { HapticFeedback.lightImpact(); } - final anchor = details.globalPosition + const Offset(0, -8); - final position = RelativeRect.fromRect( - Rect.fromLTWH( - anchor.dx, - anchor.dy, - 1, - 1, - ), - Offset.zero & renderBox.size, - ); - - final action = await showMenu<_TimelineBlockAction>( - context: context, - position: position, - items: [ - if (onEditEntry != null) - const PopupMenuItem( - value: _TimelineBlockAction.edit, - child: Text('Edit'), - ), - if (onDeleteEntry != null) - const PopupMenuItem( - value: _TimelineBlockAction.delete, - child: Text('Delete'), - ), - ], - ); - - final entry = block.entry; - if (action == null || entry == null) return; - switch (action) { - case _TimelineBlockAction.edit: - onEditEntry?.call(entry); - break; - case _TimelineBlockAction.delete: - onDeleteEntry?.call(entry); - break; - } + await showContextMenuAt(details.globalPosition); + }, + onSecondaryTapDown: (details) async { + await showContextMenuAt(details.globalPosition); }, child: _ValueBlockView(block: block), ); diff --git a/lib/components/pages/new_entry/new_entry_draft_logic.dart b/lib/components/pages/new_entry/new_entry_draft_logic.dart index 54e12e5..8ebedae 100644 --- a/lib/components/pages/new_entry/new_entry_draft_logic.dart +++ b/lib/components/pages/new_entry/new_entry_draft_logic.dart @@ -84,6 +84,32 @@ extension _NewEntryDraftLogic on _NewEntryPageState { } } + Future _saveDraftManually() async { + if (_savingDraft) return; + if (_formIsEmpty()) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text('Nothing to save yet.')), + ); + return; + } + final hadDraft = _activeDraftId != null; + _setState(() => _savingDraft = true); + try { + await _saveDraftEntry(draftId: _activeDraftId); + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(hadDraft ? 'Draft updated' : 'Draft saved')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text('Failed to save draft: $e')), + ); + } finally { + if (mounted) _setState(() => _savingDraft = false); + } + } + Future _saveDraft() async { if (_restoringDraft || !_draftPersistenceEnabled) return; final prefs = await SharedPreferences.getInstance(); @@ -212,6 +238,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(), "mode": _useManualMileage ? 'manual' : 'auto', "payload": payload, + "mileageText": _mileageController.text.trim(), "routeResult": _routeResult == null ? null : { diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 672f9c1..9502d1b 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -27,6 +27,7 @@ class _NewEntryPageState extends State { int? _selectedTripId; bool _restoringDraft = false; bool _loadingEdit = false; + bool _savingDraft = false; String? _loadError; Map? _lastSubmittedSnapshot; Map? _loadedDraftSnapshot; @@ -48,7 +49,7 @@ class _NewEntryPageState extends State { if (!mounted) return; final data = context.read(); data.fetchClassList(); - data.fetchTrips(); + data.fetchTripOptions(); if (_draftPersistenceEnabled) { _loadDraft(); } @@ -146,20 +147,31 @@ class _NewEntryPageState extends State { return; } if (result != null && result.isNotEmpty) { - final api = context.read(); - final data = context.read(); - final messenger = ScaffoldMessenger.maybeOf(context); - try { - await api.put('/trips/new', {"trip_name": result}); - await data.fetchTrips(); - if (!context.mounted) return; - final trips = data.tripList; - final match = trips.firstWhere( - (t) => t.tripName == result, - orElse: () => trips.isNotEmpty - ? trips.first - : TripSummary(tripId: 0, tripName: result, tripMileage: 0), - ); + final api = context.read(); + final data = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final encoded = Uri.encodeComponent(result); + final res = await api.put('/trips/new?trip_name=$encoded', {}); + await data.fetchTripOptions(); + if (!context.mounted) return; + final trips = data.tripList; + final apiTripId = res is Map ? res['trip_id'] as int? : null; + TripSummary match; + try { + match = trips.firstWhere( + (t) => + (apiTripId != null && t.tripId == apiTripId) || + t.tripName == result, + ); + } catch (_) { + match = TripSummary( + tripId: apiTripId ?? 0, + tripName: result, + tripMileage: 0, + ); + data.upsertTripSummary(match); + } setState(() => _selectedTripId = match.tripId); _saveDraft(); } catch (e) { @@ -176,9 +188,13 @@ class _NewEntryPageState extends State { } Future _openCalculator() async { + final initialStations = _routeResult?.inputRoute.isNotEmpty == true + ? _routeResult!.inputRoute + : (_routeResult?.calculatedRoute ?? const []); final result = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => _CalculatorPickerPage( + initialStations: initialStations.isEmpty ? null : initialStations, onResult: (res) => Navigator.of(context).pop(res), ), ), @@ -373,6 +389,25 @@ class _NewEntryPageState extends State { icon: const Icon(Icons.list_alt, size: 16), label: const Text('Drafts'), ), + const SizedBox(width: 12), + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _isEditing || _savingDraft || _submitting + ? null + : _saveDraftManually, + icon: _savingDraft + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_alt, size: 16), + label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'), + ), const Spacer(), TextButton.icon( style: TextButton.styleFrom( diff --git a/lib/components/pages/new_entry/new_entry_picker_pages.dart b/lib/components/pages/new_entry/new_entry_picker_pages.dart index 88787c4..8256900 100644 --- a/lib/components/pages/new_entry/new_entry_picker_pages.dart +++ b/lib/components/pages/new_entry/new_entry_picker_pages.dart @@ -1,8 +1,12 @@ part of 'new_entry.dart'; class _CalculatorPickerPage extends StatelessWidget { - const _CalculatorPickerPage({required this.onResult}); + const _CalculatorPickerPage({ + required this.onResult, + this.initialStations, + }); final ValueChanged onResult; + final List? initialStations; @override Widget build(BuildContext context) { @@ -14,8 +18,10 @@ class _CalculatorPickerPage extends StatelessWidget { ), title: const Text('Mileage calculator'), ), - body: RouteCalculator(onApplyRoute: onResult), + body: RouteCalculator( + onApplyRoute: onResult, + initialStations: initialStations, + ), ); } } - diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart index 8ddb1fe..109b5c5 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -27,7 +27,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { final fieldList = missing.join(', '); await showDialog( context: context, - builder: (_) => AlertDialog( + useRootNavigator: false, + builder: (dialogCtx) => AlertDialog( title: const Text('Required field missing'), content: Text( missing.length == 1 @@ -36,7 +37,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'), ), ], @@ -46,7 +47,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { } Future _submit() async { - if (!_formKey.currentState!.validate()) return; + final form = _formKey.currentState; + if (form == null) return; + if (!form.validate()) return; if (!await _validateRequiredFields()) return; final routeStations = _routeResult?.calculatedRoute ?? []; final startVal = _useManualMileage @@ -208,6 +211,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { _selectedTripId = null; _submitting = false; _activeDraftId = null; + _savingDraft = false; + _loadedDraftSnapshot = null; }); if (clearDraft) { await _clearDraft(); diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index b34cc5c..54a6134 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -26,6 +26,55 @@ class _TripsPageState extends State { await context.read().fetchTripDetails(); } + Future _renameTrip(TripDetail trip, String newName) async { + final data = context.read(); + final api = data.api; + final messenger = ScaffoldMessenger.maybeOf(context); + try { + await api.post('/trips/rename', { + "trip_id": trip.id, + "trip_name": newName, + }); + await Future.wait([ + data.fetchTripDetails(), + data.fetchTrips(), + ]); + } catch (e) { + messenger?.showSnackBar( + SnackBar(content: Text('Failed to rename trip: $e')), + ); + rethrow; + } + } + + Future _promptTripName(BuildContext context, String initial) async { + final controller = TextEditingController(text: initial); + final newName = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Rename trip'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Trip name'), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => + Navigator.of(dialogCtx).pop(controller.text.trim()), + child: const Text('Save'), + ), + ], + ), + ); + controller.dispose(); + return newName; + } + @override Widget build(BuildContext context) { final data = context.watch(); @@ -218,52 +267,85 @@ class _TripsPageState extends State { context: context, isScrollControlled: true, builder: (_) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + bool renaming = false; + String tripName = trip.name; + return StatefulBuilder( + builder: (sheetCtx, setSheetState) { + Future handleRename() async { + final newName = + await _promptTripName(sheetCtx, tripName) ?? tripName; + if (newName.isEmpty || newName == tripName) return; + setSheetState(() => renaming = true); + try { + await _renameTrip(trip, newName); + tripName = newName; + setSheetState(() {}); + } finally { + if (mounted) setSheetState(() => renaming = false); + } + } + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(sheetCtx).pop(), + ), + Expanded( + child: Text( + tripName, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + IconButton( + icon: renaming + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.edit), + tooltip: 'Rename trip', + onPressed: renaming ? null : handleRename, + ), + const SizedBox(width: 4), + Text('${trip.mileage.toStringAsFixed(1)} mi'), + ], ), - Text( - trip.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, + const SizedBox(height: 8), + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: ListView.builder( + itemCount: trip.legs.length, + itemBuilder: (context, index) { + final leg = trip.legs[index]; + return ListTile( + leading: const Icon(Icons.train), + title: Text('${leg.start} → ${leg.end}'), + subtitle: Text(_formatDate(leg.beginTime)), + trailing: Text( + leg.mileage?.toStringAsFixed(1) ?? '-', + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + }, ), ), - const Spacer(), - Text('${trip.mileage.toStringAsFixed(1)} mi'), ], ), - const SizedBox(height: 8), - SizedBox( - height: MediaQuery.of(context).size.height * 0.6, - child: ListView.builder( - itemCount: trip.legs.length, - itemBuilder: (context, index) { - final leg = trip.legs[index]; - return ListTile( - leading: const Icon(Icons.train), - title: Text('${leg.start} → ${leg.end}'), - subtitle: Text(_formatDate(leg.beginTime)), - trailing: Text( - leg.mileage?.toStringAsFixed(1) ?? '-', - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(fontWeight: FontWeight.bold), - ), - ); - }, - ), - ), - ], - ), - ), + ), + ); + }, ); }, ); diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 81bc567..5666b1a 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -int _asInt(dynamic value, [int fallback = 0]) { +int _asInt(dynamic value, [int? fallback]) { if (value is int) return value; if (value is num) return value.toInt(); final parsed = int.tryParse(value?.toString() ?? ''); - return parsed ?? fallback; + return parsed ?? fallback ?? 0; } double _asDouble(dynamic value, [double fallback = 0]) { @@ -20,6 +20,15 @@ String _asString(dynamic value, [String fallback = '']) { return (str == null) ? fallback : str; } +bool _asBool(dynamic value, [bool fallback = false]) { + if (value is bool) return value; + if (value is num) return value != 0; + final lower = value?.toString().toLowerCase(); + if (lower == 'true' || lower == 'yes' || lower == '1') return true; + if (lower == 'false' || lower == 'no' || lower == '0') return false; + return fallback; +} + DateTime _asDateTime(dynamic value, [DateTime? fallback]) { if (value is DateTime) return value; final parsed = DateTime.tryParse(value?.toString() ?? ''); @@ -67,6 +76,7 @@ class HomepageStats { final List topLocos; final List leaderboard; final List trips; + final int legCount; final UserData? user; HomepageStats({ @@ -75,6 +85,7 @@ class HomepageStats { required this.topLocos, required this.leaderboard, required this.trips, + required this.legCount, this.user, }); @@ -98,6 +109,10 @@ class HomepageStats { trips: (json['trip_data'] as List? ?? []) .map((e) => TripSummary.fromJson(e)) .toList(), + legCount: _asInt( + json['leg_count'], + (json['trip_legs'] as List?)?.length ?? 0, + ), user: userData == null ? null : UserData( @@ -126,6 +141,7 @@ class Loco { final int id; final String type, number, locoClass; final String? name, operator, notes, evn; + final bool powering; Loco({ required this.id, @@ -136,6 +152,7 @@ class Loco { required this.operator, this.notes, this.evn, + this.powering = true, }); factory Loco.fromJson(Map json) => Loco( @@ -147,6 +164,7 @@ class Loco { operator: json['operator'], notes: json['notes'], evn: json['evn'], + powering: _asBool(json['alloc_powering'] ?? json['powering'], true), ); } @@ -179,6 +197,7 @@ class LocoSummary extends Loco { this.livery, this.location, Map? extra, + bool powering = true, }) : extra = extra ?? const {}, super( id: locoId, @@ -188,6 +207,7 @@ class LocoSummary extends Loco { operator: locoOperator, notes: locoNotes, evn: locoEvn, + powering: powering, ); factory LocoSummary.fromJson(Map json) => LocoSummary( @@ -213,6 +233,7 @@ class LocoSummary extends Loco { livery: json['livery'], location: json['location'], extra: Map.from(json), + powering: _asBool(json['alloc_powering'] ?? json['powering'], true), ); } @@ -353,6 +374,96 @@ class LocoAttrVersion { } } +class LocoChange { + final int locoId; + final String locoClass; + final String locoNumber; + final String locoName; + final String attrCode; + final String attrDisplay; + final String valueDisplay; + final DateTime? validFrom; + final DateTime? approvedAt; + final String approvedBy; + + const LocoChange({ + required this.locoId, + required this.locoClass, + required this.locoNumber, + required this.locoName, + required this.attrCode, + required this.attrDisplay, + required this.valueDisplay, + required this.validFrom, + required this.approvedAt, + required this.approvedBy, + }); + + factory LocoChange.fromJson(Map json) { + String _clean(dynamic value) { + final str = value?.toString().trim() ?? ''; + if (str.isEmpty || str == '-' || str == '?') return ''; + return str; + } + + final valueLabel = json['value_norm'] ?? + json['value_display'] ?? + json['value_label'] ?? + json['value_str'] ?? + json['value_enum'] ?? + json['value_norm'] ?? + json['value']; + final approvedRaw = json['approved_at'] ?? json['approvedAt']; + final validFromRaw = json['valid_from'] ?? json['validFrom']; + return LocoChange( + locoId: _asInt(json['loco_id']), + locoClass: _clean(json['loco_class']), + locoNumber: _clean(json['loco_number']), + locoName: _clean(json['loco_name']), + attrCode: _asString(json['attr_code']), + attrDisplay: _clean(json['attr_display']), + valueDisplay: _clean(valueLabel), + validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''), + approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''), + approvedBy: _clean(json['approved_by']), + ); + } + + String get locoLabel { + final parts = [locoClass, locoNumber] + .map((e) => e.trim()) + .where((e) => e.isNotEmpty && e != '-') + .toList(); + final label = parts.join(' '); + if (label.isEmpty) return locoName.isNotEmpty ? locoName : 'Loco $locoId'; + return locoName.trim().isEmpty ? label : '$label — ${locoName.trim()}'; + } + + String get changeLabel => + _cleanLabel(attrDisplay).isNotEmpty + ? _cleanLabel(attrDisplay) + : _cleanLabel(attrCode).toUpperCase(); + + String get approvedDateLabel { + final date = approvedAt ?? validFrom; + if (date == null) return 'Pending date'; + return DateFormat('yyyy-MM-dd').format(date); + } + + String get valueLabel { + final value = _cleanLabel(valueDisplay); + if (value.isNotEmpty) return value; + return 'Unknown value'; + } + + String _cleanLabel(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return ''; + if (trimmed == '-' || trimmed == '?') return ''; + return trimmed; + } +} + class LeaderboardEntry { final String userId, username, userFullName; final double mileage; diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 9e652d4..eb06667 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -44,6 +44,10 @@ class DataService extends ChangeNotifier { bool get isTractionLoading => _isTractionLoading; bool _tractionHasMore = false; bool get tractionHasMore => _tractionHasMore; + List _latestLocoChanges = []; + List get latestLocoChanges => _latestLocoChanges; + bool _isLatestLocoChangesLoading = false; + bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading; final Map> _locoTimelines = {}; final Map _isLocoTimelineLoading = {}; List timelineForLoco(int locoId) => @@ -148,7 +152,8 @@ class DataService extends ChangeNotifier { if (json is List) { final newLegs = json.map((e) => Leg.fromJson(e)).toList(); _legs = append ? [..._legs, ...newLegs] : newLegs; - _legsHasMore = newLegs.length >= limit; + // Keep "load more" available as long as the server returns items; hide only on empty. + _legsHasMore = newLegs.isNotEmpty; } else { throw Exception('Unexpected legs response: $json'); } @@ -180,7 +185,7 @@ class DataService extends ChangeNotifier { final params = includeNonPowering ? '?include_non_powering=true' : ''; try { - final json = await api.get('/legs/$locoId$params'); + final json = await api.get('/legs/by-loco/$locoId$params'); dynamic list = json; if (json is Map) { for (final key in ['legs', 'data', 'results']) { @@ -340,6 +345,8 @@ class DataService extends ChangeNotifier { _eventFields = []; _locoTimelines.clear(); _isLocoTimelineLoading.clear(); + _latestLocoChanges = []; + _isLatestLocoChangesLoading = false; _notifyAsync(); } diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart index 187c978..5d73f63 100644 --- a/lib/services/data_service/data_service_traction.dart +++ b/lib/services/data_service/data_service_traction.dart @@ -114,5 +114,40 @@ extension DataServiceTraction on DataService { } return _locoClasses; } -} + Future fetchLatestLocoChanges({int limit = 25, int offset = 0}) async { + _isLatestLocoChangesLoading = true; + _notifyAsync(); + try { + final json = + await api.get('/loco/changes/latest?limit=$limit&offset=$offset'); + dynamic results = json; + if (json is Map && json['data'] is List) { + results = json['data']; + } + if (results is List) { + final parsed = []; + for (final item in results) { + if (item is Map) { + parsed.add(LocoChange.fromJson(item)); + } else if (item is Map) { + parsed.add( + LocoChange.fromJson( + item.map((key, value) => MapEntry(key.toString(), value)), + ), + ); + } + } + _latestLocoChanges = parsed; + } else { + throw Exception('Unexpected latest loco changes response: $json'); + } + } catch (e) { + debugPrint('Failed to fetch latest loco changes: $e'); + _latestLocoChanges = []; + } finally { + _isLatestLocoChangesLoading = false; + _notifyAsync(); + } + } +} diff --git a/lib/services/data_service/data_service_trips.dart b/lib/services/data_service/data_service_trips.dart index 676c157..f5f78c0 100644 --- a/lib/services/data_service/data_service_trips.dart +++ b/lib/services/data_service/data_service_trips.dart @@ -83,5 +83,50 @@ extension DataServiceTrips on DataService { _notifyAsync(); } } -} + Future fetchTripOptions() async { + try { + final json = await api.get('/trips'); + Iterable? raw; + if (json is List) { + raw = json; + } else if (json is Map) { + for (final key in ['trips', 'trip_data', 'data']) { + final value = json[key]; + if (value is List) { + raw = value; + break; + } + } + } + if (raw != null) { + final tripMap = raw + .whereType>() + .map((e) => TripSummary.fromJson(e)) + .toList(); + + _tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId)); + } else { + debugPrint('Unexpected trip list response: $json'); + _tripList = []; + } + } catch (e) { + debugPrint('Failed to fetch trip list: $e'); + _tripList = []; + } finally { + _notifyAsync(); + } + } + + void upsertTripSummary(TripSummary trip) { + final existingIndex = + _tripList.indexWhere((element) => element.tripId == trip.tripId); + if (existingIndex >= 0) { + _tripList[existingIndex] = trip; + } else { + _tripList = [trip, ..._tripList]; + } + _tripList.sort((a, b) => b.tripId.compareTo(a.tripId)); + _notifyAsync(); + } +} diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index eafe85a..1a57111 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -31,6 +31,21 @@ const List _contentPages = [ const int _addTabIndex = 5; +class _NavItem { + final String label; + final IconData icon; + const _NavItem(this.label, this.icon); +} + +const List<_NavItem> _navItems = [ + _NavItem("Home", Icons.home), + _NavItem("Calculator", Icons.route), + _NavItem("Entries", Icons.list), + _NavItem("Traction", Icons.train), + _NavItem("Trips", Icons.book), + _NavItem("Add", Icons.add), +]; + int tabIndexForPath(String path) { final newIndex = _contentPages.indexWhere((routePath) { if (path == routePath) return true; @@ -218,6 +233,9 @@ class _MyHomePageState extends State { if (data.traction.isEmpty) { data.fetchHadTraction(); } + if (data.latestLocoChanges.isEmpty) { + data.fetchLatestLocoChanges(); + } if (data.onThisDay.isEmpty) { data.fetchOnThisDay(); } @@ -273,41 +291,82 @@ class _MyHomePageState extends State { SystemNavigator.pop(); }, - child: Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text.rich( - TextSpan( - children: const [ - TextSpan(text: "Mile"), - TextSpan(text: "O", style: TextStyle(color: Colors.red)), - TextSpan(text: "graph"), - ], - style: const TextStyle( - decoration: TextDecoration.none, - color: Colors.white, - fontFamily: "Tomatoes", + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 900; + final railExtended = constraints.maxWidth >= 1400; + final navRailDestinations = _navItems + .map( + (item) => NavigationRailDestination( + icon: Icon(item.icon), + label: Text(item.label), + ), + ) + .toList(); + final navBarDestinations = _navItems + .map( + (item) => NavigationDestination( + icon: Icon(item.icon), + label: item.label, + ), + ) + .toList(); + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text.rich( + TextSpan( + children: const [ + TextSpan(text: "Mile"), + TextSpan(text: "O", style: TextStyle(color: Colors.red)), + TextSpan(text: "graph"), + ], + style: const TextStyle( + decoration: TextDecoration.none, + color: Colors.white, + fontFamily: "Tomatoes", + ), + ), ), + actions: [ + const IconButton( + onPressed: null, + icon: Icon(Icons.account_circle), + ), + IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), + ], ), - ), - actions: [ - const IconButton(onPressed: null, icon: Icon(Icons.account_circle)), - IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), - ], - ), - bottomNavigationBar: NavigationBar( - selectedIndex: pageIndex, - onDestinationSelected: (int index) => _onItemTapped(index, pageIndex), - destinations: const [ - NavigationDestination(icon: Icon(Icons.home), label: "Home"), - NavigationDestination(icon: Icon(Icons.route), label: "Calculator"), - NavigationDestination(icon: Icon(Icons.list), label: "Entries"), - NavigationDestination(icon: Icon(Icons.train), label: "Traction"), - NavigationDestination(icon: Icon(Icons.book), label: "Trips"), - NavigationDestination(icon: Icon(Icons.add), label: "Add"), - ], - ), - body: currentPage, + bottomNavigationBar: isWide + ? null + : NavigationBar( + selectedIndex: pageIndex, + onDestinationSelected: (int index) => + _onItemTapped(index, pageIndex), + destinations: navBarDestinations, + ), + body: isWide + ? Row( + children: [ + SafeArea( + child: NavigationRail( + selectedIndex: pageIndex, + extended: railExtended, + labelType: railExtended + ? NavigationRailLabelType.none + : NavigationRailLabelType.selected, + onDestinationSelected: (int index) => + _onItemTapped(index, pageIndex), + destinations: navRailDestinations, + ), + ), + const VerticalDivider(width: 1), + Expanded(child: currentPage), + ], + ) + : currentPage, + ); + }, ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 717151c..4388848 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.2.4+1 +version: 0.3.0+1 environment: sdk: ^3.8.1