Compare commits

...

1 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
6 changed files with 236 additions and 144 deletions

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

@@ -15,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 {},
}); });
@@ -24,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;
@@ -39,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;
@@ -1315,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(
@@ -1351,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);
@@ -1426,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

@@ -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

@@ -200,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 ||
@@ -215,6 +231,7 @@ class _MyAppState extends State<MyApp> {
replacementPendingLocoId: replacementPendingLocoId, replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel, transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId, transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
); );
}, },
), ),

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