Compare commits

...

3 Commits

Author SHA1 Message Date
559f79b805 add transfer all button for admins
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 1m4s
Release / web-build (push) Successful in 2m47s
Release / android-build (push) Successful in 11m45s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 30s
2026-01-12 17:11:37 +00:00
3b7ec31e5d add refresh button to pending page
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 50s
Release / web-build (push) Successful in 2m23s
Release / android-build (push) Successful in 7m48s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
2026-01-12 16:37:01 +00:00
e9b328e7e6 improve pending visibility, allow multiple users to have pending changes against locos. 2026-01-12 16:16:36 +00:00
9 changed files with 503 additions and 159 deletions

View File

@@ -18,10 +18,12 @@ class LocoTimelinePage extends StatefulWidget {
super.key,
required this.locoId,
required this.locoLabel,
this.forceShowPending = false,
});
final int locoId;
final String locoLabel;
final bool forceShowPending;
@override
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
@@ -41,7 +43,13 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _restorePendingVisibility();
if (widget.forceShowPending) {
setState(() {
_showPending = true;
});
} else {
await _restorePendingVisibility();
}
if (!mounted) return;
await _load();
});

View File

@@ -716,9 +716,14 @@ bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
List<_ValueSegment> _segmentsForEntries(
List<LocoAttrVersion> items,
DateTime now,
) {
DateTime now, {
bool? clampToNextStart,
}) {
if (items.isEmpty) return const [];
final hasPending = items.any((e) => e.isPending);
final hasApproved = items.any((e) => !e.isPending);
final shouldClamp =
clampToNextStart ?? (hasPending && hasApproved);
final sorted = [...items];
sorted.sort(
(a, b) => (_effectiveStart(a) ?? now)
@@ -731,7 +736,13 @@ List<_ValueSegment> _segmentsForEntries(
final nextStart = i < sorted.length - 1
? _effectiveStart(sorted[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
DateTime? rawEnd = entry.validTo;
if (nextStart != null) {
if (rawEnd == null || (shouldClamp && nextStart.isBefore(rawEnd))) {
rawEnd = nextStart;
}
}
rawEnd ??= now;
final end = _safeEnd(start, rawEnd);
segments.add(
_ValueSegment(
@@ -745,6 +756,53 @@ List<_ValueSegment> _segmentsForEntries(
return segments;
}
List<LocoAttrVersion> _applyPendingOverrides(
List<LocoAttrVersion> approved,
List<LocoAttrVersion> pending,
) {
if (pending.isEmpty) return approved;
final pendingByStart = <int, LocoAttrVersion>{};
final extraPending = <LocoAttrVersion>[];
for (final entry in pending) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
pendingByStart[key] = entry;
}
final applied = <LocoAttrVersion>[];
final seenKeys = <int>{};
for (final entry in approved) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
if (pendingByStart.containsKey(key)) {
if (!seenKeys.contains(key)) {
applied.add(pendingByStart[key]!);
seenKeys.add(key);
}
} else {
applied.add(entry);
seenKeys.add(key);
}
}
for (final entry in pendingByStart.values) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
if (!seenKeys.contains(key)) {
extraPending.add(entry);
seenKeys.add(key);
}
}
if (extraPending.isNotEmpty) {
applied.addAll(extraPending);
}
return applied;
}
List<DateTime> _buildBoundaries(
List<_ValueSegment> segments,
DateTime now,
@@ -904,14 +962,17 @@ class _TimelineModel {
}
final hasOverlap = overlapByUser.isNotEmpty;
final canToggle = pending.length > 1 && !hasOverlap;
final canToggle = pending.isNotEmpty && !hasOverlap;
final isExpanded = expandedAttrCodes.contains(attr);
final shouldShowPendingRows = isExpanded || hasOverlap;
final nonOverlapPending =
pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList();
final baseEntries = isExpanded ? approved : [...approved, ...nonOverlapPending];
final baseSegments =
isExpanded ? approvedSegments : _segmentsForEntries(baseEntries, now);
final baseEntries =
shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending];
final baseSegments = shouldShowPendingRows
? approvedSegments
: _segmentsForEntries(baseEntries, now);
rowSpecs.add(
_TimelineRowSpec.primary(
@@ -923,7 +984,6 @@ class _TimelineModel {
);
allSegments.addAll(baseSegments);
final shouldShowPendingRows = isExpanded || hasOverlap;
if (shouldShowPendingRows) {
final users = isExpanded
? pendingByUser.keys.toList()
@@ -934,11 +994,9 @@ class _TimelineModel {
? (pendingByUser[user] ?? const [])
: (overlapByUser[user] ?? const []);
if (pendingEntries.isEmpty) continue;
final userPendingSegments = _segmentsForEntries(pendingEntries, now);
final combinedSegments = [
...approvedSegments,
...userPendingSegments,
];
final appliedEntries =
_applyPendingOverrides(approved, pendingEntries);
final combinedSegments = _segmentsForEntries(appliedEntries, now);
rowSpecs.add(
_TimelineRowSpec.pending(
attrCode: attr,

View File

@@ -130,11 +130,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
int decimals = 1,
bool includeUnit = true,
}) {
return units.format(
miles,
decimals: decimals,
includeUnit: includeUnit,
);
return units.format(miles, decimals: decimals, includeUnit: includeUnit);
}
String _manualMileageLabel(DistanceUnit unit) {
@@ -191,15 +187,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
}
double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(unit)
.parseInputMiles(_mileageController.text.trim()) ??
return DistanceFormatter(
unit,
).parseInputMiles(_mileageController.text.trim()) ??
0;
}
List<UserSummary> _friendsFromFriendships(
DataService data,
String? selfId,
) {
List<UserSummary> _friendsFromFriendships(DataService data, String? selfId) {
final friends = <UserSummary>[];
for (final f in data.friendships) {
final other = _friendFromFriendship(f, selfId);
@@ -300,9 +294,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (!mounted) return;
final baseFriends = _friendsFromFriendships(data, auth.userId);
final initialSelectedIds = {..._shareUserIds};
final initialSelectedUsers = {
for (final u in _shareUsers) u.userId: u,
};
final initialSelectedUsers = {for (final u in _shareUsers) u.userId: u};
final result = await showModalBottomSheet<List<UserSummary>>(
context: context,
@@ -334,8 +326,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
searchError = null;
});
try {
final results =
await data.searchUsers(trimmed, friendsOnly: true);
final results = await data.searchUsers(
trimmed,
friendsOnly: true,
);
setModalState(() {
searchResults = results;
});
@@ -414,8 +408,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
itemCount: list.length,
itemBuilder: (_, index) {
final user = list[index];
final isSelected =
selectedIds.contains(user.userId);
final isSelected = selectedIds.contains(
user.userId,
);
return CheckboxListTile(
value: isSelected,
title: Text(user.displayName),
@@ -481,8 +476,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (previousUnit == currentUnit) return;
final miles = _milesFromInputWithUnit(previousUnit);
final nextText = DistanceFormatter(currentUnit)
.format(miles, decimals: 2, includeUnit: false);
final nextText = DistanceFormatter(
currentUnit,
).format(miles, decimals: 2, includeUnit: false);
_mileageController.text = nextText;
_lastDistanceUnit = currentUnit;
}
@@ -842,8 +838,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
final destination = json['leg_destination'] as String? ?? '';
final hasEndTime = endTime != null || endDelay != 0;
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
final destinationTime =
DateTime.tryParse(json['leg_destination_time'] ?? '');
final destinationTime = DateTime.tryParse(
json['leg_destination_time'] ?? '',
);
final hasOriginTime = originTime != null;
final hasDestinationTime = destinationTime != null;
@@ -860,8 +857,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime =
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual;
@@ -927,18 +925,20 @@ class _NewEntryPageState extends State<NewEntryPage> {
);
final tractionItems = _buildTractionFromApi(
entry.locos
.map((l) => {
"loco_id": l.id,
"type": l.type,
"number": l.number,
"class": l.locoClass,
"name": l.name,
"operator": l.operator,
"notes": l.notes,
"evn": l.evn,
"alloc_pos": l.allocPos,
"alloc_powering": l.powering ? 1 : 0,
})
.map(
(l) => {
"loco_id": l.id,
"type": l.type,
"number": l.number,
"class": l.locoClass,
"name": l.name,
"operator": l.operator,
"notes": l.notes,
"evn": l.evn,
"alloc_pos": l.allocPos,
"alloc_powering": l.powering ? 1 : 0,
},
)
.toList(),
);
final beginDelay = entry.beginDelayMinutes ?? 0;
@@ -963,8 +963,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime =
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual;
@@ -980,12 +981,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
_endDelayController.text = endDelay.toString();
_mileageController.text = mileageVal == 0
? ''
: _formatDistance(
units,
mileageVal,
decimals: 2,
includeUnit: false,
);
: _formatDistance(units, mileageVal, decimals: 2, includeUnit: false);
_tractionItems
..clear()
..addAll(tractionItems);
@@ -1187,10 +1183,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
],
if (!matchValue) ...[
_stationField(
label: label,
controller: controller,
),
_stationField(label: label, controller: controller),
CheckboxListTile(
value: hasTime,
onChanged: onTimeChanged,
@@ -1237,8 +1230,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed:
_isEditing || _activeLegShare != null ? null : _openDrafts,
onPressed: _isEditing || _activeLegShare != null
? null
: _openDrafts,
icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'),
),
@@ -1246,26 +1240,27 @@ class _NewEntryPageState extends State<NewEntryPage> {
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing ||
_savingDraft ||
_submitting ||
_activeLegShare != null
? null
: _saveDraftManually,
icon: _savingDraft
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed:
_isEditing ||
_savingDraft ||
_submitting ||
_activeLegShare != null
? null
: _saveDraftManually,
icon: _savingDraft
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_alt, size: 16),
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
),
const Spacer(),
TextButton.icon(
),
const Spacer(),
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 36),
@@ -1276,17 +1271,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
: () => _resetFormState(clearDraft: true),
icon: const Icon(Icons.clear, size: 16),
label: const Text('Clear form'),
),
],
),
],
),
if (_activeLegShare != null) ...[
const SizedBox(height: 8),
_buildSharedBanner(),
],
if (_activeLegShare != null) ...[
const SizedBox(height: 8),
_buildSharedBanner(),
],
_buildTripSelector(context),
const SizedBox(height: 8),
if (_activeLegShare == null) _buildShareSection(context),
_dateTimeGroup(
_dateTimeGroup(
context,
title: 'Departure time',
onDateTap: _pickDate,
@@ -1393,8 +1388,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
onTimeChanged: _submitting ? null : _toggleDestinationTime,
matchLabel: 'Match entry end',
matchValue: _matchDestinationToEntry,
onMatchChanged:
_submitting ? null : _toggleMatchDestination,
onMatchChanged: _submitting ? null : _toggleMatchDestination,
pickerBuilder: () => _dateTimeGroupSimple(
context,
title: 'Destination arrival',
@@ -1428,7 +1422,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final tractionPanel = _section('Traction', [
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
child: FilledButton.icon(
onPressed: _openTractionPicker,
icon: const Icon(Icons.search),
label: const Text('Search traction'),
@@ -1440,13 +1434,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.first
: (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.first
: _startController.text.trim());
? _routeResult!.inputRoute.first
: _startController.text.trim());
final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.last
: (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.last
: _endController.text.trim());
? _routeResult!.inputRoute.last
: _endController.text.trim());
final mileagePanel = _section(
'Your Journey',
@@ -1456,12 +1450,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
FilledButton.icon(
onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'),
),
OutlinedButton.icon(
TextButton.icon(
onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
@@ -1493,8 +1487,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
decoration: InputDecoration(
labelText: mileageLabel,
helperText: currentDistanceUnit ==
DistanceUnit.milesChains
helperText:
currentDistanceUnit == DistanceUnit.milesChains
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
: null,
border: const OutlineInputBorder(),
@@ -1571,7 +1565,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
],
),
const SizedBox(height: 12),
ElevatedButton.icon(
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
@@ -1783,52 +1777,52 @@ class _NewEntryPageState extends State<NewEntryPage> {
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
if (textEditingController.text != controller.text) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (textEditingController.text != controller.text) {
textEditingController.value = controller.value;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (textEditingController.text != controller.text) {
textEditingController.value = controller.value;
}
});
}
});
}
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: _loadingStations
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(4.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.search),
),
textInputAction: TextInputAction.done,
onChanged: (_) {
controller.value = textEditingController.value;
_saveDraft();
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: _loadingStations
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(4.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.search),
),
textInputAction: TextInputAction.done,
onChanged: (_) {
controller.value = textEditingController.value;
_saveDraft();
},
onFieldSubmitted: (_) {
final matches = _matchStations(
textEditingController.text,
stationNames,
).toList();
if (matches.isNotEmpty) {
final top = matches.first;
controller.text = top;
textEditingController.text = top;
_saveDraft();
}
focusNode.unfocus();
},
);
},
onFieldSubmitted: (_) {
final matches = _matchStations(
textEditingController.text,
stationNames,
).toList();
if (matches.isNotEmpty) {
final top = matches.first;
controller.text = top;
textEditingController.text = top;
_saveDraft();
}
focusNode.unfocus();
},
);
},
);
}
@@ -1842,7 +1836,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
return best;
}
void _insertCandidate(List<String> best, String candidate, {required int max}) {
void _insertCandidate(
List<String> best,
String candidate, {
required int max,
}) {
final existingIndex = best.indexOf(candidate);
if (existingIndex >= 0) return;
@@ -2016,7 +2014,6 @@ class _NewEntryPageState extends State<NewEntryPage> {
],
);
}
}
class _UpperCaseTextFormatter extends TextInputFormatter {

View File

@@ -4,6 +4,7 @@ enum _TractionMoreAction {
classStats,
classLeaderboard,
adminPending,
adminPendingChanges,
}
class TractionPage extends StatefulWidget {
@@ -14,6 +15,7 @@ class TractionPage extends StatefulWidget {
this.replacementPendingLocoId,
this.transferFromLabel,
this.transferFromLocoId,
this.transferAllAllocations = false,
this.onSelect,
this.selectedKeys = const {},
});
@@ -23,6 +25,7 @@ class TractionPage extends StatefulWidget {
final int? replacementPendingLocoId;
final String? transferFromLabel;
final int? transferFromLocoId;
final bool transferAllAllocations;
final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys;
@@ -38,6 +41,12 @@ class _TractionPageState extends State<TractionPage> {
bool _mileageFirst = true;
bool _initialised = false;
int? get _transferFromLocoId => widget.transferFromLocoId;
bool get _transferAllAllocations {
if (widget.transferAllAllocations) return true;
final param =
GoRouterState.of(context).uri.queryParameters['transferAll'];
return param?.toLowerCase() == 'true' || param == '1';
}
bool _showAdvancedFilters = false;
String? _selectedClass;
late Set<String> _selectedKeys;
@@ -708,6 +717,19 @@ class _TractionPageState extends State<TractionPage> {
);
}
break;
case _TractionMoreAction.adminPendingChanges:
final messenger = ScaffoldMessenger.of(context);
try {
await context.push('/traction/changes');
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Unable to open pending changes'),
),
);
}
break;
}
},
itemBuilder: (context) {
@@ -765,6 +787,12 @@ class _TractionPageState extends State<TractionPage> {
),
),
);
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminPendingChanges,
child: Text('Pending changes'),
),
);
}
return items;
},
@@ -1295,13 +1323,19 @@ class _TractionPageState extends State<TractionPage> {
context: navContext,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Transfer allocations?'),
title: Text(
_transferAllAllocations
? 'Transfer all allocations?'
: 'Transfer allocations?',
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Transfer all allocations from $fromLabel to $toLabel?',
_transferAllAllocations
? 'Transfer all user allocations from $fromLabel to $toLabel?'
: 'Transfer all allocations from $fromLabel to $toLabel?',
),
const SizedBox(height: 12),
Text(
@@ -1331,10 +1365,23 @@ class _TractionPageState extends State<TractionPage> {
if (!navContext.mounted) return;
try {
final data = navContext.read<DataService>();
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
if (_transferAllAllocations) {
await data.transferAllAllocations(
fromLocoId: fromId,
toLocoId: target.id,
);
} else {
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
}
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Allocations transferred')),
SnackBar(
content: Text(
_transferAllAllocations
? 'All allocations transferred'
: 'Allocations transferred',
),
),
);
}
await _refreshTraction(preservePosition: true);
@@ -1406,7 +1453,9 @@ class _TractionPageState extends State<TractionPage> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Transferring allocations from $label. Select a loco to transfer to.',
_transferAllAllocations
? 'Transferring all allocations from $label. Select a loco to transfer to.'
: 'Transferring allocations from $label. Select a loco to transfer to.',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),

View File

@@ -0,0 +1,152 @@
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:mileograph_flutter/services/authservice.dart';
import 'package:provider/provider.dart';
class TractionPendingChangesPage extends StatefulWidget {
const TractionPendingChangesPage({super.key});
@override
State<TractionPendingChangesPage> createState() =>
_TractionPendingChangesPageState();
}
class _TractionPendingChangesPageState extends State<TractionPendingChangesPage> {
bool _isLoading = false;
String? _error;
List<LocoSummary> _locos = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = context.read<ApiService>();
final json = await api.get('/event/pending/locos');
if (json is List) {
setState(() {
_locos = json
.whereType<Map>()
.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) {
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text('Pending changes'),
),
body: isElevated
? RefreshIndicator(
onRefresh: _load,
child: _buildBody(context),
)
: ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('Admin access required.'),
],
),
);
}
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 changes: $_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 changes 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(),
'showPending': true,
},
),
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
onActionComplete: _load,
),
);
},
);
}
}

