From 5c0043146fe55fa70b32c3a47f49a45e2d736ae6 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Mon, 12 Jan 2026 15:30:29 +0000 Subject: [PATCH] minor page tweaks --- lib/components/calculator/calculator.dart | 231 ++++++++++++++---- lib/components/pages/loco_timeline.dart | 92 ++++++- .../pages/loco_timeline/timeline_grid.dart | 38 +-- .../pages/traction/traction_page.dart | 28 ++- lib/ui/app_shell.dart | 40 +-- 5 files changed, 337 insertions(+), 92 deletions(-) diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index ed3de74..2fb0a0b 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -133,6 +133,7 @@ class _RouteCalculatorState extends State { bool _loadingStations = false; RouteResult? _routeResult; + List? _calculatedStations; RouteResult? get result => _routeResult; String? _errorMessage; @@ -178,6 +179,7 @@ class _RouteCalculatorState extends State { if (cleaned.length < 2) { setState(() { _routeResult = null; + _calculatedStations = null; _errorMessage = 'Add at least two stations before calculating.'; }); return; @@ -185,6 +187,7 @@ class _RouteCalculatorState extends State { setState(() { _errorMessage = null; _routeResult = null; + _calculatedStations = null; }); final api = context.read(); // context is valid here try { @@ -195,6 +198,7 @@ class _RouteCalculatorState extends State { if (res is Map && res['error'] == false) { setState(() { _routeResult = RouteResult.fromJson(Map.from(res)); + _calculatedStations = List.from(cleaned); }); final distance = (_routeResult?.distance ?? 0); widget.onDistanceComputed?.call(distance); @@ -205,17 +209,30 @@ class _RouteCalculatorState extends State { ).msg; }); } else { - setState(() => _errorMessage = 'Failed to calculate route.'); + setState(() { + _errorMessage = 'Failed to calculate route.'; + _calculatedStations = null; + }); } } catch (e) { - setState(() => _errorMessage = 'Failed to calculate route: $e'); + setState(() { + _errorMessage = 'Failed to calculate route: $e'; + _calculatedStations = null; + }); } } + void _markRouteDirty() { + _routeResult = null; + _calculatedStations = null; + _errorMessage = null; + } + void _addStation() { final data = context.read(); setState(() { data.stations.add(''); + _markRouteDirty(); }); } @@ -223,6 +240,7 @@ class _RouteCalculatorState extends State { final data = context.read(); setState(() { data.stations.removeAt(index); + _markRouteDirty(); }); } @@ -230,6 +248,7 @@ class _RouteCalculatorState extends State { final data = context.read(); setState(() { data.stations[index] = value; + _markRouteDirty(); }); } @@ -237,14 +256,91 @@ class _RouteCalculatorState extends State { final data = context.read(); setState(() { data.stations = ['']; - _routeResult = null; - _errorMessage = null; + _markRouteDirty(); }); } + bool _isResultCurrent(List stations) { + if (_routeResult == null || _calculatedStations == null) return false; + final cleaned = stations.where((s) => s.trim().isNotEmpty).toList(); + if (cleaned.length != _calculatedStations!.length) return false; + for (var i = 0; i < cleaned.length; i++) { + if (cleaned[i] != _calculatedStations![i]) return false; + } + return true; + } + @override Widget build(BuildContext context) { final data = context.watch(); + final isCompact = MediaQuery.of(context).size.width < 600; + final showApply = + widget.onApplyRoute != null && _isResultCurrent(data.stations); + final primaryPadding = EdgeInsets.symmetric( + horizontal: isCompact ? 14 : 20, + vertical: isCompact ? 10 : 14, + ); + final secondaryPadding = EdgeInsets.symmetric( + horizontal: isCompact ? 10 : 16, + vertical: isCompact ? 8 : 12, + ); + final primaryStyle = FilledButton.styleFrom( + padding: primaryPadding, + minimumSize: Size(0, isCompact ? 38 : 46), + ); + final secondaryStyle = OutlinedButton.styleFrom( + padding: secondaryPadding, + minimumSize: Size(0, isCompact ? 34 : 42), + ); + + Widget buildSecondaryButton({ + required IconData icon, + required String label, + required VoidCallback onPressed, + }) { + if (isCompact) { + return Tooltip( + message: label, + child: OutlinedButton( + onPressed: onPressed, + style: secondaryStyle, + child: Icon(icon, size: 20), + ), + ); + } + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 20), + label: Text(label), + style: secondaryStyle, + ); + } + + Widget buildPrimaryAction({required bool fullWidth}) { + final button = showApply + ? FilledButton.icon( + onPressed: () => widget.onApplyRoute!(_routeResult!), + icon: const Icon(Icons.check), + label: const Text('Apply to entry'), + style: primaryStyle, + ) + : FilledButton.icon( + onPressed: () async { + await _calculateRoute(data.stations); + }, + icon: const Icon(Icons.route), + label: const Text('Calculate Route'), + style: primaryStyle, + ); + final key = + ValueKey(showApply ? 'apply-primary-action' : 'calc-primary-action'); + if (!fullWidth) return KeyedSubtree(key: key, child: button); + return KeyedSubtree( + key: key, + child: SizedBox(width: double.infinity, child: button), + ); + } + return Column( children: [ Align( @@ -301,6 +397,7 @@ class _RouteCalculatorState extends State { setState(() { final moved = data.stations.removeAt(oldIndex); data.stations.insert(newIndex, moved); + _markRouteDirty(); }); }, children: List.generate(data.stations.length, (index) { @@ -364,56 +461,94 @@ class _RouteCalculatorState extends State { context.push('/calculator/details', extra: result); }, ), - if (widget.onApplyRoute != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ElevatedButton.icon( - onPressed: () => widget.onApplyRoute!(_routeResult!), - icon: const Icon(Icons.check), - label: const Text('Apply to entry'), - ), - ), ] else SizedBox.shrink(), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 12, - runSpacing: 8, - children: [ - ...(() { - final reverseButton = ElevatedButton.icon( - icon: const Icon(Icons.swap_horiz), - label: const Text('Reverse route'), - onPressed: () async { - setState(() { - data.stations = data.stations.reversed.toList(); - }); - await _calculateRoute(data.stations); - }, - ); - final addButton = ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text('Add Station'), - onPressed: _addStation, - ); - final calculateButton = ElevatedButton.icon( - icon: const Icon(Icons.route), - label: const Text('Calculate Route'), - onPressed: () async { - await _calculateRoute(data.stations); - }, - ); - final isMobile = MediaQuery.of(context).size.width < 600; - return isMobile - ? [addButton, reverseButton, calculateButton] - : [reverseButton, addButton, calculateButton]; - })(), - ], - ), + child: isCompact + ? Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + buildSecondaryButton( + icon: Icons.swap_horiz, + label: 'Reverse route', + onPressed: () async { + setState(() { + data.stations = data.stations.reversed.toList(); + }); + await _calculateRoute(data.stations); + }, + ), + const SizedBox(width: 12), + buildSecondaryButton( + icon: Icons.add, + label: 'Add station', + onPressed: _addStation, + ), + ], + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + transitionBuilder: (child, animation) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutBack, + ); + return ScaleTransition( + scale: + Tween(begin: 0.94, end: 1.0).animate(curved), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: buildPrimaryAction(fullWidth: true), + ), + ), + ], + ) + : Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + buildSecondaryButton( + icon: Icons.swap_horiz, + label: 'Reverse route', + onPressed: () async { + setState(() { + data.stations = data.stations.reversed.toList(); + }); + await _calculateRoute(data.stations); + }, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + transitionBuilder: (child, animation) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutBack, + ); + return ScaleTransition( + scale: + Tween(begin: 0.94, end: 1.0).animate(curved), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: buildPrimaryAction(fullWidth: false), + ), + buildSecondaryButton( + icon: Icons.add, + label: 'Add station', + onPressed: _addStation, + ), + ], + ), ), const SizedBox(height: 16), diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index f3be05a..39b3ad2 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -8,6 +8,7 @@ import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'loco_timeline/timeline_grid.dart'; part 'loco_timeline/event_editor.dart'; @@ -27,15 +28,22 @@ class LocoTimelinePage extends StatefulWidget { } class _LocoTimelinePageState extends State { + static const String _prefsKeyShowPending = 'timeline_show_pending'; + final List<_EventDraft> _draftEvents = []; bool _isSaving = false; bool _isDeleting = false; - bool _isModerating = false; + final Set _moderatingEventIds = {}; + bool _showPending = true; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _restorePendingVisibility(); + if (!mounted) return; + await _load(); + }); } dynamic _normalizeFieldValue(_FieldEntry field) { @@ -65,10 +73,35 @@ class _LocoTimelinePageState extends State { data.fetchEventFields(); return data.fetchLocoTimeline( widget.locoId, - includeAllPending: auth.isElevated, + includeAllPending: auth.isElevated && _showPending, ); } + Future _restorePendingVisibility() async { + final auth = context.read(); + if (!auth.isElevated) return; + try { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getBool(_prefsKeyShowPending); + if (saved == null) return; + if (!mounted) return; + setState(() { + _showPending = saved; + }); + } catch (_) { + // Ignore preference restore failures. + } + } + + Future _persistPendingVisibility(bool value) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyShowPending, value); + } catch (_) { + // Ignore persistence failures. + } + } + void _addDraftEvent() { setState(() { _draftEvents.add(_EventDraft()); @@ -247,7 +280,6 @@ class _LocoTimelinePageState extends State { LocoAttrVersion entry, _PendingModerationAction action, ) async { - if (_isModerating) return; final eventId = entry.sourceEventId; if (eventId == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -257,6 +289,7 @@ class _LocoTimelinePageState extends State { ); return; } + if (_moderatingEventIds.contains(eventId)) return; final data = context.read(); final approve = action == _PendingModerationAction.approve; final messenger = ScaffoldMessenger.of(context); @@ -283,7 +316,7 @@ class _LocoTimelinePageState extends State { if (ok != true || !mounted) return; setState(() { - _isModerating = true; + _moderatingEventIds.add(eventId); }); try { if (approve) { @@ -310,7 +343,7 @@ class _LocoTimelinePageState extends State { } finally { if (mounted) { setState(() { - _isModerating = false; + _moderatingEventIds.remove(eventId); }); } } @@ -499,7 +532,11 @@ class _LocoTimelinePageState extends State { Widget build(BuildContext context) { final data = context.watch(); final timeline = data.timelineForLoco(widget.locoId); + final isElevated = context.select((auth) => auth.isElevated); final isLoading = data.isLocoTimelineLoading(widget.locoId); + final visibleTimeline = (!isElevated || _showPending) + ? timeline + : timeline.where((entry) => !entry.isPending).toList(); return Scaffold( appBar: AppBar( @@ -516,7 +553,32 @@ class _LocoTimelinePageState extends State { if (isLoading && timeline.isEmpty) { return const Center(child: CircularProgressIndicator()); } - if (timeline.isEmpty) { + if (visibleTimeline.isEmpty) { + if (timeline.isNotEmpty && isElevated && !_showPending) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pending entries hidden', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + const Text( + 'Enable "Show pending entries" to view pending timeline blocks.', + ), + ], + ), + ), + ), + ); + } return Padding( padding: const EdgeInsets.all(16.0), child: Card( @@ -550,15 +612,27 @@ class _LocoTimelinePageState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ + if (isElevated) + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Show pending entries'), + value: _showPending, + onChanged: (value) { + setState(() { + _showPending = value; + }); + _persistPendingVisibility(value); + }, + ), _TimelineGrid( - entries: timeline, + entries: visibleTimeline, onEditEntry: (entry) => _prefillDraftFromEntry( entry, data.eventFields, ), onDeleteEntry: _deleteEntry, onModeratePending: _moderatePendingEntry, - pendingActionsBusy: _isModerating, + pendingActionEventIds: _moderatingEventIds, ), const SizedBox(height: 16), _EventEditor( diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart index 612ffce..b785748 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -10,7 +10,7 @@ class _TimelineGrid extends StatefulWidget { this.onEditEntry, this.onDeleteEntry, this.onModeratePending, - this.pendingActionsBusy = false, + this.pendingActionEventIds = const {}, }); final List entries; @@ -20,7 +20,7 @@ class _TimelineGrid extends StatefulWidget { LocoAttrVersion entry, _PendingModerationAction action, )? onModeratePending; - final bool pendingActionsBusy; + final Set pendingActionEventIds; @override State<_TimelineGrid> createState() => _TimelineGridState(); @@ -201,7 +201,7 @@ class _TimelineGridState extends State<_TimelineGrid> { onEditEntry: widget.onEditEntry, onDeleteEntry: widget.onDeleteEntry, onModeratePending: widget.onModeratePending, - pendingActionsBusy: widget.pendingActionsBusy, + pendingActionEventIds: widget.pendingActionEventIds, ), ); }, @@ -288,7 +288,7 @@ class _AttrRow extends StatelessWidget { this.onEditEntry, this.onDeleteEntry, this.onModeratePending, - this.pendingActionsBusy = false, + this.pendingActionEventIds = const {}, }); final double rowHeight; @@ -302,7 +302,7 @@ class _AttrRow extends StatelessWidget { LocoAttrVersion entry, _PendingModerationAction action, )? onModeratePending; - final bool pendingActionsBusy; + final Set pendingActionEventIds; @override Widget build(BuildContext context) { @@ -329,7 +329,7 @@ class _AttrRow extends StatelessWidget { onEditEntry: onEditEntry, onDeleteEntry: onDeleteEntry, onModeratePending: onModeratePending, - pendingActionsBusy: pendingActionsBusy, + pendingActionEventIds: pendingActionEventIds, ), ), if (activeBlock != null) @@ -346,7 +346,7 @@ class _AttrRow extends StatelessWidget { width: stickyWidth, ), clipLeftEdge: scrollOffset > activeBlock.left + 0.1, - pendingActionsBusy: pendingActionsBusy, + pendingActionEventIds: pendingActionEventIds, ), ), ), @@ -368,12 +368,12 @@ class _ValueBlockView extends StatelessWidget { const _ValueBlockView({ required this.block, this.clipLeftEdge = false, - this.pendingActionsBusy = false, + this.pendingActionEventIds = const {}, }); final _ValueBlock block; final bool clipLeftEdge; - final bool pendingActionsBusy; + final Set pendingActionEventIds; @override Widget build(BuildContext context) { @@ -384,6 +384,11 @@ class _ValueBlockView extends StatelessWidget { ? Colors.white : Colors.black87; + final entry = block.entry; + final eventId = entry?.sourceEventId; + final isPendingAction = + entry?.isPending == true && eventId != null && pendingActionEventIds.contains(eventId); + final radius = BorderRadius.only( topLeft: Radius.circular(clipLeftEdge ? 0 : 12), bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12), @@ -425,7 +430,7 @@ class _ValueBlockView extends StatelessWidget { child: SizedBox( width: 16, height: 16, - child: pendingActionsBusy + child: isPendingAction ? CircularProgressIndicator( strokeWidth: 2, valueColor: @@ -484,7 +489,7 @@ class _ValueBlockMenu extends StatelessWidget { this.onEditEntry, this.onDeleteEntry, this.onModeratePending, - this.pendingActionsBusy = false, + this.pendingActionEventIds = const {}, }); final _ValueBlock block; @@ -494,7 +499,7 @@ class _ValueBlockMenu extends StatelessWidget { LocoAttrVersion entry, _PendingModerationAction action, )? onModeratePending; - final bool pendingActionsBusy; + final Set pendingActionEventIds; bool get _hasActions { final canModerate = block.entry?.isPending == true && @@ -515,6 +520,9 @@ class _ValueBlockMenu extends StatelessWidget { block.entry?.canModeratePending == true && onModeratePending != null; final canEdit = onEditEntry != null && block.entry?.isPending != true; + final eventId = block.entry?.sourceEventId; + final isPendingAction = + eventId != null && pendingActionEventIds.contains(eventId); Future showContextMenuAt(Offset globalPosition) async { final overlay = Overlay.of(context); @@ -540,13 +548,13 @@ class _ValueBlockMenu extends StatelessWidget { if (canModerate) PopupMenuItem( value: _TimelineBlockAction.approve, - enabled: !pendingActionsBusy, + enabled: !isPendingAction, child: const Text('Approve pending'), ), if (canModerate) PopupMenuItem( value: _TimelineBlockAction.reject, - enabled: !pendingActionsBusy, + enabled: !isPendingAction, child: const Text('Reject pending'), ), if (onDeleteEntry != null) @@ -588,7 +596,7 @@ class _ValueBlockMenu extends StatelessWidget { }, child: _ValueBlockView( block: block, - pendingActionsBusy: pendingActionsBusy, + pendingActionEventIds: pendingActionEventIds, ), ); } diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index d5b75e9..dec21fe 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -714,17 +714,37 @@ class _TractionPageState extends State { final items = >[]; if (hasClassActions) { items.add( - const PopupMenuItem( + PopupMenuItem( value: _TractionMoreAction.classStats, - child: Text('Class stats'), + child: Row( + children: [ + Icon( + _showClassStatsPanel ? Icons.check : Icons.check_box_outline_blank, + size: 18, + ), + const SizedBox(width: 8), + const Text('Class stats'), + ], + ), ), ); } if (hasClassActions) { items.add( - const PopupMenuItem( + PopupMenuItem( value: _TractionMoreAction.classLeaderboard, - child: Text('Class leaderboard'), + child: Row( + children: [ + Icon( + _showClassLeaderboardPanel + ? Icons.check + : Icons.check_box_outline_blank, + size: 18, + ), + const SizedBox(width: 8), + const Text('Class leaderboard'), + ], + ), ), ); } diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index d9b4739..bbfcb95 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -523,26 +523,34 @@ class _MyHomePageState extends State { ) .toList(); + final logo = 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), + title: isWide + ? logo + : FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: logo, ), - TextSpan(text: "graph"), - ], - style: const TextStyle( - decoration: TextDecoration.none, - color: Colors.white, - fontFamily: "Tomatoes", - ), - ), - ), actions: [ _buildNotificationAction(context, data), IconButton(