From 44d79e7c28cc7623f3d277283cf21b2bedbb5fb0 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Tue, 23 Dec 2025 17:41:21 +0000 Subject: [PATCH] Improve entries page and latest changes panel, units on events and timeline --- .../calculator/route_summary_widget.dart | 18 +- .../dashboard/latest_loco_changes_panel.dart | 469 ++++++++- lib/components/legs/leg_card.dart | 169 ++- lib/components/pages/calculator_details.dart | 2 +- lib/components/pages/dashboard.dart | 4 +- lib/components/pages/loco_timeline.dart | 32 +- .../pages/loco_timeline/event_editor.dart | 141 ++- .../pages/loco_timeline/timeline_grid.dart | 59 +- .../new_entry/new_entry_submit_logic.dart | 4 +- .../new_entry/new_entry_traction_logic.dart | 5 +- lib/components/pages/traction/traction.dart | 1 + .../pages/traction/traction_page.dart | 977 +++++++++++++----- .../data_service/data_service_core.dart | 3 + .../data_service/data_service_traction.dart | 33 +- lib/ui/app_shell.dart | 343 +++--- pubspec.yaml | 2 +- 16 files changed, 1764 insertions(+), 498 deletions(-) diff --git a/lib/components/calculator/route_summary_widget.dart b/lib/components/calculator/route_summary_widget.dart index a4eacd2..9845b88 100644 --- a/lib/components/calculator/route_summary_widget.dart +++ b/lib/components/calculator/route_summary_widget.dart @@ -36,16 +36,20 @@ class RouteDetailsView extends StatelessWidget { final List route; final List costs; final VoidCallback onBack; + final Set routingPoints; const RouteDetailsView({ super.key, required this.route, required this.costs, required this.onBack, + this.routingPoints = const {}, }); @override Widget build(BuildContext context) { + final highlightColor = Theme.of(context).colorScheme.primary; + final mutedColor = Theme.of(context).colorScheme.outlineVariant; return Column( children: [ Align( @@ -60,8 +64,20 @@ class RouteDetailsView extends StatelessWidget { child: ListView.builder( itemCount: route.length, itemBuilder: (context, index) { + final label = route[index]; + final isRoutingPoint = routingPoints.contains(label); return ListTile( - title: Text(route[index]), + leading: Icon( + Icons.circle, + size: 12, + color: isRoutingPoint ? highlightColor : mutedColor, + ), + title: Text( + label, + style: isRoutingPoint + ? TextStyle(color: highlightColor, fontWeight: FontWeight.w600) + : null, + ), trailing: Text("${costs[index].toStringAsFixed(2)} mi"), ); }, diff --git a/lib/components/dashboard/latest_loco_changes_panel.dart b/lib/components/dashboard/latest_loco_changes_panel.dart index 739f860..fc1d355 100644 --- a/lib/components/dashboard/latest_loco_changes_panel.dart +++ b/lib/components/dashboard/latest_loco_changes_panel.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; class LatestLocoChangesPanel extends StatefulWidget { - const LatestLocoChangesPanel({super.key}); + const LatestLocoChangesPanel({super.key, this.expanded = false}); + + final bool expanded; @override State createState() => _LatestLocoChangesPanelState(); @@ -11,6 +15,9 @@ class LatestLocoChangesPanel extends StatefulWidget { class _LatestLocoChangesPanelState extends State { late final ScrollController _controller; + final Set _collapsedDates = {}; + final Set _collapsedClasses = {}; + final Set _collapsedLocos = {}; @override void initState() { @@ -29,17 +36,18 @@ class _LatestLocoChangesPanelState extends State { final data = context.watch(); final changes = data.latestLocoChanges; final isLoading = data.isLatestLocoChangesLoading; + final hasMore = data.latestLocoChangesHasMore; final textTheme = Theme.of(context).textTheme; return Card( clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ const Icon(Icons.bolt, size: 20), const SizedBox(width: 8), Expanded( @@ -73,52 +81,419 @@ class _LatestLocoChangesPanelState extends State { ), ) else - SizedBox( - height: 260, - child: Scrollbar( - controller: _controller, - child: ListView.separated( - controller: _controller, - itemCount: changes.length, - separatorBuilder: (context, index) => - 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, - ), - ); - }, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildChangesList(changes, textTheme), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: isLoading ? null : _loadMore, + icon: isLoading + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text(isLoading ? 'Loading...' : 'Show more'), + ), ), - ), + ], ), ], ), ), ); } + + Widget _buildChangesList(List changes, TextTheme textTheme) { + final grouped = _groupChanges(changes); + // Start with all locos collapsed by default. + if (_collapsedLocos.isEmpty) { + for (final group in grouped) { + for (final classGroup in group.classGroups) { + for (final locoGroup in classGroup.locoGroups) { + _collapsedLocos.add( + _locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel), + ); + } + } + } + } + + final listView = ListView.separated( + controller: null, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, groupIndex) { + final group = grouped[groupIndex]; + final dateCollapsed = _collapsedDates.contains(group.dateLabel); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: [ + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => _toggleDate(group.dateLabel), + icon: Icon( + dateCollapsed ? Icons.chevron_right : Icons.expand_more, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + group.dateLabel, + style: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton( + onPressed: () => _collapseDateChildren( + group.dateLabel, + group.classGroups, + collapse: !_isDateFullyCollapsed(group), + ), + child: Text( + _isDateFullyCollapsed(group) ? 'Expand all' : 'Collapse all', + ), + ), + ], + ), + ), + if (!dateCollapsed) + ...group.classGroups.map( + (classGroup) { + final classKey = _classKey(group.dateLabel, classGroup.classLabel); + final classCollapsed = _collapsedClasses.contains(classKey); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0, left: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => _toggleClass(classKey), + icon: Icon( + classCollapsed + ? Icons.chevron_right + : Icons.expand_more, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + classGroup.classLabel, + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton( + onPressed: () => _collapseClassChildren( + group.dateLabel, + classGroup.classLabel, + classGroup.locoGroups, + collapse: + !_isClassFullyCollapsed(classKey, classGroup, group.dateLabel), + ), + child: Text( + _isClassFullyCollapsed(classKey, classGroup, group.dateLabel) + ? 'Expand all' + : 'Collapse all', + ), + ), + ], + ), + if (!classCollapsed) + ...classGroup.locoGroups.map( + (locoGroup) { + final locoKey = + _locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel); + final locoCollapsed = _collapsedLocos.contains(locoKey); + return Padding( + padding: + const EdgeInsets.only(bottom: 4.0, left: 22.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => _toggleLoco(locoKey), + icon: Icon( + locoCollapsed + ? Icons.chevron_right + : Icons.expand_more, + ), + ), + const SizedBox(width: 4), + Text( + locoGroup.locoLabel, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + if (!locoCollapsed) ...[ + const SizedBox(height: 2), + ...locoGroup.changes.map( + (change) => ListTile( + dense: true, + visualDensity: const VisualDensity( + horizontal: 0, + vertical: -3, + ), + minVerticalPadding: 0, + contentPadding: EdgeInsets.zero, + title: Text( + '${change.changeLabel}: ${change.valueLabel}', + style: textTheme.bodyMedium, + ), + trailing: change.approvedBy.isEmpty + ? null + : Text( + change.approvedBy, + style: textTheme.labelSmall, + ), + ), + ), + ], + ], + ), + ); + }, + ), + ], + ), + ); + }, + ), + ], + ); + }, + separatorBuilder: (_, __) => const Divider(height: 8), + itemCount: grouped.length, + ); + + if (widget.expanded) { + return listView; + } + + return listView; + } + + void _toggleDate(String date) { + setState(() { + if (_collapsedDates.contains(date)) { + _collapsedDates.remove(date); + } else { + _collapsedDates.add(date); + } + }); + } + + void _toggleClass(String key) { + setState(() { + if (_collapsedClasses.contains(key)) { + _collapsedClasses.remove(key); + } else { + _collapsedClasses.add(key); + } + }); + } + + void _toggleLoco(String key) { + setState(() { + if (_collapsedLocos.contains(key)) { + _collapsedLocos.remove(key); + } else { + _collapsedLocos.add(key); + } + }); + } + + void _collapseDateChildren( + String date, + List<_ClassGroup> classGroups, { + required bool collapse, + }) { + setState(() { + for (final classGroup in classGroups) { + final classKey = _classKey(date, classGroup.classLabel); + if (collapse) { + _collapsedClasses.add(classKey); + } else { + _collapsedClasses.remove(classKey); + } + for (final locoGroup in classGroup.locoGroups) { + final locoKey = _locoKey(date, classGroup.classLabel, locoGroup.locoLabel); + if (collapse) { + _collapsedLocos.add(locoKey); + } else { + _collapsedLocos.remove(locoKey); + } + } + } + }); + } + + void _collapseClassChildren( + String date, + String classLabel, + List<_LocoGroup> locos, { + required bool collapse, + }) { + setState(() { + final classKey = _classKey(date, classLabel); + if (collapse) { + _collapsedClasses.add(classKey); + } else { + _collapsedClasses.remove(classKey); + } + for (final locoGroup in locos) { + final locoKey = _locoKey(date, classLabel, locoGroup.locoLabel); + if (collapse) { + _collapsedLocos.add(locoKey); + } else { + _collapsedLocos.remove(locoKey); + } + } + }); + } + + bool _isDateFullyCollapsed(_ChangeGroup group) { + for (final classGroup in group.classGroups) { + final classKey = _classKey(group.dateLabel, classGroup.classLabel); + if (!_collapsedClasses.contains(classKey)) return false; + for (final loco in classGroup.locoGroups) { + final locoKey = _locoKey(group.dateLabel, classGroup.classLabel, loco.locoLabel); + if (!_collapsedLocos.contains(locoKey)) return false; + } + } + return true; + } + + bool _isClassFullyCollapsed(String classKey, _ClassGroup classGroup, String date) { + if (!_collapsedClasses.contains(classKey)) return false; + for (final loco in classGroup.locoGroups) { + final locoKey = _locoKey(date, classGroup.classLabel, loco.locoLabel); + if (!_collapsedLocos.contains(locoKey)) return false; + } + return true; + } + + String _classKey(String date, String classLabel) => '$date|$classLabel'; + String _locoKey(String date, String classLabel, String locoLabel) => + '$date|$classLabel|$locoLabel'; + + List<_ChangeGroup> _groupChanges(List changes) { + final dateFormat = DateFormat('yyyy-MM-dd'); + final Map>>> grouped = {}; + + final filtered = changes.where((change) { + final code = change.attrCode.toLowerCase(); + return code != 'build_prec' && code != 'operational' && code != 'gettable'; + }); + + for (final change in filtered) { + final date = change.approvedAt ?? change.validFrom; + final dateKey = date != null ? dateFormat.format(date) : 'Unknown date'; + final classKey = change.locoClass.isNotEmpty + ? change.locoClass + : 'Unknown class'; + final locoKey = _locoLabel(change); + grouped.putIfAbsent(dateKey, () => {}); + grouped[dateKey]!.putIfAbsent(classKey, () => {}); + grouped[dateKey]![classKey]!.putIfAbsent(locoKey, () => []); + grouped[dateKey]![classKey]![locoKey]!.add(change); + } + + final sortedDates = grouped.keys.toList() + ..sort((a, b) { + if (a == 'Unknown date') return 1; + if (b == 'Unknown date') return -1; + return b.compareTo(a); // newest first + }); + + return sortedDates + .map( + (dateKey) => _ChangeGroup( + dateLabel: dateKey, + classGroups: grouped[dateKey]!.entries + .map( + (classEntry) => _ClassGroup( + classLabel: classEntry.key, + locoGroups: classEntry.value.entries + .map( + (locoEntry) => _LocoGroup( + locoLabel: locoEntry.key, + changes: locoEntry.value + ..sort( + (a, b) => (b.approvedAt ?? b.validFrom ?? DateTime(0)) + .compareTo(a.approvedAt ?? a.validFrom ?? DateTime(0)), + ), + ), + ) + .toList(), + ), + ) + .toList(), + ), + ) + .toList(); + } + + Future _loadMore() async { + final data = context.read(); + await data.fetchLatestLocoChanges( + offset: data.latestLocoChanges.length, + append: true, + ); + } +} + +class _ChangeGroup { + final String dateLabel; + final List<_ClassGroup> classGroups; + + _ChangeGroup({required this.dateLabel, required this.classGroups}); +} + +class _LocoGroup { + final String locoLabel; + final List changes; + + _LocoGroup({required this.locoLabel, required this.changes}); +} + +class _ClassGroup { + final String classLabel; + final List<_LocoGroup> locoGroups; + + _ClassGroup({required this.classLabel, required this.locoGroups}); +} + +String _locoLabel(LocoChange change) { + final number = change.locoNumber.trim(); + final name = change.locoName.trim(); + if (number.isNotEmpty && name.isNotEmpty) return '$number — $name'; + if (number.isNotEmpty) return number; + if (name.isNotEmpty) return name; + return 'Loco ${change.locoId}'; } diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index c127366..bdedc91 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -3,8 +3,10 @@ import 'dart:convert'; 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 LegCard extends StatelessWidget { +class LegCard extends StatefulWidget { const LegCard({ super.key, required this.leg, @@ -16,30 +18,106 @@ class LegCard extends StatelessWidget { final bool showEditButton; final bool showDate; + @override + State createState() => _LegCardState(); +} + +class _LegCardState extends State { + bool _expanded = false; + @override Widget build(BuildContext context) { + final leg = widget.leg; final routeSegments = _parseRouteSegments(leg.route); final textTheme = Theme.of(context).textTheme; return Card( child: ExpansionTile( + onExpansionChanged: (v) => setState(() => _expanded = v), tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), leading: const Icon(Icons.train), - title: Text('${leg.start} → ${leg.end}'), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDate) Text(_formatDateTime(leg.beginTime)), - if (leg.headcode.isNotEmpty) - Text( - 'Headcode: ${leg.headcode}', - style: textTheme.labelSmall, - ), - if (leg.network.isNotEmpty) - Text( - leg.network, - style: textTheme.labelSmall, - ), - ], + title: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 520; + final routeText = Text('${leg.start} → ${leg.end}'); + final timeText = + Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate)); + if (!isWide) { + return routeText; + } + return Row( + children: [ + timeText, + const SizedBox(width: 6), + const Text('·'), + const SizedBox(width: 6), + Expanded(child: routeText), + ], + ); + }, + ), + subtitle: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 520; + final timeWidget = + Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate)); + final tractionWrap = !_expanded && leg.locos.isNotEmpty + ? Wrap( + spacing: 8, + runSpacing: 4, + children: leg.locos.map((loco) { + final iconColor = loco.powering + ? Theme.of(context).colorScheme.primary + : Theme.of(context).hintColor; + final label = '${loco.locoClass} ${loco.number}'.trim(); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.train, size: 14, color: iconColor), + const SizedBox(width: 4), + Text( + label.isEmpty ? 'Loco ${loco.id}' : label, + style: textTheme.labelSmall, + ), + ], + ); + }).toList(), + ) + : null; + + final children = []; + if (isWide) { + if (tractionWrap != null) { + children.add(tractionWrap); + } + } else { + children.add(timeWidget); + if (tractionWrap != null) { + children.add(const SizedBox(height: 4)); + children.add(tractionWrap); + } + } + if (leg.headcode.isNotEmpty) { + children.add( + Text( + 'Headcode: ${leg.headcode}', + style: textTheme.labelSmall, + ), + ); + } + if (leg.network.isNotEmpty) { + children.add( + Text( + leg.network, + style: textTheme.labelSmall, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + }, ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -66,7 +144,7 @@ class LegCard extends StatelessWidget { ], ], ), - if (showEditButton) ...[ + if (widget.showEditButton) ...[ const SizedBox(width: 8), IconButton( tooltip: 'Edit entry', @@ -76,6 +154,18 @@ class LegCard extends StatelessWidget { constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: () => context.push('/legs/edit/${leg.id}'), ), + if (_expanded) ...[ + const SizedBox(width: 4), + IconButton( + tooltip: 'Delete entry', + icon: const Icon(Icons.delete_outline), + color: Theme.of(context).colorScheme.error, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () => _confirmDelete(context, leg.id), + ), + ], ], ], ), @@ -114,15 +204,52 @@ class LegCard extends StatelessWidget { ); } + Future _confirmDelete(BuildContext context, int legId) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete entry?'), + content: const Text('Are you sure you want to delete this entry?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + + final data = context.read(); + final messenger = ScaffoldMessenger.of(context); + try { + await data.api.delete('/legs/delete?leg_id=$legId'); + await data.refreshLegs(); + messenger.showSnackBar(const SnackBar(content: Text('Entry deleted'))); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to delete entry: $e')), + ); + } + } + 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')}'; } - String _formatDateTime(DateTime date) { + String _formatTime(DateTime date) { + return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + + String _formatDateTime(DateTime date, {bool includeDate = true}) { + final timeStr = _formatTime(date); + if (!includeDate) return timeStr; final dateStr = _formatDate(date); - final timeStr = - '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; return '$dateStr · $timeStr'; } diff --git a/lib/components/pages/calculator_details.dart b/lib/components/pages/calculator_details.dart index 44a4378..68f8181 100644 --- a/lib/components/pages/calculator_details.dart +++ b/lib/components/pages/calculator_details.dart @@ -39,9 +39,9 @@ class CalculatorDetailsPage extends StatelessWidget { child: RouteDetailsView( route: parsed.calculatedRoute, costs: parsed.costs, + routingPoints: parsed.inputRoute.toSet(), onBack: () => context.pop(), ), ); } } - diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index 594884e..b5a8470 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -232,6 +232,8 @@ class _DashboardState extends State { _buildOnThisDayCard(context, data), const SizedBox(height: 16), _buildTripsCard(context, data), + const SizedBox(height: 16), + const LatestLocoChangesPanel(expanded: true), ], ), ), @@ -244,8 +246,6 @@ class _DashboardState extends State { TopTractionPanel(), SizedBox(height: 16), LeaderboardPanel(), - SizedBox(height: 16), - LatestLocoChangesPanel(), ], ), ), diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 9317b34..216c5ab 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -36,6 +36,21 @@ class _LocoTimelinePageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } + dynamic _normalizeFieldValue(_FieldEntry field) { + final name = field.field.name.toLowerCase(); + final val = field.value; + if (name == 'max_speed') { + final numVal = val is num ? val.toDouble() : double.tryParse('$val'); + if (numVal == null) return val; + final unit = (field.unit ?? 'kph').toLowerCase(); + if (unit == 'mph') { + return numVal * 1.60934; + } + return numVal; + } + return val; + } + @override void dispose() { _disposeDrafts(_draftEvents); @@ -57,7 +72,7 @@ class _LocoTimelinePageState extends State { String? _eventDateForEntry(LocoAttrVersion entry) { final masked = entry.maskedValidFrom?.trim(); if (masked != null && masked.isNotEmpty) return masked; - final from = entry.validFrom ?? entry.txnFrom; + final from = entry.validFrom; if (from == null) return null; return DateFormat('yyyy-MM-dd').format(from); } @@ -115,7 +130,8 @@ class _LocoTimelinePageState extends State { draft.details = ''; draft.fields.add( _FieldEntry(field: field) - ..value = _valueForEntry(entry), + ..value = _valueForEntry(entry) + ..unit = _guessUnit(field, entry.valueLabel), ); setState(() { @@ -123,6 +139,16 @@ class _LocoTimelinePageState extends State { }); } + String? _guessUnit(EventField field, String valueLabel) { + final name = field.name.toLowerCase(); + if (name == 'max_speed') { + final val = valueLabel.toLowerCase(); + if (val.contains('mph')) return 'mph'; + return 'kph'; + } + return _defaultUnitForField(field); + } + Future _deleteEntry(LocoAttrVersion entry) async { if (_isDeleting) return; final blockId = entry.versionId; @@ -241,7 +267,7 @@ class _LocoTimelinePageState extends State { invalid.add('Field ${field.field.display} is empty'); break; } - values[field.field.name] = val; + values[field.field.name] = _normalizeFieldValue(field); } if (invalid.isNotEmpty) continue; if (values.isEmpty) { diff --git a/lib/components/pages/loco_timeline/event_editor.dart b/lib/components/pages/loco_timeline/event_editor.dart index 01ccc45..b537730 100644 --- a/lib/components/pages/loco_timeline/event_editor.dart +++ b/lib/components/pages/loco_timeline/event_editor.dart @@ -184,7 +184,9 @@ class _FieldList extends StatelessWidget { value: null, onChanged: (field) { if (field == null) return; - draft.fields.add(_FieldEntry(field: field)); + draft.fields.add( + _FieldEntry(field: field)..unit = _defaultUnitForField(field), + ); onChange(); }, items: availableFields @@ -224,10 +226,10 @@ class _FieldList extends StatelessWidget { ), const SizedBox(height: 4), _FieldInput( - field: field.field, - value: field.value, - onChanged: (val) { + entry: field, + onChanged: (val, {String? unit}) { field.value = val; + if (unit != null) field.unit = unit; onChange(); }, ), @@ -253,17 +255,18 @@ class _FieldList extends StatelessWidget { class _FieldInput extends StatelessWidget { const _FieldInput({ - required this.field, - required this.value, + required this.entry, required this.onChanged, }); - final EventField field; - final dynamic value; - final ValueChanged onChanged; + final _FieldEntry entry; + final void Function(dynamic value, {String? unit}) onChanged; @override Widget build(BuildContext context) { + final field = entry.field; + final value = entry.value; + if (field.enumValues != null && field.enumValues!.isNotEmpty) { final options = field.enumValues!; return DropdownButtonFormField( @@ -293,6 +296,119 @@ class _FieldInput extends StatelessWidget { ); } + final name = field.name.toLowerCase(); + if (name == 'max_speed') { + final unit = entry.unit ?? 'kph'; + final isNumber = true; + return Row( + children: [ + Expanded( + child: TextFormField( + initialValue: value?.toString(), + onChanged: (val) { + final parsed = double.tryParse(val); + onChanged(isNumber ? parsed : val, unit: unit); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter value', + suffixText: 'kph/mph', + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 88, + child: DropdownButtonFormField( + value: unit, + items: const [ + DropdownMenuItem(value: 'kph', child: Text('kph')), + DropdownMenuItem(value: 'mph', child: Text('mph')), + ], + onChanged: (val) { + if (val == null) return; + onChanged(value, unit: val); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Unit', + ), + ), + ), + ], + ); + } + + if ({ + 'height', + 'length', + 'width', + 'track_gauge', + }.contains(name)) { + return TextFormField( + initialValue: value?.toString(), + onChanged: (val) { + final parsed = double.tryParse(val); + onChanged(parsed ?? val); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter value', + suffixText: 'mm', + ), + keyboardType: TextInputType.number, + ); + } + + if (name == 'weight') { + return TextFormField( + initialValue: value?.toString(), + onChanged: (val) { + final parsed = double.tryParse(val); + onChanged(parsed ?? val); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter value', + suffixText: 'tonnes', + ), + keyboardType: TextInputType.number, + ); + } + + if (name == 'power') { + return TextFormField( + initialValue: value?.toString(), + onChanged: (val) { + final parsed = double.tryParse(val); + onChanged(parsed ?? val); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter value', + suffixText: 'kW', + ), + keyboardType: TextInputType.number, + ); + } + + if (name == 'tractive_effort') { + return TextFormField( + initialValue: value?.toString(), + onChanged: (val) { + final parsed = double.tryParse(val); + onChanged(parsed ?? val); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter value', + suffixText: 'kN', + ), + keyboardType: TextInputType.number, + ); + } + final isNumber = type == 'int' || type == 'integer'; return TextFormField( initialValue: value?.toString(), @@ -326,6 +442,13 @@ class _EventDraft { class _FieldEntry { final EventField field; dynamic value; + String? unit; _FieldEntry({required this.field}); } + +String? _defaultUnitForField(EventField field) { + final name = field.name.toLowerCase(); + if (name == 'max_speed') return 'kph'; + return null; +} diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart index 8e54f46..8d44b31 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -577,7 +577,7 @@ class _TimelineModel { _ValueSegment( start: start, end: end, - value: entry.valueLabel, + value: _formatValueWithUnits(entry), entry: entry, ), ); @@ -680,6 +680,53 @@ class _TimelineModel { } } +String _formatValueWithUnits(LocoAttrVersion entry) { + final raw = entry.valueLabel; + final code = entry.attrCode.toLowerCase(); + final lowerRaw = raw.toLowerCase(); + // Avoid double-appending if units already present. + final hasUnits = lowerRaw.contains('mm') || + lowerRaw.contains('tonne') || + lowerRaw.contains('kph') || + lowerRaw.contains('mph'); + + double? asNumber = double.tryParse(raw); + String formatNumber(double value) { + if (value % 1 == 0) return value.toStringAsFixed(0); + return value.toStringAsFixed(2); + } + + switch (code) { + case 'height': + case 'length': + case 'width': + case 'track_gauge': + if (hasUnits) return raw; + return asNumber != null ? '${formatNumber(asNumber)} mm' : '$raw mm'; + case 'weight': + if (hasUnits) return raw; + return asNumber != null ? '${formatNumber(asNumber)} tonnes' : '$raw tonnes'; + case 'power': + if (hasUnits) return raw; + return asNumber != null ? '${formatNumber(asNumber)} kW' : '$raw kW'; + case 'tractive_effort': + if (hasUnits) return raw; + return asNumber != null ? '${formatNumber(asNumber)} kN' : '$raw kN'; + case 'max_speed': + if (hasUnits) return raw; + if (asNumber != null) { + // Stored as kph. + final formatted = asNumber % 1 == 0 + ? asNumber.toStringAsFixed(0) + : asNumber.toStringAsFixed(1); + return '$formatted kph'; + } + return '$raw kph'; + default: + return raw; + } +} + class _AxisSegment { final DateTime start; final DateTime end; @@ -742,7 +789,15 @@ class _RowCell { color: Colors.transparent, ); } - final displayStart = _formatDate(seg.start) ?? ''; + final entry = seg.entry; + String displayStart = ''; + if (entry != null) { + if ((entry.maskedValidFrom ?? '').trim().isNotEmpty) { + displayStart = entry.maskedValidFrom!.trim(); + } else if (entry.validFrom != null) { + displayStart = _formatDate(entry.validFrom) ?? ''; + } + } return _RowCell( value: seg.value, rangeLabel: displayStart, 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 109b5c5..166cff3 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -128,7 +128,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { ); _lastSubmittedSnapshot = snapshot; _activeDraftId = null; - } catch (e) { + } catch (e, st) { + debugPrint('Leg submit/update failed: $e'); + debugPrintStack(stackTrace: st); if (!mounted) return; messenger?.showSnackBar( SnackBar(content: Text('Failed to submit: $e')), diff --git a/lib/components/pages/new_entry/new_entry_traction_logic.dart b/lib/components/pages/new_entry/new_entry_traction_logic.dart index 4321092..f269350 100644 --- a/lib/components/pages/new_entry/new_entry_traction_logic.dart +++ b/lib/components/pages/new_entry/new_entry_traction_logic.dart @@ -73,6 +73,8 @@ extension _NewEntryTractionLogic on _NewEntryPageState { for (var i = 0; i < _tractionItems.length; i++) { final item = _tractionItems[i]; if (item.isMarker || item.loco == null) continue; + final locoId = item.loco!.id; + if (locoId == 0) continue; int allocPos; if (i > markerIndex) { allocPos = -(i - markerIndex); @@ -80,8 +82,7 @@ extension _NewEntryTractionLogic on _NewEntryPageState { allocPos = (markerIndex - 1) - i; } payload.add({ - "loco_type": item.loco!.type, - "loco_number": item.loco!.number, + "loco_id": locoId, "alloc_pos": allocPos, "alloc_powering": item.powering ? 1 : 0, }); diff --git a/lib/components/pages/traction/traction.dart b/lib/components/pages/traction/traction.dart index 61d7a68..d6ddb05 100644 --- a/lib/components/pages/traction/traction.dart +++ b/lib/components/pages/traction/traction.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 3c7e6c2..36f69ba 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -26,6 +26,13 @@ class _TractionPageState extends State { bool _showAdvancedFilters = false; String? _selectedClass; late Set _selectedKeys; + String? _lastEventFieldsSignature; + Timer? _classStatsDebounce; + bool _showClassStatsPanel = false; + bool _classStatsLoading = false; + String? _classStatsError; + String? _classStatsForClass; + Map? _classStats; final Map _dynamicControllers = {}; final Map _enumSelections = {}; @@ -68,6 +75,7 @@ class _TractionPageState extends State { for (final controller in _dynamicControllers.values) { controller.dispose(); } + _classStatsDebounce?.cancel(); super.dispose(); } @@ -137,6 +145,10 @@ class _TractionPageState extends State { setState(() { _selectedClass = null; _mileageFirst = true; + _showClassStatsPanel = false; + _classStats = null; + _classStatsError = null; + _classStatsForClass = null; }); _refreshTraction(); } @@ -148,6 +160,7 @@ class _TractionPageState extends State { _selectedClass = null; }); } + _refreshClassStatsIfOpen(); } List _activeEventFields(List fields) { @@ -164,6 +177,26 @@ class _TractionPageState extends State { .toList(); } + void _syncControllersForFields(List fields) { + final signature = _eventFieldsSignature(fields); + if (signature == _lastEventFieldsSignature) return; + _lastEventFieldsSignature = signature; + _ensureControllersForFields(fields); + } + + String _eventFieldsSignature(List fields) { + final active = _activeEventFields(fields); + return active + .map( + (field) => [ + field.name, + field.type ?? '', + if (field.enumValues != null) field.enumValues!.join('|'), + ].join('::'), + ) + .join(';'); + } + void _ensureControllersForFields(List fields) { for (final field in fields) { if (field.enumValues != null) { @@ -183,34 +216,34 @@ class _TractionPageState extends State { final traction = data.traction; final classOptions = data.locoClasses; final isMobile = MediaQuery.of(context).size.width < 700; - _ensureControllersForFields(data.eventFields); + _syncControllersForFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields); - final listView = RefreshIndicator( - onRefresh: _refreshTraction, - child: ListView( - padding: const EdgeInsets.all(16), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Fleet', - style: Theme.of(context).textTheme.labelMedium, + final slivers = [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Fleet', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 2), + Text( + 'Traction', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], ), - const SizedBox(height: 2), - Text( - 'Traction', - style: Theme.of(context).textTheme.headlineSmall, - ), - ], - ), - ), + ), Row( mainAxisSize: MainAxisSize.min, children: [ @@ -219,295 +252,277 @@ class _TractionPageState extends State { onPressed: _refreshTraction, icon: const Icon(Icons.refresh), ), + if (_hasClassQuery) ...[ + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: _toggleClassStatsPanel, + icon: Icon( + _showClassStatsPanel ? Icons.bar_chart : Icons.insights, + ), + label: Text( + _showClassStatsPanel ? 'Hide class stats' : 'Class stats', + ), + ), + ], const SizedBox(width: 8), FilledButton.icon( onPressed: () async { final createdClass = await context.push( '/traction/new', - ); - if (createdClass != null && createdClass.isNotEmpty) { - _classController.text = createdClass; - _selectedClass = createdClass; - if (mounted) { - _refreshTraction(); - } - } else if (mounted && createdClass == '') { - _refreshTraction(); - } - }, - icon: const Icon(Icons.add), - label: const Text('New Traction'), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Filters', - style: Theme.of(context).textTheme.titleMedium, - ), - TextButton( - onPressed: _clearFilters, - child: const Text('Clear'), + ); + 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'), ), ], ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 12, + ], + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - width: isMobile ? double.infinity : 240, - child: RawAutocomplete( - textEditingController: _classController, - focusNode: _classFocusNode, - optionsBuilder: (TextEditingValue textEditingValue) { - final query = textEditingValue.text.toLowerCase(); - if (query.isEmpty) { - return classOptions; - } - return classOptions.where( - (c) => c.toLowerCase().contains(query), - ); - }, - fieldViewBuilder: - ( - context, - controller, - focusNode, - onFieldSubmitted, - ) { - return TextField( - controller: controller, - focusNode: focusNode, - decoration: const InputDecoration( - labelText: 'Class', - border: OutlineInputBorder(), - ), - onSubmitted: (_) => _refreshTraction(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Filters', + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton( + onPressed: _clearFilters, + child: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: isMobile ? double.infinity : 240, + child: RawAutocomplete( + textEditingController: _classController, + focusNode: _classFocusNode, + optionsBuilder: (TextEditingValue textEditingValue) { + final query = textEditingValue.text.toLowerCase(); + if (query.isEmpty) { + return classOptions; + } + return classOptions.where( + (c) => c.toLowerCase().contains(query), ); }, - optionsViewBuilder: (context, onSelected, options) { - final optionList = options.toList(); - if (optionList.isEmpty) { - return const SizedBox.shrink(); - } - final maxWidth = isMobile - ? MediaQuery.of(context).size.width - 64 - : 240.0; - return Align( - alignment: Alignment.topLeft, - child: Material( - elevation: 4, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxWidth, - maxHeight: 240, + fieldViewBuilder: + ( + context, + controller, + focusNode, + onFieldSubmitted, + ) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration( + labelText: 'Class', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + final optionList = options.toList(); + if (optionList.isEmpty) { + return const SizedBox.shrink(); + } + final maxWidth = isMobile + ? MediaQuery.of(context).size.width - 64 + : 240.0; + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: 240, + ), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: optionList.length, + itemBuilder: (context, index) { + final option = optionList[index]; + return ListTile( + title: Text(option), + onTap: () => onSelected(option), + ); + }, + ), + ), ), - child: ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: optionList.length, - itemBuilder: (context, index) { - final option = optionList[index]; - return ListTile( - title: Text(option), - onTap: () => onSelected(option), - ); - }, - ), - ), - ), - ); - }, + ); + }, onSelected: (String selection) { setState(() { _selectedClass = selection; _classController.text = selection; }); _refreshTraction(); + _refreshClassStatsIfOpen(immediate: true); }, ), ), - SizedBox( - width: isMobile ? double.infinity : 220, - child: TextField( - controller: _numberController, - decoration: const InputDecoration( - labelText: 'Number', - border: OutlineInputBorder(), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _numberController, + decoration: const InputDecoration( + labelText: 'Number', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), ), - onSubmitted: (_) => _refreshTraction(), - ), - ), - SizedBox( - width: isMobile ? double.infinity : 220, - child: TextField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Name', - border: OutlineInputBorder(), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), ), - onSubmitted: (_) => _refreshTraction(), - ), + FilterChip( + label: Text( + _mileageFirst ? 'Mileage first' : 'Number order', + ), + selected: _mileageFirst, + onSelected: (v) { + setState(() => _mileageFirst = v); + _refreshTraction(); + }, + ), + TextButton.icon( + onPressed: () => setState( + () => _showAdvancedFilters = !_showAdvancedFilters, + ), + icon: Icon( + _showAdvancedFilters + ? Icons.expand_less + : Icons.expand_more, + ), + label: Text( + _showAdvancedFilters + ? 'Hide filters' + : 'More filters', + ), + ), + ElevatedButton.icon( + onPressed: _refreshTraction, + icon: const Icon(Icons.search), + label: const Text('Search'), + ), + ], ), - FilterChip( - label: Text( - _mileageFirst ? 'Mileage first' : 'Number order', + AnimatedCrossFade( + crossFadeState: _showAdvancedFilters + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: data.isEventFieldsLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ) + : extraFields.isEmpty + ? const Text('No extra filters available right now.') + : Wrap( + spacing: 12, + runSpacing: 12, + children: extraFields + .map( + (field) => _buildFilterInput( + context, + field, + isMobile, + ), + ) + .toList(), + ), ), - selected: _mileageFirst, - onSelected: (v) { - setState(() => _mileageFirst = v); - _refreshTraction(); - }, - ), - TextButton.icon( - onPressed: () => setState( - () => _showAdvancedFilters = !_showAdvancedFilters, - ), - icon: Icon( - _showAdvancedFilters - ? Icons.expand_less - : Icons.expand_more, - ), - label: Text( - _showAdvancedFilters - ? 'Hide filters' - : 'More filters', - ), - ), - ElevatedButton.icon( - onPressed: _refreshTraction, - icon: const Icon(Icons.search), - label: const Text('Search'), + secondChild: const SizedBox.shrink(), ), ], ), - AnimatedCrossFade( - crossFadeState: _showAdvancedFilters - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 200), - firstChild: Padding( - padding: const EdgeInsets.only(top: 12.0), - child: data.isEventFieldsLoading - ? const Center( - child: Padding( - padding: EdgeInsets.all(8.0), - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ) - : extraFields.isEmpty - ? const Text('No extra filters available right now.') - : Wrap( - spacing: 12, - runSpacing: 12, - children: extraFields - .map( - (field) => _buildFilterInput( - context, - field, - isMobile, - ), - ) - .toList(), - ), - ), - secondChild: const SizedBox.shrink(), - ), - ], + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + sliver: SliverToBoxAdapter( + child: AnimatedCrossFade( + crossFadeState: (_showClassStatsPanel && _hasClassQuery) + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: _buildClassStatsCard(context), + secondChild: const SizedBox.shrink(), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + sliver: _buildTractionSliver(context, data, traction), + ), + ]; + + final scrollView = RefreshIndicator( + onRefresh: _refreshTraction, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: slivers, + ), + ); + + final content = Stack( + children: [ + scrollView, + if (data.isTractionLoading) + Positioned.fill( + child: IgnorePointer( + child: Container( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + child: const Center(child: CircularProgressIndicator()), ), ), ), - const SizedBox(height: 12), - Stack( - children: [ - if (data.isTractionLoading && traction.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), - child: Center(child: CircularProgressIndicator()), - ) - else if (traction.isEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'No traction found', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - const Text('Try relaxing the filters or sync again.'), - ], - ), - ), - ) - else - Column( - children: [ - ...traction.map( - (loco) => TractionCard( - loco: loco, - selectionMode: widget.selectionMode, - isSelected: _isSelected(loco), - onShowInfo: () => showTractionDetails(context, loco), - onOpenTimeline: () => _openTimeline(loco), - onOpenLegs: () => _openLegs(loco), - onToggleSelect: - widget.selectionMode ? () => _toggleSelection(loco) : null, - ), - ), - if (data.tractionHasMore || data.isTractionLoading) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: OutlinedButton.icon( - onPressed: data.isTractionLoading - ? null - : () => _refreshTraction(append: true), - icon: data.isTractionLoading - ? const SizedBox( - height: 14, - width: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.expand_more), - label: Text( - data.isTractionLoading ? 'Loading...' : 'Load more', - ), - ), - ), - ], - ), - if (data.isTractionLoading) - Positioned.fill( - child: IgnorePointer( - child: Container( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), - child: const Center(child: CircularProgressIndicator()), - ), - ), - ), - ], - ), - ], - ), + ], ); if (widget.selectionMode) { @@ -520,11 +535,323 @@ class _TractionPageState extends State { ), title: null, ), - body: listView, + body: content, ); } - return listView; + return content; + } + + bool get _hasClassQuery { + return (_selectedClass ?? _classController.text).trim().isNotEmpty; + } + + Future _toggleClassStatsPanel() async { + if (!_hasClassQuery) return; + final targetState = !_showClassStatsPanel; + setState(() { + _showClassStatsPanel = targetState; + }); + if (targetState) { + await _loadClassStats(); + } + } + + void _refreshClassStatsIfOpen({bool immediate = false}) { + if (!_showClassStatsPanel || !_hasClassQuery) return; + final query = (_selectedClass ?? _classController.text).trim(); + if (!immediate && _classStatsForClass == query && _classStats != null) { + return; + } + _classStatsDebounce?.cancel(); + if (immediate) { + _loadClassStats(); + return; + } + _classStatsDebounce = Timer( + const Duration(milliseconds: 400), + () { + if (mounted) _loadClassStats(); + }, + ); + } + + Future _loadClassStats() async { + final query = (_selectedClass ?? _classController.text).trim(); + if (query.isEmpty) return; + if (_classStatsForClass == query && _classStats != null) return; + setState(() { + _classStatsLoading = true; + _classStatsError = null; + }); + try { + final data = context.read(); + final stats = await data.fetchClassStats(query); + if (!mounted) return; + setState(() { + _classStatsForClass = query; + _classStats = stats; + _classStatsError = stats == null ? 'No stats returned.' : null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _classStatsError = 'Failed to load stats: $e'; + }); + } finally { + if (mounted) { + setState(() => _classStatsLoading = false); + } + } + } + + Widget _buildClassStatsCard(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + if (_classStatsLoading) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: const [ + SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Loading class stats...'), + ], + ), + ), + ); + } + + if (_classStatsError != null) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + _classStatsError!, + style: TextStyle(color: scheme.error), + ), + ), + ); + } + + final stats = _classStats; + if (stats == null) { + return const SizedBox.shrink(); + } + + final totalMileage = + (stats['total_mileage_with_class'] as num?)?.toDouble() ?? 0.0; + final avgMileagePerEntry = + (stats['avg_mileage_per_entry'] as num?)?.toDouble() ?? 0.0; + final avgMileagePerLoco = + (stats['avg_mileage_per_loco_had'] as num?)?.toDouble() ?? 0.0; + final hadCount = stats['had_count']?.toString() ?? '0'; + final entriesWithClass = stats['entries_with_class']?.toString() ?? '0'; + + final classStats = stats['class_stats'] is Map + ? Map.from(stats['class_stats']) + : const {}; + final totalCount = (classStats['total'] as num?)?.toInt() ?? + _sumCounts(classStats['status']) ?? + 0; + final statusList = _normalizeStatList(classStats['status'], 'status'); + final domainList = _normalizeStatList(classStats['domain'], 'domain'); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + stats['loco_class']?.toString() ?? 'Class stats', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton.icon( + onPressed: _loadClassStats, + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _metricTile('Had', hadCount), + _metricTile('Entries', entriesWithClass), + _metricTile('Avg mi / loco had', avgMileagePerLoco.toStringAsFixed(2)), + _metricTile('Avg mi / entry', avgMileagePerEntry.toStringAsFixed(2)), + _metricTile('Total mileage', totalMileage.toStringAsFixed(2)), + ], + ), + const SizedBox(height: 12), + if (statusList.isNotEmpty) + _statBar( + context, + title: 'By status', + items: statusList, + total: totalCount, + colorFor: (label) => _statusColor(label, scheme), + ), + if (domainList.isNotEmpty) ...[ + const SizedBox(height: 10), + _statBar( + context, + title: 'By domain', + items: domainList, + total: totalCount, + colorFor: (label) => _domainColor(label, scheme), + ), + ], + ], + ), + ), + ); + } + + Widget _metricTile(String label, String value) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 12)), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } + + Widget _statBar( + BuildContext context, { + required String title, + required List> items, + required int total, + required Color Function(String) colorFor, + }) { + if (total <= 0) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + children: items.map((item) { + final label = item['label']?.toString() ?? ''; + final count = (item['count'] as num?)?.toInt() ?? 0; + final pct = total == 0 ? 0.0 : (count / total) * 100; + final flex = count == 0 ? 1 : (count * 1000 / total).round(); + return Expanded( + flex: flex, + child: Tooltip( + message: + '$label: $count (${pct.isNaN ? 0 : pct.toStringAsFixed(1)}%)', + child: Container( + height: 16, + color: colorFor(label), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 12, + runSpacing: 6, + children: items.map((item) { + final label = item['label']?.toString() ?? ''; + final count = (item['count'] as num?)?.toInt() ?? 0; + final pct = total == 0 ? 0.0 : (count / total) * 100; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: colorFor(label), + borderRadius: BorderRadius.circular(2), + ), + ), + Text('$label (${pct.isNaN ? 0 : pct.toStringAsFixed(1)}%, $count)'), + ], + ); + }).toList(), + ), + ], + ); + } + + List> _normalizeStatList(dynamic list, String labelKey) { + if (list is! List) return const []; + return list + .whereType() + .map((item) => { + 'label': item[labelKey]?.toString() ?? '', + 'count': (item['count'] as num?)?.toInt() ?? 0, + }) + .where((item) => (item['label'] ?? '').toString().isNotEmpty) + .toList(); + } + + int? _sumCounts(dynamic list) { + if (list is! List) return null; + int total = 0; + for (final item in list) { + final count = (item is Map ? item['count'] : null) as num?; + if (count != null) total += count.toInt(); + } + return total; + } + + Color _statusColor(String status, ColorScheme scheme) { + final key = status.toLowerCase(); + if (key.contains('scrap')) return Colors.red.shade600; + if (key.contains('active')) return scheme.primary; + if (key.contains('overhaul')) return Colors.blueGrey; + if (key.contains('withdrawn')) return Colors.amber.shade700; + if (key.contains('stored')) return Colors.grey.shade600; + return scheme.tertiary; + } + + Color _domainColor(String domain, ColorScheme scheme) { + final palette = [ + scheme.primary, + scheme.secondary, + scheme.tertiary, + Colors.teal, + Colors.indigo, + Colors.orange, + Colors.pink, + Colors.brown, + ]; + if (domain.isEmpty) return scheme.surfaceContainerHighest; + final index = domain.hashCode.abs() % palette.length; + return palette[index]; } void _toggleSelection(LocoSummary loco) { @@ -627,4 +954,84 @@ class _TractionPageState extends State { ), ); } + + Widget _buildTractionSliver( + BuildContext context, + DataService data, + List traction, + ) { + if (data.isTractionLoading && traction.isEmpty) { + return const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + if (traction.isEmpty) { + return SliverToBoxAdapter( + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No traction found', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + const Text('Try relaxing the filters or sync again.'), + ], + ), + ), + ), + ); + } + + final itemCount = + traction.length + ((data.tractionHasMore || data.isTractionLoading) ? 1 : 0); + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index < traction.length) { + final loco = traction[index]; + return TractionCard( + loco: loco, + selectionMode: widget.selectionMode, + isSelected: _isSelected(loco), + onShowInfo: () => showTractionDetails(context, loco), + onOpenTimeline: () => _openTimeline(loco), + onOpenLegs: () => _openLegs(loco), + onToggleSelect: + widget.selectionMode ? () => _toggleSelection(loco) : null, + ); + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: OutlinedButton.icon( + onPressed: + data.isTractionLoading ? null : () => _refreshTraction(append: true), + icon: data.isTractionLoading + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text( + data.isTractionLoading ? 'Loading...' : 'Load more', + ), + ), + ); + }, + childCount: itemCount, + ), + ); + } } diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 4079657..f4df126 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -48,6 +48,9 @@ class DataService extends ChangeNotifier { List get latestLocoChanges => _latestLocoChanges; bool _isLatestLocoChangesLoading = false; bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading; + bool _latestLocoChangesHasMore = false; + bool get latestLocoChangesHasMore => _latestLocoChangesHasMore; + int _latestLocoChangesFetched = 0; final Map> _locoTimelines = {}; final Map _isLocoTimelineLoading = {}; List timelineForLoco(int locoId) => diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart index 5d73f63..6b2bf8d 100644 --- a/lib/services/data_service/data_service_traction.dart +++ b/lib/services/data_service/data_service_traction.dart @@ -115,7 +115,11 @@ extension DataServiceTraction on DataService { return _locoClasses; } - Future fetchLatestLocoChanges({int limit = 25, int offset = 0}) async { + Future fetchLatestLocoChanges({ + int limit = 100, + int offset = 0, + bool append = false, + }) async { _isLatestLocoChangesLoading = true; _notifyAsync(); try { @@ -138,16 +142,41 @@ extension DataServiceTraction on DataService { ); } } - _latestLocoChanges = parsed; + if (append) { + _latestLocoChanges = [..._latestLocoChanges, ...parsed]; + } else { + _latestLocoChanges = parsed; + } + final fetchedCount = parsed.length; + _latestLocoChangesFetched = append + ? offset + fetchedCount + : fetchedCount; + _latestLocoChangesHasMore = _latestLocoChangesFetched < 5000; } else { throw Exception('Unexpected latest loco changes response: $json'); } } catch (e) { debugPrint('Failed to fetch latest loco changes: $e'); _latestLocoChanges = []; + _latestLocoChangesHasMore = false; + _latestLocoChangesFetched = 0; } finally { _isLatestLocoChangesLoading = false; _notifyAsync(); } } + + Future?> fetchClassStats(String locoClass) async { + try { + final path = Uri.encodeComponent(locoClass); + final json = await api.get('/loco/class/stats/$path/user'); + if (json is Map) { + return Map.from(json); + } + debugPrint('Unexpected class stats response for $locoClass: $json'); + } catch (e) { + debugPrint('Failed to fetch class stats for $locoClass: $e'); + } + return null; + } } diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index 6034bfa..b0df8cb 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -1,4 +1,5 @@ import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -189,6 +190,14 @@ class _MyAppState extends State { } } +class _BackIntent extends Intent { + const _BackIntent(); +} + +class _ForwardIntent extends Intent { + const _ForwardIntent(); +} + class MyHomePage extends StatefulWidget { final Widget child; const MyHomePage({super.key, required this.child}); @@ -206,13 +215,14 @@ class _MyHomePageState extends State { } await NavigationGuard.attemptNavigation(() async { if (!mounted) return; - context.go(contentPages[index]); + _navigateToIndex(index); }); } - int? _lastTabIndex; - final List _tabHistory = []; - bool _handlingBackNavigation = false; + final List _history = []; + int _historyPosition = -1; + final List _forwardHistory = []; + bool _suppressRecord = false; bool _fetched = false; @@ -256,7 +266,7 @@ class _MyHomePageState extends State { Widget build(BuildContext context) { final uri = GoRouterState.of(context).uri; final pageIndex = tabIndexForPath(uri.path); - _recordTabChange(pageIndex); + _syncHistory(pageIndex); if (pageIndex != _addTabIndex) { NavigationGuard.unregister(); } @@ -269,136 +279,227 @@ class _MyHomePageState extends State { ? widget.child : const Center(child: CircularProgressIndicator()); - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) async { - if (didPop) return; + final scaffold = 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(); - final shellNav = _shellNavigatorKey.currentState; - if (shellNav != null && shellNav.canPop()) { - shellNav.pop(); - return; - } - - if (_tabHistory.isNotEmpty) { - final previousTab = _tabHistory.removeLast(); - if (!mounted) return; - _handlingBackNavigation = true; - context.go(contentPages[previousTab]); - return; - } - - if (pageIndex != 0) { - if (!mounted) return; - _handlingBackNavigation = true; - context.go(contentPages[0]); - return; - } - - SystemNavigator.pop(); - }, - 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", - ), + 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( - tooltip: 'Settings', - onPressed: () => context.go('/settings'), - icon: const Icon(Icons.settings), - ), - IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), - ], ), - 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, - ), + actions: [ + const IconButton( + onPressed: null, + icon: Icon(Icons.account_circle), + ), + IconButton( + tooltip: 'Settings', + onPressed: () => context.go('/settings'), + icon: const Icon(Icons.settings), + ), + IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), + ], + ), + 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, - ); + ), + const VerticalDivider(width: 1), + Expanded(child: currentPage), + ], + ) + : currentPage, + ); + }, + ); + + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(), + LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(), + }, + child: Actions( + actions: { + _BackIntent: CallbackAction<_BackIntent>( + onInvoke: (_) { + _handleBackNavigation(allowExit: false, recordForward: true); + return null; + }, + ), + _ForwardIntent: CallbackAction<_ForwardIntent>( + onInvoke: (_) { + _handleForwardNavigation(); + return null; + }, + ), }, + child: Focus( + autofocus: true, + child: Listener( + onPointerDown: _handlePointerButtons, + behavior: HitTestBehavior.opaque, + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + await _handleBackNavigation(allowExit: true, recordForward: false); + }, + child: scaffold, + ), + ), + ), ), ); } - void _recordTabChange(int pageIndex) { - final last = _lastTabIndex; - if (last == null) { - _lastTabIndex = pageIndex; - return; + void _handlePointerButtons(PointerDownEvent event) { + // Support mouse back/forward buttons. + if (event.buttons == kBackMouseButton) { + _handleBackNavigation(allowExit: false, recordForward: true); + } else if (event.buttons == kForwardMouseButton) { + _handleForwardNavigation(); } - if (last == pageIndex) return; + } - if (_handlingBackNavigation) { - _handlingBackNavigation = false; - _lastTabIndex = pageIndex; - return; + int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path); + + Future _handleBackNavigation({ + bool allowExit = false, + bool recordForward = false, + }) async { + final pageIndex = _currentPageIndex; + final shellNav = _shellNavigatorKey.currentState; + if (shellNav != null && shellNav.canPop()) { + shellNav.pop(); + return true; } - if (_tabHistory.isEmpty || _tabHistory.last != last) { - _tabHistory.add(last); + if (_historyPosition > 0) { + if (recordForward) _pushForward(pageIndex); + _historyPosition -= 1; + _suppressRecord = true; + context.go(contentPages[_history[_historyPosition]]); + return true; } - _lastTabIndex = pageIndex; + + if (pageIndex != 0) { + if (recordForward) _pushForward(pageIndex); + _suppressRecord = true; + context.go(contentPages[0]); + return true; + } + + if (allowExit) { + SystemNavigator.pop(); + return true; + } + + return false; + } + + Future _handleForwardNavigation() async { + if (_forwardHistory.isEmpty) return false; + final nextTab = _forwardHistory.removeLast(); + + // Move cursor forward, keeping history in sync. + if (_historyPosition < _history.length - 1) { + _historyPosition += 1; + _history[_historyPosition] = nextTab; + if (_historyPosition < _history.length - 1) { + _history.removeRange(_historyPosition + 1, _history.length); + } + } else { + _history.add(nextTab); + _historyPosition = _history.length - 1; + } + + _suppressRecord = true; + if (!mounted) return false; + context.go(contentPages[nextTab]); + return true; + } + + void _pushForward(int pageIndex) { + if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) { + _forwardHistory.add(pageIndex); + } + } + + void _syncHistory(int pageIndex) { + if (_history.isEmpty) { + _history.add(pageIndex); + _historyPosition = 0; + return; + } + if (_suppressRecord) { + _suppressRecord = false; + return; + } + if (_historyPosition >= 0 && + _historyPosition < _history.length && + _history[_historyPosition] == pageIndex) { + return; + } + if (_historyPosition < _history.length - 1) { + _history.removeRange(_historyPosition + 1, _history.length); + } + _history.add(pageIndex); + _historyPosition = _history.length - 1; + _forwardHistory.clear(); + } + + void _navigateToIndex(int index) { + _suppressRecord = false; + _forwardHistory.clear(); + context.go(contentPages[index]); } } diff --git a/pubspec.yaml b/pubspec.yaml index 92f8787..5765666 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.3.3+1 +version: 0.3.4+1 environment: sdk: ^3.8.1