Compare commits

...

1 Commits

Author SHA1 Message Date
f06a1c75b6 Fix transfer functoin, add display for numer of pending locos
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m10s
Release / web-build (push) Successful in 1m19s
Release / android-build (push) Successful in 9m2s
Release / release-master (push) Successful in 17s
Release / release-dev (push) Successful in 19s
2026-01-06 14:44:12 +00:00
6 changed files with 184 additions and 33 deletions

View File

@@ -12,6 +12,7 @@ class TractionPage extends StatefulWidget {
this.selectionMode = false, this.selectionMode = false,
this.selectionSingle = false, this.selectionSingle = false,
this.replacementPendingLocoId, this.replacementPendingLocoId,
this.transferFromLabel,
this.transferFromLocoId, this.transferFromLocoId,
this.onSelect, this.onSelect,
this.selectedKeys = const {}, this.selectedKeys = const {},
@@ -20,6 +21,7 @@ class TractionPage extends StatefulWidget {
final bool selectionMode; final bool selectionMode;
final bool selectionSingle; final bool selectionSingle;
final int? replacementPendingLocoId; final int? replacementPendingLocoId;
final String? transferFromLabel;
final int? transferFromLocoId; final int? transferFromLocoId;
final ValueChanged<LocoSummary>? onSelect; final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys; final Set<String> selectedKeys;
@@ -61,6 +63,8 @@ class _TractionPageState extends State<TractionPage> {
static const int _pageSize = 100; static const int _pageSize = 100;
int _lastTractionOffset = 0; int _lastTractionOffset = 0;
String? _lastQuerySignature; String? _lastQuerySignature;
String? _transferFromLabel;
bool _isSearching = false;
@override @override
void initState() { void initState() {
@@ -74,6 +78,7 @@ class _TractionPageState extends State<TractionPage> {
if (!_initialised) { if (!_initialised) {
_initialised = true; _initialised = true;
_selectedKeys = {...widget.selectedKeys}; _selectedKeys = {...widget.selectedKeys};
_transferFromLabel = widget.transferFromLabel;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_initialLoad(); _initialLoad();
}); });
@@ -82,12 +87,16 @@ class _TractionPageState extends State<TractionPage> {
Future<void> _initialLoad() async { Future<void> _initialLoad() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final auth = context.read<AuthService>();
await _restoreSearchState(); await _restoreSearchState();
if (_lastTractionOffset == 0 && data.traction.length > _pageSize) { if (_lastTractionOffset == 0 && data.traction.length > _pageSize) {
_lastTractionOffset = data.traction.length - _pageSize; _lastTractionOffset = data.traction.length - _pageSize;
} }
data.fetchClassList(); data.fetchClassList();
data.fetchEventFields(); data.fetchEventFields();
if (auth.isElevated) {
unawaited(data.fetchPendingLocoCount());
}
await _refreshTraction(); await _refreshTraction();
} }
@@ -153,6 +162,7 @@ class _TractionPageState extends State<TractionPage> {
bool append = false, bool append = false,
bool preservePosition = true, bool preservePosition = true,
}) async { }) async {
_setState(() => _isSearching = true);
final data = context.read<DataService>(); final data = context.read<DataService>();
final filters = <String, dynamic>{}; final filters = <String, dynamic>{};
final name = _nameController.text.trim(); final name = _nameController.text.trim();
@@ -211,6 +221,7 @@ class _TractionPageState extends State<TractionPage> {
} }
await _persistSearchState(); await _persistSearchState();
_setState(() => _isSearching = false);
} }
void _clearFilters() { void _clearFilters() {
@@ -309,6 +320,7 @@ class _TractionPageState extends State<TractionPage> {
final isMobile = MediaQuery.of(context).size.width < 700; final isMobile = MediaQuery.of(context).size.width < 700;
_syncControllersForFields(data.eventFields); _syncControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields);
final transferBanner = _buildTransferBanner(data);
final slivers = <Widget>[ final slivers = <Widget>[
SliverPadding( SliverPadding(
@@ -338,6 +350,10 @@ class _TractionPageState extends State<TractionPage> {
_buildHeaderActions(context, isMobile), _buildHeaderActions(context, isMobile),
], ],
), ),
if (transferBanner != null) ...[
const SizedBox(height: 8),
transferBanner,
],
const SizedBox(height: 12), const SizedBox(height: 12),
Card( Card(
child: Padding( child: Padding(
@@ -487,8 +503,25 @@ class _TractionPageState extends State<TractionPage> {
), ),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _refreshTraction, onPressed: _refreshTraction,
icon: const Icon(Icons.search), icon: (_isSearching || data.isTractionLoading)
label: const Text('Search'), ? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context)
.colorScheme
.onPrimary,
),
),
)
: const Icon(Icons.search),
label: Text(
(_isSearching || data.isTractionLoading)
? 'Searching...'
: 'Search',
),
), ),
], ],
), ),
@@ -613,6 +646,12 @@ class _TractionPageState extends State<TractionPage> {
Widget _buildHeaderActions(BuildContext context, bool isMobile) { Widget _buildHeaderActions(BuildContext context, bool isMobile) {
final isElevated = context.read<AuthService>().isElevated; final isElevated = context.read<AuthService>().isElevated;
final data = context.watch<DataService>();
final pendingCount = data.pendingLocoCount;
String? pendingLabel;
if (pendingCount > 0) {
pendingLabel = pendingCount > 999 ? '999+' : pendingCount.toString();
}
final refreshButton = IconButton( final refreshButton = IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: _refreshTraction, onPressed: _refreshTraction,
@@ -659,6 +698,7 @@ class _TractionPageState extends State<TractionPage> {
try { try {
await context.push('/traction/pending'); await context.push('/traction/pending');
if (!mounted) return; if (!mounted) return;
await data.fetchPendingLocoCount();
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
@@ -693,9 +733,16 @@ class _TractionPageState extends State<TractionPage> {
} }
if (hasAdminActions) { if (hasAdminActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.adminPending, 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<TractionPage> {
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: () {}, onPressed: () {},
icon: const Icon(Icons.more_horiz), 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<TractionPage> {
if (fromId == null) return; if (fromId == null) return;
final navContext = context; final navContext = context;
final messenger = ScaffoldMessenger.of(navContext); final messenger = ScaffoldMessenger.of(navContext);
final data = navContext.read<DataService>();
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<bool>( final confirmed = await showDialog<bool>(
context: navContext, context: navContext,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Transfer allocations?'), title: const Text('Transfer allocations?'),
content: Text( content: Column(
'Transfer all allocations from this loco to ${target.locoClass} ${target.number}?', 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: [ actions: [
TextButton( TextButton(
@@ -1273,6 +1351,69 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
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( Widget _buildFilterInput(
BuildContext context, BuildContext context,
EventField field, EventField field,
@@ -1395,12 +1536,16 @@ class _TractionPageState extends State<TractionPage> {
onOpenLegs: () => _openLegs(loco), onOpenLegs: () => _openLegs(loco),
onActionComplete: _refreshTraction, onActionComplete: _refreshTraction,
onToggleSelect: widget.selectionMode && onToggleSelect: widget.selectionMode &&
widget.replacementPendingLocoId == null widget.replacementPendingLocoId == null &&
(widget.transferFromLocoId == null ||
widget.transferFromLocoId != loco.id)
? () => _toggleSelection(loco) ? () => _toggleSelection(loco)
: null, : null,
onReplacePending: widget.selectionMode && onReplacePending: widget.selectionMode &&
widget.selectionSingle && widget.selectionSingle &&
widget.replacementPendingLocoId != null widget.replacementPendingLocoId != null &&
(widget.transferFromLocoId == null ||
widget.transferFromLocoId != loco.id)
? () => _confirmReplacePending(loco) ? () => _confirmReplacePending(loco)
: null, : null,
onTransferAllocations: widget.selectionMode && onTransferAllocations: widget.selectionMode &&

View File

@@ -635,27 +635,6 @@ Future<void> showTractionDetails(
child: ListView( child: ListView(
controller: controller, controller: controller,
children: [ 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) if (isRejected && rejectedReason.isNotEmpty)
...[ ...[
_detailRow( _detailRow(
@@ -748,17 +727,20 @@ Future<void> showTractionDetails(
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
final transferLabel = '${loco.locoClass} ${loco.number}'.trim();
navContext.push( navContext.push(
Uri( Uri(
path: '/traction', path: '/traction',
queryParameters: { queryParameters: {
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id.toString(), 'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel,
}, },
).toString(), ).toString(),
extra: { extra: {
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id, 'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
}, },
); );
}, },

View File

@@ -52,6 +52,8 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading; bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false; bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore; bool get tractionHasMore => _tractionHasMore;
int _pendingLocoCount = 0;
int get pendingLocoCount => _pendingLocoCount;
List<LocoChange> _latestLocoChanges = []; List<LocoChange> _latestLocoChanges = [];
List<LocoChange> get latestLocoChanges => _latestLocoChanges; List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false; bool _isLatestLocoChangesLoading = false;
@@ -94,6 +96,22 @@ class DataService extends ChangeNotifier {
bool _isEventFieldsLoading = false; bool _isEventFieldsLoading = false;
bool get isEventFieldsLoading => _isEventFieldsLoading; bool get isEventFieldsLoading => _isEventFieldsLoading;
Future<void> 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 // Station Data
final Map<String, List<Station>> _stationCache = {}; final Map<String, List<Station>> _stationCache = {};
final Map<String, Future<List<Station>>?> _stationInFlightByKey = {}; final Map<String, Future<List<Station>>?> _stationInFlightByKey = {};

View File

@@ -62,8 +62,9 @@ extension DataServiceTraction on DataService {
_tractionHasMore = false; _tractionHasMore = false;
} finally { } finally {
_isTractionLoading = false; _isTractionLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<List<LocoAttrVersion>> fetchLocoTimeline( Future<List<LocoAttrVersion>> fetchLocoTimeline(

View File

@@ -195,6 +195,10 @@ class _MyAppState extends State<MyApp> {
'', '',
) )
: null; : null;
final transferFromLabel = state.uri.queryParameters['transferFromLabel'] ??
(state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString()
: null);
final selectionMode = final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) || (selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null || replacementPendingLocoId != null ||
@@ -208,6 +212,7 @@ class _MyAppState extends State<MyApp> {
selectionMode: selectionMode, selectionMode: selectionMode,
selectionSingle: selectionSingle, selectionSingle: selectionSingle,
replacementPendingLocoId: replacementPendingLocoId, replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId, transferFromLocoId: transferFromLocoId,
); );
}, },

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.8.1 sdk: ^3.8.1