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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ enum _TractionMoreAction {
classStats, classStats,
classLeaderboard, classLeaderboard,
adminPending, adminPending,
adminPendingChanges,
} }
class TractionPage extends StatefulWidget { class TractionPage extends StatefulWidget {
@@ -14,6 +15,7 @@ class TractionPage extends StatefulWidget {
this.replacementPendingLocoId, this.replacementPendingLocoId,
this.transferFromLabel, this.transferFromLabel,
this.transferFromLocoId, this.transferFromLocoId,
this.transferAllAllocations = false,
this.onSelect, this.onSelect,
this.selectedKeys = const {}, this.selectedKeys = const {},
}); });
@@ -23,6 +25,7 @@ class TractionPage extends StatefulWidget {
final int? replacementPendingLocoId; final int? replacementPendingLocoId;
final String? transferFromLabel; final String? transferFromLabel;
final int? transferFromLocoId; final int? transferFromLocoId;
final bool transferAllAllocations;
final ValueChanged<LocoSummary>? onSelect; final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys; final Set<String> selectedKeys;
@@ -38,6 +41,12 @@ class _TractionPageState extends State<TractionPage> {
bool _mileageFirst = true; bool _mileageFirst = true;
bool _initialised = false; bool _initialised = false;
int? get _transferFromLocoId => widget.transferFromLocoId; 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; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; late Set<String> _selectedKeys;
@@ -708,6 +717,19 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
break; 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) { itemBuilder: (context) {
@@ -765,6 +787,12 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
); );
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminPendingChanges,
child: Text('Pending changes'),
),
);
} }
return items; return items;
}, },
@@ -1295,13 +1323,19 @@ class _TractionPageState extends State<TractionPage> {
context: navContext, context: navContext,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Transfer allocations?'), title: Text(
_transferAllAllocations
? 'Transfer all allocations?'
: 'Transfer allocations?',
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( 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), const SizedBox(height: 12),
Text( Text(
@@ -1331,10 +1365,23 @@ class _TractionPageState extends State<TractionPage> {
if (!navContext.mounted) return; if (!navContext.mounted) return;
try { try {
final data = navContext.read<DataService>(); 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) { if (navContext.mounted) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('Allocations transferred')), SnackBar(
content: Text(
_transferAllAllocations
? 'All allocations transferred'
: 'Allocations transferred',
),
),
); );
} }
await _refreshTraction(preservePosition: true); await _refreshTraction(preservePosition: true);
@@ -1406,7 +1453,9 @@ class _TractionPageState extends State<TractionPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( 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), 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), const SizedBox(height: 16),
FilledButton.icon( if (hasMileageOrTrips)
onPressed: () { FilledButton.icon(
Navigator.of(ctx).pop(); onPressed: () {
final transferLabel = '${loco.locoClass} ${loco.number}'.trim(); Navigator.of(ctx).pop();
final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push( navContext.push(
Uri( Uri(
path: '/traction', path: '/traction',
@@ -735,18 +737,20 @@ Future<void> showTractionDetails(
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id.toString(), 'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel, 'transferFromLabel': transferLabel,
'transferAll': '0',
}, },
).toString(), ).toString(),
extra: { extra: {
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id, 'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel, 'transferFromLabel': transferLabel,
'transferAll': false,
}, },
); );
}, },
icon: const Icon(Icons.swap_horiz), icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'), label: const Text('Transfer allocations'),
), ),
if (auth.isElevated || canDeleteAsOwner) ...[ if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
ExpansionTile( ExpansionTile(
@@ -757,6 +761,34 @@ Future<void> showTractionDetails(
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
children: [ 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( FilledButton.tonal(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: 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 { Future<void> adminDeleteLoco({required int locoId}) async {
try { try {
await api.delete('/loco/admin/delete/$locoId'); 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/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.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_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/pages/more/user_profile_page.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_edit_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 is Map
? (state.extra as Map)['transferFromLabel']?.toString() ? (state.extra as Map)['transferFromLabel']?.toString()
: null); : 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 = final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) || (selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null || replacementPendingLocoId != null ||
@@ -214,6 +231,7 @@ class _MyAppState extends State<MyApp> {
replacementPendingLocoId: replacementPendingLocoId, replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel, transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId, transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
); );
}, },
), ),
@@ -221,6 +239,11 @@ class _MyAppState extends State<MyApp> {
path: '/traction/pending', path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(), builder: (context, state) => const TractionPendingPage(),
), ),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => const ProfilePage(),
@@ -238,7 +261,15 @@ class _MyAppState extends State<MyApp> {
label = extra; label = extra;
} }
if (label.trim().isEmpty) label = 'Loco $locoId'; 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( 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 # 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.3+11 version: 0.7.4+12
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1