View File

@@ -724,10 +724,12 @@ Future<void> showTractionDetails(
},
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {
Navigator.of(ctx).pop();
final transferLabel = '${loco.locoClass} ${loco.number}'.trim();
if (hasMileageOrTrips)
FilledButton.icon(
onPressed: () {
Navigator.of(ctx).pop();
final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push(
Uri(
path: '/traction',
@@ -735,18 +737,20 @@ Future<void> showTractionDetails(
'selection': 'single',
'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel,
'transferAll': '0',
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': false,
},
);
},
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'),
),
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'),
),
if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 8),
ExpansionTile(
@@ -757,6 +761,34 @@ Future<void> showTractionDetails(
style: Theme.of(context).textTheme.titleSmall,
),
children: [
if (auth.isElevated) ...[
FilledButton.tonal(
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,
'transferAll': 'true',
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': true,
},
);
},
child: const Text('Transfer all allocations'),
),
const SizedBox(height: 8),
],
FilledButton.tonal(
style: FilledButton.styleFrom(
backgroundColor:

View File

@@ -482,6 +482,23 @@ extension DataServiceTraction on DataService {
}
}
Future<void> transferAllAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer?transferAll=true', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint(
'Failed to transfer all allocations $fromLocoId -> $toLocoId: $e',
);
rethrow;
}
}
Future<void> adminDeleteLoco({required int locoId}) async {
try {
await api.delete('/loco/admin/delete/$locoId');

View File

@@ -21,6 +21,7 @@ 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/traction/traction_pending_changes_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';
@@ -199,6 +200,22 @@ class _MyAppState extends State<MyApp> {
(state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString()
: null);
bool transferAllAllocations = false;
final transferAllParam =
state.uri.queryParameters['transferAll'] ??
(state.extra is Map
? (state.extra as Map)['transferAll']?.toString()
: null);
if (transferAllParam != null) {
transferAllAllocations = transferAllParam.toLowerCase() == 'true' ||
transferAllParam == '1';
}
if (!transferAllAllocations && state.extra is Map) {
final raw = (state.extra as Map)['transferAll'];
if (raw is bool) {
transferAllAllocations = raw;
}
}
final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null ||
@@ -214,6 +231,7 @@ class _MyAppState extends State<MyApp> {
replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
);
},
),
@@ -221,6 +239,11 @@ class _MyAppState extends State<MyApp> {
path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(),
),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
@@ -238,7 +261,15 @@ class _MyAppState extends State<MyApp> {
label = extra;
}
if (label.trim().isEmpty) label = 'Loco $locoId';
return LocoTimelinePage(locoId: locoId, locoLabel: label);
bool showPending = false;
if (extra is Map && extra['showPending'] is bool) {
showPending = extra['showPending'] as bool;
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
forceShowPending: showPending,
);
},
),
GoRoute(

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
# 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.3+11
version: 0.7.4+12
environment:
sdk: ^3.8.1