diff --git a/lib/components/pages/badges.dart b/lib/components/pages/badges.dart index e827710..90ce0d9 100644 --- a/lib/components/pages/badges.dart +++ b/lib/components/pages/badges.dart @@ -62,7 +62,7 @@ class _BadgesPageState extends State { if (navigator.canPop()) { navigator.pop(); } else { - context.go('/'); + context.go('/more'); } }, ), diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 216c5ab..f3be05a 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; 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'; @@ -29,6 +30,7 @@ class _LocoTimelinePageState extends State { final List<_EventDraft> _draftEvents = []; bool _isSaving = false; bool _isDeleting = false; + bool _isModerating = false; @override void initState() { @@ -59,8 +61,12 @@ class _LocoTimelinePageState extends State { Future _load() { final data = context.read(); + final auth = context.read(); data.fetchEventFields(); - return data.fetchLocoTimeline(widget.locoId); + return data.fetchLocoTimeline( + widget.locoId, + includeAllPending: auth.isElevated, + ); } void _addDraftEvent() { @@ -151,8 +157,18 @@ class _LocoTimelinePageState extends State { Future _deleteEntry(LocoAttrVersion entry) async { if (_isDeleting) return; + final isPending = entry.isPending; final blockId = entry.versionId; - if (blockId == null) { + final pendingEventId = entry.sourceEventId; + if (isPending && pendingEventId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot delete: pending timeline block has no event ID.'), + ), + ); + return; + } + if (!isPending && blockId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cannot delete: timeline block has no ID.')), ); @@ -193,13 +209,23 @@ class _LocoTimelinePageState extends State { _isDeleting = true; }); try { - await data.deleteTimelineBlock( - blockId: blockId, - ); + if (isPending && pendingEventId != null) { + await data.deletePendingEvent(eventId: pendingEventId); + } else if (blockId != null) { + await data.deleteTimelineBlock( + blockId: blockId, + ); + } await _load(); if (mounted) { messenger.showSnackBar( - const SnackBar(content: Text('Timeline block deleted')), + SnackBar( + content: Text( + isPending + ? 'Pending timeline block deleted' + : 'Timeline block deleted', + ), + ), ); } } catch (e) { @@ -217,6 +243,79 @@ class _LocoTimelinePageState extends State { } } + Future _moderatePendingEntry( + LocoAttrVersion entry, + _PendingModerationAction action, + ) async { + if (_isModerating) return; + final eventId = entry.sourceEventId; + if (eventId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot moderate: pending timeline block has no event ID.'), + ), + ); + return; + } + final data = context.read(); + final approve = action == _PendingModerationAction.approve; + final messenger = ScaffoldMessenger.of(context); + final verb = approve ? 'approve' : 'reject'; + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('${approve ? 'Approve' : 'Reject'} pending event?'), + content: Text( + 'Are you sure you want to $verb this pending timeline block?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(approve ? 'Approve' : 'Reject'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + + setState(() { + _isModerating = true; + }); + try { + if (approve) { + await data.approvePendingEvent(eventId: eventId); + } else { + await data.rejectPendingEvent(eventId: eventId); + } + await _load(); + if (mounted) { + messenger.showSnackBar( + SnackBar( + content: Text( + 'Pending timeline block ${approve ? 'approved' : 'rejected'}.', + ), + ), + ); + } + } catch (e) { + if (mounted) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to $verb pending timeline block: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isModerating = false; + }); + } + } + } + void _removeDraftAt(int index) { if (index < 0 || index >= _draftEvents.length) return; final draft = _draftEvents.removeAt(index); @@ -248,6 +347,9 @@ class _LocoTimelinePageState extends State { _isSaving = true; }); try { + final existingPending = + await data.fetchUserPendingEvents(widget.locoId); + final clearedEventIds = {}; final invalid = []; for (final draft in _draftEvents) { final dateStr = draft.dateController.text.trim(); @@ -274,6 +376,13 @@ class _LocoTimelinePageState extends State { invalid.add('Add at least one value'); continue; } + await _clearDuplicatePending( + existingPending, + clearedEventIds, + values.keys, + dateStr, + data, + ); await data.createLocoEvent( locoId: widget.locoId, eventDate: dateStr, @@ -312,6 +421,42 @@ class _LocoTimelinePageState extends State { } } + Future _clearDuplicatePending( + List existingPending, + Set clearedEventIds, + Iterable attrs, + String dateStr, + DataService data, + ) async { + final trimmedDate = dateStr.trim().toLowerCase(); + final attrSet = attrs.map((e) => e.toLowerCase()).toSet(); + for (final pending in existingPending) { + final attrMatch = attrSet.contains(pending.attrCode.toLowerCase()); + if (!attrMatch) continue; + final matchesDate = _dateMatchesPending(trimmedDate, pending); + if (!matchesDate) continue; + final eventId = pending.sourceEventId; + if (eventId == null || clearedEventIds.contains(eventId)) continue; + await data.deletePendingEvent(eventId: eventId); + clearedEventIds.add(eventId); + } + } + + bool _dateMatchesPending(String draftDateLower, LocoAttrVersion pending) { + final masked = pending.maskedValidFrom?.trim().toLowerCase(); + if (masked != null && masked.isNotEmpty && masked == draftDateLower) { + return true; + } + final draftDate = DateTime.tryParse(draftDateLower); + final pendingDate = pending.validFrom; + if (draftDate != null && pendingDate != null) { + return draftDate.year == pendingDate.year && + draftDate.month == pendingDate.month && + draftDate.day == pendingDate.day; + } + return false; + } + bool _isValidDateString(String input) { final trimmed = input.trim(); final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$'); @@ -412,6 +557,8 @@ class _LocoTimelinePageState extends State { data.eventFields, ), onDeleteEntry: _deleteEntry, + onModeratePending: _moderatePendingEntry, + pendingActionsBusy: _isModerating, ), const SizedBox(height: 16), _EventEditor( diff --git a/lib/components/pages/loco_timeline/event_editor.dart b/lib/components/pages/loco_timeline/event_editor.dart index 4374efc..8e345a6 100644 --- a/lib/components/pages/loco_timeline/event_editor.dart +++ b/lib/components/pages/loco_timeline/event_editor.dart @@ -23,6 +23,7 @@ class _EventEditor extends StatelessWidget { @override Widget build(BuildContext context) { + final hasDrafts = drafts.isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -37,12 +38,14 @@ class _EventEditor extends StatelessWidget { ), Row( children: [ - OutlinedButton.icon( - onPressed: onAddEvent, - icon: const Icon(Icons.add), - label: const Text('New event'), - ), - const SizedBox(width: 8), + if (!hasDrafts) ...[ + OutlinedButton.icon( + onPressed: onAddEvent, + icon: const Icon(Icons.add), + label: const Text('New event'), + ), + const SizedBox(width: 8), + ], FilledButton.icon( onPressed: (!canSave || isSaving) ? null : onSave, icon: isSaving @@ -147,6 +150,14 @@ class _EventEditor extends StatelessWidget { ); }, ), + if (hasDrafts) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: onAddEvent, + icon: const Icon(Icons.add), + label: const Text('New event'), + ), + ], ], ); } diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart index 8d44b31..612ffce 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -2,16 +2,25 @@ part of 'package:mileograph_flutter/components/pages/loco_timeline.dart'; final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); +enum _PendingModerationAction { approve, reject } + class _TimelineGrid extends StatefulWidget { const _TimelineGrid({ required this.entries, this.onEditEntry, this.onDeleteEntry, + this.onModeratePending, + this.pendingActionsBusy = false, }); final List entries; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; + final Future Function( + LocoAttrVersion entry, + _PendingModerationAction action, + )? onModeratePending; + final bool pendingActionsBusy; @override State<_TimelineGrid> createState() => _TimelineGridState(); @@ -191,6 +200,8 @@ class _TimelineGridState extends State<_TimelineGrid> { viewportWidth: axisWidth, onEditEntry: widget.onEditEntry, onDeleteEntry: widget.onDeleteEntry, + onModeratePending: widget.onModeratePending, + pendingActionsBusy: widget.pendingActionsBusy, ), ); }, @@ -276,6 +287,8 @@ class _AttrRow extends StatelessWidget { required this.viewportWidth, this.onEditEntry, this.onDeleteEntry, + this.onModeratePending, + this.pendingActionsBusy = false, }); final double rowHeight; @@ -285,6 +298,11 @@ class _AttrRow extends StatelessWidget { final double viewportWidth; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; + final Future Function( + LocoAttrVersion entry, + _PendingModerationAction action, + )? onModeratePending; + final bool pendingActionsBusy; @override Widget build(BuildContext context) { @@ -310,6 +328,8 @@ class _AttrRow extends StatelessWidget { block: block, onEditEntry: onEditEntry, onDeleteEntry: onDeleteEntry, + onModeratePending: onModeratePending, + pendingActionsBusy: pendingActionsBusy, ), ), if (activeBlock != null) @@ -326,6 +346,7 @@ class _AttrRow extends StatelessWidget { width: stickyWidth, ), clipLeftEdge: scrollOffset > activeBlock.left + 0.1, + pendingActionsBusy: pendingActionsBusy, ), ), ), @@ -347,15 +368,17 @@ class _ValueBlockView extends StatelessWidget { const _ValueBlockView({ required this.block, this.clipLeftEdge = false, + this.pendingActionsBusy = false, }); final _ValueBlock block; final bool clipLeftEdge; + final bool pendingActionsBusy; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = block.cell.color.withValues(alpha: 0.9); + final color = block.cell.color; final textColor = ThemeData.estimateBrightnessForColor(color) == Brightness.dark ? Colors.white @@ -386,32 +409,63 @@ class _ValueBlockView extends StatelessWidget { fit: BoxFit.scaleDown, child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 1, minHeight: 1), - child: Column( + child: Row( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - block.cell.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, - color: textColor, - ) ?? - TextStyle( - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - const SizedBox(height: 4), - Text( - block.cell.rangeLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: textColor.withValues(alpha: 0.9), - ) ?? - TextStyle(color: textColor.withValues(alpha: 0.9)), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (block.cell.isPending) + Padding( + padding: const EdgeInsets.only(right: 6), + child: SizedBox( + width: 16, + height: 16, + child: pendingActionsBusy + ? CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(textColor), + ) + : Icon( + Icons.pending, + size: 16, + color: textColor, + ), + ), + ), + Text( + block.cell.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: textColor, + ) ?? + TextStyle( + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + block.cell.rangeLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: textColor.withValues(alpha: 0.9), + ) ?? + TextStyle( + color: textColor.withValues(alpha: 0.9), + ), + ), + ], ), ], ), @@ -422,26 +476,45 @@ class _ValueBlockView extends StatelessWidget { } } -enum _TimelineBlockAction { edit, delete } +enum _TimelineBlockAction { edit, delete, approve, reject } class _ValueBlockMenu extends StatelessWidget { const _ValueBlockMenu({ required this.block, this.onEditEntry, this.onDeleteEntry, + this.onModeratePending, + this.pendingActionsBusy = false, }); final _ValueBlock block; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; + final Future Function( + LocoAttrVersion entry, + _PendingModerationAction action, + )? onModeratePending; + final bool pendingActionsBusy; - bool get _hasActions => onEditEntry != null || onDeleteEntry != null; + bool get _hasActions { + final canModerate = block.entry?.isPending == true && + block.entry?.canModeratePending == true && + onModeratePending != null; + final canEdit = onEditEntry != null && block.entry?.isPending != true; + return onDeleteEntry != null || canModerate || canEdit; + } @override Widget build(BuildContext context) { if (!_hasActions || block.entry == null) { - return _ValueBlockView(block: block); + return _ValueBlockView( + block: block, + ); } + final canModerate = block.entry?.isPending == true && + block.entry?.canModeratePending == true && + onModeratePending != null; + final canEdit = onEditEntry != null && block.entry?.isPending != true; Future showContextMenuAt(Offset globalPosition) async { final overlay = Overlay.of(context); @@ -459,11 +532,23 @@ class _ValueBlockMenu extends StatelessWidget { context: context, position: position, items: [ - if (onEditEntry != null) + if (canEdit) const PopupMenuItem( value: _TimelineBlockAction.edit, child: Text('Edit'), ), + if (canModerate) + PopupMenuItem( + value: _TimelineBlockAction.approve, + enabled: !pendingActionsBusy, + child: const Text('Approve pending'), + ), + if (canModerate) + PopupMenuItem( + value: _TimelineBlockAction.reject, + enabled: !pendingActionsBusy, + child: const Text('Reject pending'), + ), if (onDeleteEntry != null) const PopupMenuItem( value: _TimelineBlockAction.delete, @@ -481,11 +566,17 @@ class _ValueBlockMenu extends StatelessWidget { case _TimelineBlockAction.delete: onDeleteEntry?.call(entry); break; + case _TimelineBlockAction.approve: + onModeratePending?.call(entry, _PendingModerationAction.approve); + break; + case _TimelineBlockAction.reject: + onModeratePending?.call(entry, _PendingModerationAction.reject); + break; } } return GestureDetector( - behavior: HitTestBehavior.opaque, + behavior: HitTestBehavior.deferToChild, onLongPressStart: (details) async { if (defaultTargetPlatform == TargetPlatform.android) { HapticFeedback.lightImpact(); @@ -495,7 +586,10 @@ class _ValueBlockMenu extends StatelessWidget { onSecondaryTapDown: (details) async { await showContextMenuAt(details.globalPosition); }, - child: _ValueBlockView(block: block), + child: _ValueBlockView( + block: block, + pendingActionsBusy: pendingActionsBusy, + ), ); } } @@ -518,7 +612,28 @@ String _formatAttrLabel(String code) { DateTime? _parseDateString(String? value) { if (value == null || value.isEmpty) return null; - return DateTime.tryParse(value); + final direct = DateTime.tryParse(value); + if (direct != null) return direct; + final maskedMatch = + RegExp(r'^(\\d{4})-(\\d{2}|xx|XX)-(\\d{2}|xx|XX)\$').firstMatch(value); + if (maskedMatch != null) { + final year = int.tryParse(maskedMatch.group(1) ?? ''); + if (year == null) return null; + String normalize(String part, int fallback) { + final lower = part.toLowerCase(); + if (lower == 'xx') return fallback.toString().padLeft(2, '0'); + return part; + } + + final month = int.tryParse(normalize(maskedMatch.group(2) ?? '01', 1)) ?? 1; + final day = int.tryParse(normalize(maskedMatch.group(3) ?? '01', 1)) ?? 1; + try { + return DateTime(year, month.clamp(1, 12), day.clamp(1, 31)); + } catch (_) { + return null; + } + } + return null; } DateTime? _effectiveStart(LocoAttrVersion entry) { @@ -550,8 +665,11 @@ class _TimelineModel { }); factory _TimelineModel.fromEntries(List entries) { + final effectiveEntries = entries + .where((e) => _effectiveStart(e) != null) + .toList(); final grouped = >{}; - for (final entry in entries) { + for (final entry in effectiveEntries) { grouped.putIfAbsent(entry.attrCode, () => []).add(entry); } final now = DateTime.now(); @@ -774,11 +892,13 @@ class _RowCell { final String value; final String rangeLabel; final Color color; + final bool isPending; const _RowCell({ required this.value, required this.rangeLabel, required this.color, + this.isPending = false, }); factory _RowCell.fromSegment(_ValueSegment seg) { @@ -787,6 +907,7 @@ class _RowCell { value: '', rangeLabel: '', color: Colors.transparent, + isPending: false, ); } final entry = seg.entry; @@ -802,6 +923,7 @@ class _RowCell { value: seg.value, rangeLabel: displayStart, color: _colorForValue(seg.value), + isPending: entry?.isPending ?? false, ); } } diff --git a/lib/components/pages/more/admin_page.dart b/lib/components/pages/more/admin_page.dart index 1070124..1325e2f 100644 --- a/lib/components/pages/more/admin_page.dart +++ b/lib/components/pages/more/admin_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; @@ -354,6 +355,18 @@ class _AdminPageState extends State { return Scaffold( appBar: AppBar( title: const Text('Admin'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.maybePop(); + } else { + context.go('/more'); + } + }, + tooltip: 'Back', + ), ), body: ListView( padding: const EdgeInsets.all(16), 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 9c884f3..1e5ad01 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -232,16 +232,16 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { if (!mounted) return false; final result = await showDialog( context: context, - builder: (_) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Duplicate entry?'), content: const Text('Entry already added, are you sure?'), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Submit anyway'), ), ], diff --git a/lib/components/pages/profile.dart b/lib/components/pages/profile.dart index 02f02c1..6d4b028 100644 --- a/lib/components/pages/profile.dart +++ b/lib/components/pages/profile.dart @@ -363,6 +363,18 @@ class _ProfilePageState extends State { return Scaffold( appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + final nav = Navigator.of(context); + if (nav.canPop()) { + nav.maybePop(); + } else { + context.go('/more'); + } + }, + tooltip: 'Back', + ), title: const Text('Profile'), ), body: RefreshIndicator( diff --git a/lib/components/pages/settings.dart b/lib/components/pages/settings.dart index 29f00a5..559b919 100644 --- a/lib/components/pages/settings.dart +++ b/lib/components/pages/settings.dart @@ -146,7 +146,7 @@ class _SettingsPageState extends State { if (navigator.canPop()) { navigator.pop(); } else { - context.go('/'); + context.go('/more'); } }, ), diff --git a/lib/components/pages/stats.dart b/lib/components/pages/stats.dart index d25bf17..1414727 100644 --- a/lib/components/pages/stats.dart +++ b/lib/components/pages/stats.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; @@ -32,7 +33,21 @@ class _StatsPageState extends State { final data = context.watch(); final distanceUnits = context.watch(); return Scaffold( - appBar: AppBar(title: const Text('Stats')), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + final nav = Navigator.of(context); + if (nav.canPop()) { + nav.maybePop(); + } else { + context.go('/more'); + } + }, + tooltip: 'Back', + ), + title: const Text('Stats'), + ), body: RefreshIndicator( onRefresh: () => _loadStats(force: true), child: _buildContent(data, distanceUnits), diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index e300036..6dd40ae 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -1,14 +1,24 @@ part of 'traction.dart'; +enum _TractionMoreAction { + classStats, + classLeaderboard, + adminPending, +} + class TractionPage extends StatefulWidget { const TractionPage({ super.key, this.selectionMode = false, + this.selectionSingle = false, + this.replacementPendingLocoId, this.onSelect, this.selectedKeys = const {}, }); final bool selectionMode; + final bool selectionSingle; + final int? replacementPendingLocoId; final ValueChanged? onSelect; final Set selectedKeys; @@ -606,60 +616,106 @@ class _TractionPageState extends State { icon: const Icon(Icons.refresh), ); - final classStatsButton = !_hasClassQuery - ? null - : FilledButton.tonalIcon( - onPressed: _toggleClassStatsPanel, - icon: Icon( - _showClassStatsPanel ? Icons.bar_chart : Icons.insights, - ), - label: Text( - _showClassStatsPanel ? 'Hide class stats' : 'Class stats', - ), - ); - final classLeaderboardButton = !_hasClassQuery - ? null - : FilledButton.tonalIcon( - onPressed: _toggleClassLeaderboardPanel, - icon: Icon( - _showClassLeaderboardPanel ? Icons.emoji_events : Icons.leaderboard, - ), - label: Text( - _showClassLeaderboardPanel ? 'Hide class leaderboard' : 'Class leaderboard', - ), - ); + final hasClassActions = _hasClassQuery; - final newTractionButton = !isElevated + final newTractionButton = FilledButton.icon( + onPressed: () async { + final createdClass = await context.push( + '/traction/new', + ); + if (!mounted) return; + if (createdClass != null && createdClass.isNotEmpty) { + _classController.text = createdClass; + _selectedClass = createdClass; + _refreshTraction(); + } else if (createdClass == '') { + _refreshTraction(); + } + }, + icon: const Icon(Icons.add), + label: const Text('New Traction'), + ); + + final hasAdminActions = isElevated; + final hasMoreMenu = hasClassActions || hasAdminActions; + + final moreButton = !hasMoreMenu ? null - : FilledButton.icon( - onPressed: () async { - final createdClass = await context.push( - '/traction/new', - ); - if (!mounted) return; - if (createdClass != null && createdClass.isNotEmpty) { - _classController.text = createdClass; - _selectedClass = createdClass; - _refreshTraction(); - } else if (createdClass == '') { - _refreshTraction(); + : PopupMenuButton<_TractionMoreAction>( + tooltip: 'More options', + onSelected: (action) async { + switch (action) { + case _TractionMoreAction.classStats: + _toggleClassStatsPanel(); + break; + case _TractionMoreAction.classLeaderboard: + _toggleClassLeaderboardPanel(); + break; + case _TractionMoreAction.adminPending: + final messenger = ScaffoldMessenger.of(context); + try { + await context.push('/traction/pending'); + if (!mounted) return; + } catch (_) { + if (!mounted) return; + messenger.showSnackBar( + const SnackBar( + content: Text('Unable to open pending locos'), + ), + ); + } + break; } }, - icon: const Icon(Icons.add), - label: const Text('New Traction'), + itemBuilder: (context) { + final items = >[]; + if (hasClassActions) { + items.add( + const PopupMenuItem( + value: _TractionMoreAction.classStats, + child: Text('Class stats'), + ), + ); + } + if (hasClassActions) { + items.add( + const PopupMenuItem( + value: _TractionMoreAction.classLeaderboard, + child: Text('Class leaderboard'), + ), + ); + } + if (items.isNotEmpty && hasAdminActions) { + items.add(const PopupMenuDivider()); + } + if (hasAdminActions) { + items.add( + const PopupMenuItem( + value: _TractionMoreAction.adminPending, + child: Text('Pending locos'), + ), + ); + } + return items; + }, + child: IgnorePointer( + child: FilledButton.tonalIcon( + onPressed: () {}, + icon: const Icon(Icons.more_horiz), + label: const Text('More'), + ), + ), ); final desktopActions = [ refreshButton, - if (classStatsButton != null) classStatsButton, - if (classLeaderboardButton != null) classLeaderboardButton, - if (newTractionButton != null) newTractionButton, + newTractionButton, + if (moreButton != null) moreButton, ]; final mobileActions = [ - if (newTractionButton != null) newTractionButton, - if (classStatsButton != null) classStatsButton, - if (classLeaderboardButton != null) classLeaderboardButton, + if (moreButton != null) moreButton, + newTractionButton, refreshButton, ]; @@ -1054,6 +1110,12 @@ class _TractionPageState extends State { if (widget.onSelect != null) { widget.onSelect!(loco); } + if (widget.selectionMode && widget.selectionSingle) { + if (mounted) { + context.pop(loco); + } + return; + } setState(() { if (_selectedKeys.contains(keyVal)) { _selectedKeys.remove(keyVal); @@ -1063,6 +1125,81 @@ class _TractionPageState extends State { }); } + Future _confirmReplacePending(LocoSummary replacement) async { + final pendingId = widget.replacementPendingLocoId; + if (pendingId == null) return; + final navContext = context; + final messenger = ScaffoldMessenger.of(navContext); + String rejectionReason = ''; + final confirmed = await showDialog( + context: navContext, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + final canSubmit = rejectionReason.trim().isNotEmpty; + return AlertDialog( + title: const Text('Replace pending loco?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Replace pending loco with ${replacement.locoClass} ${replacement.number}?', + ), + const SizedBox(height: 12), + TextField( + autofocus: true, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Rejection reason', + hintText: 'Reason for replacing this loco', + ), + onChanged: (val) => setState(() { + rejectionReason = val; + }), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: + canSubmit ? () => Navigator.of(dialogContext).pop(true) : null, + child: const Text('Replace'), + ), + ], + ); + }, + ); + }, + ); + if (confirmed != true) return; + if (!navContext.mounted) return; + try { + final data = navContext.read(); + await data.rejectPendingLoco( + locoId: pendingId, + replacementLocoId: replacement.id, + rejectedReason: rejectionReason, + ); + if (navContext.mounted) { + messenger.showSnackBar( + const SnackBar(content: Text('Pending loco replaced')), + ); + navContext.pop(); + } + } catch (e) { + if (navContext.mounted) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to replace loco: $e')), + ); + } + } + } + bool _isSelected(LocoSummary loco) { final keyVal = '${loco.locoClass}-${loco.number}'; return _selectedKeys.contains(keyVal); @@ -1195,15 +1332,27 @@ class _TractionPageState extends State { (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 TractionCard( + loco: loco, + selectionMode: widget.selectionMode, + isSelected: _isSelected(loco), + onShowInfo: () => showTractionDetails( + context, + loco, + onActionComplete: () => _refreshTraction(preservePosition: true), + ), + onOpenTimeline: () => _openTimeline(loco), + onOpenLegs: () => _openLegs(loco), + onActionComplete: _refreshTraction, + onToggleSelect: widget.selectionMode && + widget.replacementPendingLocoId == null + ? () => _toggleSelection(loco) + : null, + onReplacePending: widget.selectionMode && + widget.selectionSingle && + widget.replacementPendingLocoId != null + ? () => _confirmReplacePending(loco) + : null, ); } diff --git a/lib/components/pages/traction/traction_pending_page.dart b/lib/components/pages/traction/traction_pending_page.dart new file mode 100644 index 0000000..359e7a6 --- /dev/null +++ b/lib/components/pages/traction/traction_pending_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/components/traction/traction_card.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:provider/provider.dart'; + +class TractionPendingPage extends StatefulWidget { + const TractionPendingPage({super.key}); + + @override + State createState() => _TractionPendingPageState(); +} + +class _TractionPendingPageState extends State { + bool _isLoading = false; + String? _error; + List _locos = const []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final api = context.read(); + final params = '?limit=200&offset=0'; + final json = await api.get('/loco/pending$params'); + if (json is List) { + setState(() { + _locos = json + .whereType() + .map((e) => LocoSummary.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList(); + }); + } else { + setState(() { + _error = 'Unexpected response'; + _locos = const []; + }); + } + } catch (e) { + setState(() { + _error = e.toString(); + _locos = const []; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: const Text('Pending traction'), + ), + body: RefreshIndicator( + onRefresh: _load, + child: _buildBody(context), + ), + ); + } + + Widget _buildBody(BuildContext context) { + if (_isLoading && _locos.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Failed to load pending traction: $_error', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ); + } + if (_locos.isEmpty) { + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + Text('No pending traction found.'), + ], + ); + } + return ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: _locos.length, + itemBuilder: (context, index) { + final loco = _locos[index]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: TractionCard( + loco: loco, + selectionMode: false, + isSelected: false, + onShowInfo: () => showTractionDetails( + context, + loco, + onActionComplete: _load, + ), + onOpenTimeline: () => context.push( + '/traction/${loco.id}/timeline', + extra: {'label': '${loco.locoClass} ${loco.number}'.trim()}, + ), + onOpenLegs: () => context.push('/traction/${loco.id}/legs'), + onActionComplete: _load, + ), + ); + }, + ); + } +} diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index 1f013b5..52d6dce 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; @@ -14,6 +18,8 @@ class TractionCard extends StatelessWidget { required this.onOpenTimeline, this.onOpenLegs, this.onToggleSelect, + this.onReplacePending, + this.onActionComplete, }); final LocoSummary loco; @@ -23,12 +29,19 @@ class TractionCard extends StatelessWidget { final VoidCallback onOpenTimeline; final VoidCallback? onOpenLegs; final VoidCallback? onToggleSelect; + final VoidCallback? onReplacePending; + final Future Function()? onActionComplete; @override Widget build(BuildContext context) { final status = loco.status ?? 'Unknown'; final operatorName = loco.operator ?? ''; final domain = loco.domain ?? ''; + final isVisibilityPending = + (loco.visibility ?? '').toLowerCase().trim() == 'pending'; + final isRejected = + (loco.visibility ?? '').toLowerCase().contains('reject'); + final isElevated = context.read().isElevated; final hasMileageOrTrips = _hasMileageOrTrips(loco); final statusColors = _statusChipColors(context, status); final distanceUnits = context.watch(); @@ -45,23 +58,14 @@ class TractionCard extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - loco.number, - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), - ), - if (hasMileageOrTrips) - Padding( - padding: const EdgeInsets.only(left: 6.0), - child: Icon( - Icons.check_circle, - size: 18, - color: Colors.green.shade600, - ), - ), - ], + _LocoNumberWithHistory( + number: loco.number, + matchedNumber: loco.matchedNumber, + matchedNumberValidTo: loco.matchedNumberValidTo, + hasMileageOrTrips: hasMileageOrTrips, + largeStyle: Theme.of(context).textTheme.headlineSmall, + showPendingChip: isVisibilityPending, + showRejectedChip: isRejected && !isVisibilityPending, ), Text( loco.locoClass, @@ -78,10 +82,42 @@ class TractionCard extends StatelessWidget { ), ], ), - Chip( - label: Text(status), - backgroundColor: statusColors.$1, - labelStyle: TextStyle(color: statusColors.$2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isElevated && isVisibilityPending) ...[ + PopupMenuButton<_PendingLocoAction>( + tooltip: 'Pending options', + onSelected: (action) => _handlePendingAction( + context, + action, + loco, + onActionComplete: onActionComplete, + ), + itemBuilder: (context) => const [ + PopupMenuItem( + value: _PendingLocoAction.accept, + child: Text('Accept loco'), + ), + PopupMenuItem( + value: _PendingLocoAction.reject, + child: Text('Reject loco'), + ), + PopupMenuItem( + value: _PendingLocoAction.replace, + child: Text('Replace...'), + ), + ], + icon: const Icon(Icons.more_vert), + ), + const SizedBox(width: 6), + ], + Chip( + label: Text(status), + backgroundColor: statusColors.$1, + labelStyle: TextStyle(color: statusColors.$2), + ), + ], ), ], ), @@ -108,17 +144,24 @@ class TractionCard extends StatelessWidget { ), ]; - final addButton = selectionMode && onToggleSelect != null + // Prefer replace action when picking a replacement loco. + final addButton = onReplacePending != null ? TextButton.icon( - onPressed: onToggleSelect, - icon: Icon( - isSelected - ? Icons.remove_circle_outline - : Icons.add_circle_outline, - ), - label: Text(isSelected ? 'Remove' : 'Add to entry'), + onPressed: onReplacePending, + icon: const Icon(Icons.swap_horiz), + label: const Text('Replace'), ) - : null; + : (!isRejected && selectionMode && onToggleSelect != null) + ? TextButton.icon( + onPressed: onToggleSelect, + icon: Icon( + isSelected + ? Icons.remove_circle_outline + : Icons.add_circle_outline, + ), + label: Text(isSelected ? 'Remove' : 'Add to entry'), + ) + : null; if (isNarrow) { return Column( @@ -205,13 +248,326 @@ class TractionCard extends StatelessWidget { } } +class _LocoNumberWithHistory extends StatelessWidget { + const _LocoNumberWithHistory({ + required this.number, + required this.matchedNumber, + required this.matchedNumberValidTo, + required this.hasMileageOrTrips, + this.largeStyle, + this.showPendingChip = false, + this.showRejectedChip = false, + }); + + final String number; + final String? matchedNumber; + final DateTime? matchedNumberValidTo; + final bool hasMileageOrTrips; + final TextStyle? largeStyle; + final bool showPendingChip; + final bool showRejectedChip; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final historicNumber = matchedNumber?.trim() ?? ''; + final hasHistoricDate = matchedNumberValidTo != null; + final showHistoric = historicNumber.isNotEmpty && hasHistoricDate; + final historicDate = + hasHistoricDate ? DateFormat('yyyy-MM-dd').format(matchedNumberValidTo!) : null; + + return Row( + children: [ + Text( + number, + style: (largeStyle ?? theme.textTheme.titleLarge)?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (hasMileageOrTrips) + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.green.shade600, + ), + ), + if (showPendingChip) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.pending, size: 14), + const SizedBox(width: 4), + Text( + 'Pending', + style: theme.textTheme.labelSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ), + ], + if (showRejectedChip) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red.shade700, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.close, size: 14, color: Colors.white), + const SizedBox(width: 4), + Text( + 'Rejected', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ], + if (showHistoric) ...[ + const SizedBox(width: 8), + Text( + historicNumber, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w800, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (historicDate != null) ...[ + const SizedBox(width: 6), + Text( + 'until $historicDate', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ], + ); + } +} + +enum _PendingLocoAction { accept, reject, replace } + +Future _handlePendingAction( + BuildContext context, + _PendingLocoAction action, + LocoSummary loco, { + Future Function()? onActionComplete, +}) async { + final navContext = context; + final messenger = ScaffoldMessenger.of(navContext); + final data = navContext.read(); + if (action == _PendingLocoAction.replace) { + final path = Uri( + path: '/traction', + queryParameters: { + 'selection': 'single', + 'replacementPendingLocoId': loco.id.toString(), + }, + ).toString(); + final selected = await navContext.push( + path, + extra: { + 'selection': 'single', + 'replacementPendingLocoId': loco.id, + }, + ); + if (!navContext.mounted) return; + if (selected == null) return; + String rejectionReason = ''; + final confirmed = await showDialog( + context: navContext, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + final canSubmit = rejectionReason.trim().isNotEmpty; + return AlertDialog( + title: const Text('Replace pending loco?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Replace ${loco.locoClass} ${loco.number} with ${selected.locoClass} ${selected.number}?', + ), + const SizedBox(height: 12), + TextField( + autofocus: true, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Rejection reason', + hintText: 'Reason for replacing this loco', + ), + onChanged: (val) => setState(() { + rejectionReason = val; + }), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: canSubmit + ? () => Navigator.of(dialogContext).pop(true) + : null, + child: const Text('Replace'), + ), + ], + ); + }, + ); + }, + ); + if (!navContext.mounted) return; + if (confirmed != true) return; + try { + await data.rejectPendingLoco( + locoId: loco.id, + replacementLocoId: selected.id, + rejectedReason: rejectionReason, + ); + await data.fetchClassList(force: true); + if (navContext.mounted) { + messenger.showSnackBar( + const SnackBar(content: Text('Pending loco replaced')), + ); + } + await onActionComplete?.call(); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to replace loco: $e')), + ); + } + return; + } + if (action == _PendingLocoAction.reject) { + String rejectionReason = ''; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + final canSubmit = rejectionReason.trim().isNotEmpty; + return AlertDialog( + title: const Text('Reject loco?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + autofocus: true, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Rejection reason', + hintText: 'Why is this loco being rejected?', + ), + onChanged: (val) => setState(() { + rejectionReason = val; + }), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: canSubmit + ? () => Navigator.of(context).pop(true) + : null, + child: const Text('Reject'), + ), + ], + ); + }, + ); + }, + ); + if (confirmed != true) return; + + if (!navContext.mounted) return; + try { + await data.rejectPendingLoco( + locoId: loco.id, + rejectedReason: rejectionReason, + ); + await data.fetchClassList(force: true); + if (navContext.mounted) { + messenger.showSnackBar( + const SnackBar(content: Text('Pending loco rejected')), + ); + } + await onActionComplete?.call(); + } catch (e) { + if (navContext.mounted) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to reject loco: $e')), + ); + } + } + return; + } + + try { + await data.acceptPendingLoco(locoId: loco.id); + if (navContext.mounted) { + messenger.showSnackBar( + const SnackBar(content: Text('Pending loco accepted')), + ); + } + await onActionComplete?.call(); + } catch (e) { + if (navContext.mounted) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to accept loco: $e')), + ); + } + } +} + Future showTractionDetails( BuildContext context, - LocoSummary loco, -) async { + LocoSummary loco, { + Future Function()? onActionComplete, +}) async { final hasMileageOrTrips = _hasMileageOrTrips(loco); + final isVisibilityPending = + (loco.visibility ?? '').toLowerCase().trim() == 'pending'; + final isRejected = + (loco.visibility ?? '').toLowerCase().contains('reject'); + final rejectedReason = + loco.extra['rejected_reason']?.toString().trim() ?? ''; final distanceUnits = context.read(); final api = context.read(); + final data = context.read(); + final auth = context.read(); + final messenger = ScaffoldMessenger.of(context); + final userId = auth.userId; + final createdBy = loco.extra['created_by']?.toString(); + final isOwnedByUser = + userId != null && createdBy != null && createdBy == userId; + final canDeleteAsOwner = isOwnedByUser && (isVisibilityPending || isRejected); final leaderboardId = _leaderboardId(loco); final leaderboardFuture = leaderboardId == null ? Future.value(const []) @@ -240,23 +596,13 @@ Future showTractionDetails( Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - loco.number, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w800), - ), - if (hasMileageOrTrips) - Padding( - padding: const EdgeInsets.only(left: 6.0), - child: Icon( - Icons.check_circle, - size: 18, - color: Colors.green.shade600, - ), - ), - ], + _LocoNumberWithHistory( + number: loco.number, + matchedNumber: loco.matchedNumber, + matchedNumberValidTo: loco.matchedNumberValidTo, + hasMileageOrTrips: hasMileageOrTrips, + showPendingChip: isVisibilityPending, + showRejectedChip: isRejected && !isVisibilityPending, ), Text( loco.locoClass, @@ -279,6 +625,15 @@ Future showTractionDetails( child: ListView( controller: controller, children: [ + if (isRejected && rejectedReason.isNotEmpty) + ...[ + _detailRow( + context, + 'Rejection reason', + rejectedReason, + ), + const Divider(), + ], _detailRow(context, 'Status', loco.status ?? 'Unknown'), _detailRow(context, 'Operator', loco.operator ?? ''), _detailRow(context, 'Domain', loco.domain ?? ''), @@ -355,12 +710,65 @@ Future showTractionDetails( distanceUnits, ); }).toList(), - ); - }, - ), - ], + ); + }, ), - ), + if (auth.isElevated || canDeleteAsOwner) ...[ + const SizedBox(height: 16), + FilledButton.tonal( + style: FilledButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.errorContainer, + foregroundColor: + Theme.of(context).colorScheme.onErrorContainer, + ), + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete loco?'), + content: const Text( + 'This will permanently delete this loco. Are you sure?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ); + }, + ); + if (confirmed != true) return; + try { + await data.adminDeleteLoco(locoId: loco.id); + messenger.showSnackBar( + const SnackBar(content: Text('Loco deleted')), + ); + await onActionComplete?.call(); + if (!context.mounted) return; + Navigator.of(ctx).pop(); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to delete loco: $e')), + ); + } + }, + child: const Text('Delete loco'), + ), + ], + ], + ), + ), ], ), ); diff --git a/lib/components/widgets/leg_share_edit_notification_card.dart b/lib/components/widgets/leg_share_edit_notification_card.dart index 87a94b5..80bc2b3 100644 --- a/lib/components/widgets/leg_share_edit_notification_card.dart +++ b/lib/components/widgets/leg_share_edit_notification_card.dart @@ -160,6 +160,7 @@ class _LegShareEditNotificationCardState extends State extra; LocoSummary({ @@ -772,6 +775,9 @@ class LocoSummary extends Loco { this.owner, this.livery, this.location, + this.visibility, + this.matchedNumber, + this.matchedNumberValidTo, Map? extra, super.powering = true, super.allocPos = 0, @@ -808,6 +814,9 @@ class LocoSummary extends Loco { owner: json['owner'] ?? json['loco_owner'], livery: json['livery'], location: json['location'], + visibility: json['visibility']?.toString(), + matchedNumber: json['matched_number']?.toString(), + matchedNumberValidTo: _asNullableDateTime(json['matched_number_valid_to']), extra: Map.from(json), powering: _asBool(json['alloc_powering'] ?? json['powering'], true), allocPos: _asInt(json['alloc_pos'], 0), @@ -835,6 +844,8 @@ class LocoAttrVersion { final String? precisionLevel; final String? maskedValidFrom; final dynamic valueNorm; + final bool isPending; + final bool canModeratePending; const LocoAttrVersion({ required this.attrCode, @@ -857,6 +868,8 @@ class LocoAttrVersion { this.precisionLevel, this.maskedValidFrom, this.valueNorm, + this.isPending = false, + this.canModeratePending = false, }); factory LocoAttrVersion.fromJson(Map json) { @@ -881,6 +894,8 @@ class LocoAttrVersion { precisionLevel: json['precision_level']?.toString(), maskedValidFrom: json['masked_valid_from']?.toString(), valueNorm: json['value_norm'], + isPending: json['is_pending'] == true, + canModeratePending: json['can_moderate_pending'] == true, ); } @@ -916,17 +931,21 @@ class LocoAttrVersion { }); } items.sort( - (a, b) { - final aDate = a.validFrom ?? a.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0); - final bDate = b.validFrom ?? b.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0); - final dateCompare = aDate.compareTo(bDate); - if (dateCompare != 0) return dateCompare; - return a.attrCode.compareTo(b.attrCode); - }, + compareByStart, ); return items; } + static int compareByStart(LocoAttrVersion a, LocoAttrVersion b) { + final aDate = + a.validFrom ?? a.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0); + final bDate = + b.validFrom ?? b.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0); + final dateCompare = aDate.compareTo(bDate); + if (dateCompare != 0) return dateCompare; + return a.attrCode.compareTo(b.attrCode); + } + String get valueLabel { if (valueStr != null && valueStr!.isNotEmpty) return valueStr!; if (valueEnum != null && valueEnum!.isNotEmpty) return valueEnum!; diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 9d92c78..4ca69f0 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -588,6 +588,39 @@ class DataService extends ChangeNotifier { } } + Future deletePendingEvent({ + required int eventId, + }) async { + try { + await api.delete('/event/pending/$eventId'); + } catch (e) { + debugPrint('Failed to delete pending event $eventId: $e'); + rethrow; + } + } + + Future approvePendingEvent({ + required int eventId, + }) async { + try { + await api.put('/event/approve/$eventId', null); + } catch (e) { + debugPrint('Failed to approve pending event $eventId: $e'); + rethrow; + } + } + + Future rejectPendingEvent({ + required int eventId, + }) async { + try { + await api.put('/event/reject/$eventId', null); + } catch (e) { + debugPrint('Failed to reject pending event $eventId: $e'); + rethrow; + } + } + void clear() { _currentUserId = null; _lastLegsFetch = const _LegFetchOptions(); diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart index 048f792..51de750 100644 --- a/lib/services/data_service/data_service_traction.dart +++ b/lib/services/data_service/data_service_traction.dart @@ -66,14 +66,68 @@ extension DataServiceTraction on DataService { } } - Future> fetchLocoTimeline(int locoId) async { + Future> fetchLocoTimeline( + int locoId, { + bool includeAllPending = false, + }) async { _isLocoTimelineLoading[locoId] = true; _notifyAsync(); try { - final json = await api.get('/loco/get-timeline/$locoId'); - final timeline = LocoAttrVersion.fromGroupedJson(json); - _locoTimelines[locoId] = timeline; - return timeline; + final baseJson = await api.get('/loco/get-timeline/$locoId'); + final timeline = LocoAttrVersion.fromGroupedJson(baseJson); + final baseKeys = timeline + .map(_entryKey) + .where((key) => key.isNotEmpty) + .toSet(); + final pendingEntries = []; + final pendingSeen = {}; + + void addPending(List entries) { + for (final entry in entries) { + final key = _entryKey(entry); + if (pendingSeen.contains(key)) continue; + if (baseKeys.contains(key)) continue; + pendingSeen.add(key); + pendingEntries.add(entry); + baseKeys.add(key); + } + } + + try { + final pendingJson = + await api.get('/event/pending/user?loco_id=$locoId'); + addPending( + _parsePendingLocoEvents( + pendingJson, + locoId, + canModerate: false, + ), + ); + } catch (e) { + debugPrint('Failed to fetch pending loco events for $locoId: $e'); + } + + if (includeAllPending) { + try { + final pendingJson = await api.get('/event/pending?loco_id=$locoId'); + addPending( + _parsePendingLocoEvents( + pendingJson, + locoId, + canModerate: true, + ), + ); + } catch (e) { + debugPrint('Failed to fetch all pending loco events for $locoId: $e'); + } + } + + final merged = [ + ...timeline, + ...pendingEntries, + ]..sort(LocoAttrVersion.compareByStart); + _locoTimelines[locoId] = merged; + return merged; } catch (e) { debugPrint('Failed to fetch loco timeline for $locoId: $e'); _locoTimelines[locoId] = []; @@ -84,6 +138,173 @@ extension DataServiceTraction on DataService { } } + Future> fetchUserPendingEvents(int locoId) async { + try { + final pendingJson = await api.get('/event/pending/user?loco_id=$locoId'); + return _parsePendingLocoEvents( + pendingJson, + locoId, + canModerate: false, + ); + } catch (e) { + debugPrint('Failed to fetch user pending events for $locoId: $e'); + return const []; + } + } + + List _parsePendingLocoEvents( + dynamic json, + int fallbackLocoId, { + bool canModerate = false, + }) { + if (json is! List) return const []; + final entries = []; + final seen = {}; + for (final item in json) { + if (item is! Map) continue; + final map = _extractPendingEventMap(item); + final locoId = (map['loco_id'] as num?)?.toInt() ?? fallbackLocoId; + final maskedValidFrom = map['masked_valid_from']?.toString(); + final precision = map['precision_level']?.toString(); + final username = map['username']?.toString(); + final sourceEventId = (map['loco_event_id'] as num?)?.toInt(); + final startDate = _parsePendingDate(map); + final status = map['moderation_status']?.toString().toLowerCase(); + if (status != null && status != 'pending') continue; + if (startDate == null && + (maskedValidFrom == null || maskedValidFrom.trim().isEmpty)) { + continue; + } + final valueMap = _decodeEventValues(map['loco_event_value']); + if (valueMap.isEmpty) continue; + + valueMap.forEach((attr, rawValue) { + final attrCode = attr.toString(); + if (attrCode.isEmpty) return; + final key = [ + sourceEventId?.toString() ?? '', + attrCode.toLowerCase(), + maskedValidFrom ?? '', + startDate?.toIso8601String() ?? '', + ].join('|'); + if (seen.contains(key)) return; + seen.add(key); + final parsedValue = _PendingTimelineValue.fromDynamic(rawValue); + entries.add( + LocoAttrVersion( + attrCode: attrCode, + locoId: locoId, + valueStr: parsedValue.valueStr, + valueInt: parsedValue.valueInt, + valueBool: parsedValue.valueBool, + valueDate: parsedValue.valueDate, + valueNorm: parsedValue.valueNorm ?? rawValue, + validFrom: startDate, + maskedValidFrom: maskedValidFrom, + precisionLevel: precision, + suggestedBy: username, + sourceEventId: sourceEventId, + isPending: true, + canModeratePending: canModerate, + ), + ); + }); + } + return entries; + } + + String _entryKey(LocoAttrVersion entry) { + final attr = entry.attrCode.toLowerCase(); + final masked = entry.maskedValidFrom?.trim() ?? ''; + final start = entry.validFrom?.toIso8601String() ?? ''; + final source = entry.sourceEventId?.toString() ?? ''; + return '$attr|$masked|$start|$source'; + } + + Map _extractPendingEventMap(Map raw) { + if (raw['event_info'] is Map) { + final eventInfo = Map.from(raw['event_info']); + final merged = {...eventInfo}; + for (final key in [ + 'loco_id', + 'masked_valid_from', + 'precision_level', + 'username', + 'loco_event_id', + 'earliest_date', + 'valid_from', + 'loco_event_date', + 'event_year', + 'event_month', + 'event_day', + 'loco_event_value', + ]) { + merged.putIfAbsent(key, () => raw[key]); + } + return merged; + } + return Map.from(raw); + } + + Map _decodeEventValues(dynamic raw) { + if (raw is Map) { + return raw.map((key, value) => MapEntry(key.toString(), value)); + } + if (raw is String) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return const {}; + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map) { + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + } catch (_) {} + } + return const {}; + } + + DateTime? _parsePendingDate(Map json) { + DateTime? parseDate(dynamic value) { + if (value == null) return null; + if (value is DateTime) return value; + return DateTime.tryParse(value.toString()); + } + + // Try masked date by normalising unknown parts to 01 so we can position on the axis. + final masked = json['masked_valid_from']?.toString(); + if (masked is String && masked.contains('-')) { + final sanitized = masked + .replaceAll(RegExp(r'[Xx?]{2}'), '01') + .replaceAll(RegExp(r'[Xx?]'), '1'); + final parsedMasked = DateTime.tryParse(sanitized); + if (parsedMasked != null) return parsedMasked; + } + + for (final key in ['earliest_date', 'valid_from', 'loco_event_date']) { + final parsed = parseDate(json[key]); + if (parsed != null) return parsed; + } + + final year = _asNullableInt(json['event_year']); + if (year != null && year > 0) { + final monthValue = _asNullableInt(json['event_month']) ?? 1; + final dayValue = _asNullableInt(json['event_day']) ?? 1; + final month = monthValue.clamp(1, 12).toInt(); + final day = dayValue.clamp(1, 31).toInt(); + try { + return DateTime(year, month, day); + } catch (_) {} + } + return null; + } + + int? _asNullableInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value.toString()); + } + Future createLoco(Map payload) async { try { final response = await api.put('/loco/new', payload); @@ -101,8 +322,9 @@ extension DataServiceTraction on DataService { } } - Future> fetchClassList() async { - if (_locoClasses.isNotEmpty) return _locoClasses; + Future> fetchClassList({bool force = false}) async { + if (!force && _locoClasses.isNotEmpty) return _locoClasses; + if (force) _locoClasses = []; try { final json = await api.get('/loco/classlist'); if (json is List) { @@ -214,4 +436,90 @@ extension DataServiceTraction on DataService { return const []; } } + + Future acceptPendingLoco({required int locoId}) async { + try { + await api.put('/loco/pending/approve/$locoId', null); + } catch (e) { + debugPrint('Failed to approve pending loco $locoId: $e'); + rethrow; + } + } + + Future rejectPendingLoco({ + required int locoId, + int? replacementLocoId, + String? rejectedReason, + }) async { + try { + final body = {}; + if (replacementLocoId != null) { + body['replacement_loco_id'] = replacementLocoId; + } + if (rejectedReason != null && rejectedReason.trim().isNotEmpty) { + body['rejected_reason'] = rejectedReason.trim(); + } + await api.put('/loco/pending/reject/$locoId', body.isEmpty ? null : body); + } catch (e) { + debugPrint('Failed to reject pending loco $locoId: $e'); + rethrow; + } + } + + Future adminDeleteLoco({required int locoId}) async { + try { + await api.delete('/loco/admin/delete/$locoId'); + } catch (e) { + debugPrint('Failed to delete loco $locoId as admin: $e'); + rethrow; + } + } +} + +class _PendingTimelineValue { + final String? valueStr; + final int? valueInt; + final bool? valueBool; + final DateTime? valueDate; + final dynamic valueNorm; + + const _PendingTimelineValue({ + this.valueStr, + this.valueInt, + this.valueBool, + this.valueDate, + this.valueNorm, + }); + + factory _PendingTimelineValue.fromDynamic(dynamic raw) { + if (raw is Map || raw is List) { + return _PendingTimelineValue(valueStr: jsonEncode(raw)); + } + if (raw is bool) return _PendingTimelineValue(valueBool: raw); + if (raw is int) return _PendingTimelineValue(valueInt: raw); + if (raw is num) return _PendingTimelineValue(valueNorm: raw); + if (raw is String) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return const _PendingTimelineValue(valueStr: ''); + final lower = trimmed.toLowerCase(); + if (['true', 'yes', 'y'].contains(lower)) { + return const _PendingTimelineValue(valueBool: true); + } + if (['false', 'no', 'n'].contains(lower)) { + return const _PendingTimelineValue(valueBool: false); + } + final intVal = int.tryParse(trimmed); + if (intVal != null) return _PendingTimelineValue(valueInt: intVal); + final doubleVal = double.tryParse(trimmed); + if (doubleVal != null) { + return _PendingTimelineValue(valueNorm: doubleVal); + } + final dateVal = DateTime.tryParse(trimmed); + if (dateVal != null) { + return _PendingTimelineValue(valueDate: dateVal); + } + return _PendingTimelineValue(valueStr: trimmed); + } + return _PendingTimelineValue(valueNorm: raw); + } } diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index b06b5f7..75a81dc 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -20,6 +20,7 @@ import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; +import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart'; import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart'; @@ -170,7 +171,40 @@ class _MyAppState extends State { ), GoRoute( path: '/traction', - builder: (context, state) => TractionPage(), + builder: (context, state) { + final selectionParam = + state.uri.queryParameters['selection'] ?? + (state.extra is Map + ? (state.extra as Map)['selection']?.toString() + : null); + final replacementPendingLocoIdStr = + state.uri.queryParameters['replacementPendingLocoId']; + final replacementPendingLocoId = replacementPendingLocoIdStr != null + ? int.tryParse(replacementPendingLocoIdStr) + : state.extra is Map + ? int.tryParse( + (state.extra as Map)['replacementPendingLocoId'] + ?.toString() ?? + '', + ) + : null; + final selectionMode = + (selectionParam != null && selectionParam.isNotEmpty) || + replacementPendingLocoId != null; + final selectionSingle = replacementPendingLocoId != null || + selectionParam?.toLowerCase() == 'single' || + selectionParam == '1' || + selectionParam?.toLowerCase() == 'true'; + return TractionPage( + selectionMode: selectionMode, + selectionSingle: selectionSingle, + replacementPendingLocoId: replacementPendingLocoId, + ); + }, + ), + GoRoute( + path: '/traction/pending', + builder: (context, state) => const TractionPendingPage(), ), GoRoute( path: '/profile', diff --git a/pubspec.yaml b/pubspec.yaml index 7487ad4..e2027ba 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.6.5+7 +version: 0.7.0+8 environment: sdk: ^3.8.1