From f06a1c75b63444eeaf1e2e8204208a3cc0b9dff6 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Tue, 6 Jan 2026 14:44:12 +0000 Subject: [PATCH] Fix transfer functoin, add display for numer of pending locos --- .../pages/traction/traction_page.dart | 163 +++++++++++++++++- lib/components/traction/traction_card.dart | 24 +-- .../data_service/data_service_core.dart | 18 ++ .../data_service/data_service_traction.dart | 5 +- lib/ui/app_shell.dart | 5 + pubspec.yaml | 2 +- 6 files changed, 184 insertions(+), 33 deletions(-) diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 3835f7d..d5b75e9 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -12,6 +12,7 @@ class TractionPage extends StatefulWidget { this.selectionMode = false, this.selectionSingle = false, this.replacementPendingLocoId, + this.transferFromLabel, this.transferFromLocoId, this.onSelect, this.selectedKeys = const {}, @@ -20,6 +21,7 @@ class TractionPage extends StatefulWidget { final bool selectionMode; final bool selectionSingle; final int? replacementPendingLocoId; + final String? transferFromLabel; final int? transferFromLocoId; final ValueChanged? onSelect; final Set selectedKeys; @@ -61,6 +63,8 @@ class _TractionPageState extends State { static const int _pageSize = 100; int _lastTractionOffset = 0; String? _lastQuerySignature; + String? _transferFromLabel; + bool _isSearching = false; @override void initState() { @@ -74,6 +78,7 @@ class _TractionPageState extends State { if (!_initialised) { _initialised = true; _selectedKeys = {...widget.selectedKeys}; + _transferFromLabel = widget.transferFromLabel; WidgetsBinding.instance.addPostFrameCallback((_) { _initialLoad(); }); @@ -82,12 +87,16 @@ class _TractionPageState extends State { Future _initialLoad() async { final data = context.read(); + final auth = context.read(); await _restoreSearchState(); if (_lastTractionOffset == 0 && data.traction.length > _pageSize) { _lastTractionOffset = data.traction.length - _pageSize; } data.fetchClassList(); data.fetchEventFields(); + if (auth.isElevated) { + unawaited(data.fetchPendingLocoCount()); + } await _refreshTraction(); } @@ -153,6 +162,7 @@ class _TractionPageState extends State { bool append = false, bool preservePosition = true, }) async { + _setState(() => _isSearching = true); final data = context.read(); final filters = {}; final name = _nameController.text.trim(); @@ -211,6 +221,7 @@ class _TractionPageState extends State { } await _persistSearchState(); + _setState(() => _isSearching = false); } void _clearFilters() { @@ -309,6 +320,7 @@ class _TractionPageState extends State { final isMobile = MediaQuery.of(context).size.width < 700; _syncControllersForFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields); + final transferBanner = _buildTransferBanner(data); final slivers = [ SliverPadding( @@ -338,6 +350,10 @@ class _TractionPageState extends State { _buildHeaderActions(context, isMobile), ], ), + if (transferBanner != null) ...[ + const SizedBox(height: 8), + transferBanner, + ], const SizedBox(height: 12), Card( child: Padding( @@ -487,8 +503,25 @@ class _TractionPageState extends State { ), ElevatedButton.icon( onPressed: _refreshTraction, - icon: const Icon(Icons.search), - label: const Text('Search'), + icon: (_isSearching || data.isTractionLoading) + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context) + .colorScheme + .onPrimary, + ), + ), + ) + : const Icon(Icons.search), + label: Text( + (_isSearching || data.isTractionLoading) + ? 'Searching...' + : 'Search', + ), ), ], ), @@ -613,6 +646,12 @@ class _TractionPageState extends State { Widget _buildHeaderActions(BuildContext context, bool isMobile) { final isElevated = context.read().isElevated; + final data = context.watch(); + final pendingCount = data.pendingLocoCount; + String? pendingLabel; + if (pendingCount > 0) { + pendingLabel = pendingCount > 999 ? '999+' : pendingCount.toString(); + } final refreshButton = IconButton( tooltip: 'Refresh', onPressed: _refreshTraction, @@ -659,6 +698,7 @@ class _TractionPageState extends State { try { await context.push('/traction/pending'); if (!mounted) return; + await data.fetchPendingLocoCount(); } catch (_) { if (!mounted) return; messenger.showSnackBar( @@ -693,9 +733,16 @@ class _TractionPageState extends State { } if (hasAdminActions) { items.add( - const PopupMenuItem( + PopupMenuItem( value: _TractionMoreAction.adminPending, - child: Text('Pending locos'), + child: Row( + children: [ + const Text('Pending locos'), + const Spacer(), + if (pendingLabel != null) + _countChip(context, pendingLabel), + ], + ), ), ); } @@ -705,7 +752,16 @@ class _TractionPageState extends State { child: FilledButton.tonalIcon( onPressed: () {}, icon: const Icon(Icons.more_horiz), - label: const Text('More'), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('More'), + if (pendingLabel != null) ...[ + const SizedBox(width: 6), + _countChip(context, pendingLabel), + ], + ], + ), ), ), ); @@ -1208,13 +1264,35 @@ class _TractionPageState extends State { if (fromId == null) return; final navContext = context; final messenger = ScaffoldMessenger.of(navContext); + final data = navContext.read(); + final fromLoco = data.traction.firstWhere( + (l) => l.id == fromId, + orElse: () => target, + ); + final fromLabel = '${fromLoco.locoClass} ${fromLoco.number}'.trim(); + final toLabel = '${target.locoClass} ${target.number}'.trim(); final confirmed = await showDialog( context: navContext, builder: (dialogContext) { return AlertDialog( title: const Text('Transfer allocations?'), - content: Text( - 'Transfer all allocations from this loco to ${target.locoClass} ${target.number}?', + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Transfer all allocations from $fromLabel to $toLabel?', + ), + const SizedBox(height: 12), + Text( + 'From: $fromLabel', + style: Theme.of(dialogContext) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + Text('To: $toLabel'), + ], ), actions: [ TextButton( @@ -1273,6 +1351,69 @@ class _TractionPageState extends State { ); } + Widget? _buildTransferBanner(DataService data) { + final fromId = _transferFromLocoId; + if (fromId == null) return null; + if (_transferFromLabel == null || _transferFromLabel!.trim().isEmpty) { + final from = data.traction.firstWhere( + (loco) => loco.id == fromId, + orElse: () => LocoSummary( + locoId: fromId, + locoType: '', + locoNumber: '', + locoName: '', + locoClass: '', + locoOperator: '', + ), + ); + final fallbackLabel = '${from.locoClass} ${from.number}'.trim().isEmpty + ? 'Loco $fromId' + : '${from.locoClass} ${from.number}'.trim(); + _transferFromLabel = fallbackLabel; + } + final label = _transferFromLabel ?? 'Loco $fromId'; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + const Icon(Icons.swap_horiz, color: Colors.orange), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Transferring allocations from $label. Select a loco to transfer to.', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + Widget _countChip(BuildContext context, String label) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: scheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: TextStyle( + color: scheme.onPrimary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ); + } + Widget _buildFilterInput( BuildContext context, EventField field, @@ -1395,12 +1536,16 @@ class _TractionPageState extends State { onOpenLegs: () => _openLegs(loco), onActionComplete: _refreshTraction, onToggleSelect: widget.selectionMode && - widget.replacementPendingLocoId == null + widget.replacementPendingLocoId == null && + (widget.transferFromLocoId == null || + widget.transferFromLocoId != loco.id) ? () => _toggleSelection(loco) : null, onReplacePending: widget.selectionMode && widget.selectionSingle && - widget.replacementPendingLocoId != null + widget.replacementPendingLocoId != null && + (widget.transferFromLocoId == null || + widget.transferFromLocoId != loco.id) ? () => _confirmReplacePending(loco) : null, onTransferAllocations: widget.selectionMode && diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index c3c8996..e71f8b5 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -635,27 +635,6 @@ Future showTractionDetails( child: ListView( controller: controller, children: [ - FilledButton.icon( - onPressed: () { - Navigator.of(ctx).pop(); - navContext.push( - Uri( - path: '/traction', - queryParameters: { - 'selection': 'single', - 'transferFromLocoId': loco.id.toString(), - }, - ).toString(), - extra: { - 'selection': 'single', - 'transferFromLocoId': loco.id, - }, - ); - }, - icon: const Icon(Icons.swap_horiz), - label: const Text('Transfer allocations'), - ), - const SizedBox(height: 12), if (isRejected && rejectedReason.isNotEmpty) ...[ _detailRow( @@ -748,17 +727,20 @@ Future showTractionDetails( FilledButton.icon( onPressed: () { Navigator.of(ctx).pop(); + final transferLabel = '${loco.locoClass} ${loco.number}'.trim(); navContext.push( Uri( path: '/traction', queryParameters: { 'selection': 'single', 'transferFromLocoId': loco.id.toString(), + 'transferFromLabel': transferLabel, }, ).toString(), extra: { 'selection': 'single', 'transferFromLocoId': loco.id, + 'transferFromLabel': transferLabel, }, ); }, diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 4ca69f0..e4f151b 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -52,6 +52,8 @@ class DataService extends ChangeNotifier { bool get isTractionLoading => _isTractionLoading; bool _tractionHasMore = false; bool get tractionHasMore => _tractionHasMore; + int _pendingLocoCount = 0; + int get pendingLocoCount => _pendingLocoCount; List _latestLocoChanges = []; List get latestLocoChanges => _latestLocoChanges; bool _isLatestLocoChangesLoading = false; @@ -94,6 +96,22 @@ class DataService extends ChangeNotifier { bool _isEventFieldsLoading = false; bool get isEventFieldsLoading => _isEventFieldsLoading; + Future fetchPendingLocoCount() async { + try { + final json = await api.get('/loco/pending?limit=1000&offset=0'); + if (json is List) { + _pendingLocoCount = json.length; + } else { + _pendingLocoCount = 0; + } + } catch (e) { + debugPrint('Failed to fetch pending loco count: $e'); + _pendingLocoCount = 0; + } finally { + _notifyAsync(); + } + } + // Station Data final Map> _stationCache = {}; final Map>?> _stationInFlightByKey = {}; diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart index 78ef0ed..fc75d86 100644 --- a/lib/services/data_service/data_service_traction.dart +++ b/lib/services/data_service/data_service_traction.dart @@ -62,8 +62,9 @@ extension DataServiceTraction on DataService { _tractionHasMore = false; } finally { _isTractionLoading = false; - _notifyAsync(); - } + _notifyAsync(); + } + } Future> fetchLocoTimeline( diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index 8ce504a..d9b4739 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -195,6 +195,10 @@ class _MyAppState extends State { '', ) : null; + final transferFromLabel = state.uri.queryParameters['transferFromLabel'] ?? + (state.extra is Map + ? (state.extra as Map)['transferFromLabel']?.toString() + : null); final selectionMode = (selectionParam != null && selectionParam.isNotEmpty) || replacementPendingLocoId != null || @@ -208,6 +212,7 @@ class _MyAppState extends State { selectionMode: selectionMode, selectionSingle: selectionSingle, replacementPendingLocoId: replacementPendingLocoId, + transferFromLabel: transferFromLabel, transferFromLocoId: transferFromLocoId, ); }, diff --git a/pubspec.yaml b/pubspec.yaml index 596ef2c..94f8e19 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.7.1+9 +version: 0.7.2+10 environment: sdk: ^3.8.1