diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 0ce84f0..39cb2d9 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -130,11 +130,7 @@ class _NewEntryPageState extends State { 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 { } double _milesFromInputWithUnit(DistanceUnit unit) { - return DistanceFormatter(unit) - .parseInputMiles(_mileageController.text.trim()) ?? + return DistanceFormatter( + unit, + ).parseInputMiles(_mileageController.text.trim()) ?? 0; } - List _friendsFromFriendships( - DataService data, - String? selfId, - ) { + List _friendsFromFriendships(DataService data, String? selfId) { final friends = []; for (final f in data.friendships) { final other = _friendFromFriendship(f, selfId); @@ -300,9 +294,7 @@ class _NewEntryPageState extends State { 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>( context: context, @@ -334,8 +326,10 @@ class _NewEntryPageState extends State { 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 { 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 { 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 { 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 { _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 { ); 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 { _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 { _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 { ), ], 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 { 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 { 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 { : () => _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 { 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 { 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 { 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 { 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 { ), 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 { ], ), const SizedBox(height: 12), - ElevatedButton.icon( + FilledButton.icon( onPressed: _submitting ? null : _submit, icon: _submitting ? const SizedBox( @@ -1783,52 +1777,52 @@ class _NewEntryPageState extends State { }, 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 { return best; } - void _insertCandidate(List best, String candidate, {required int max}) { + void _insertCandidate( + List best, + String candidate, { + required int max, + }) { final existingIndex = best.indexOf(candidate); if (existingIndex >= 0) return; @@ -2016,7 +2014,6 @@ class _NewEntryPageState extends State { ], ); } - } class _UpperCaseTextFormatter extends TextInputFormatter { diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index daa1737..0d045f1 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -15,6 +15,7 @@ class TractionPage extends StatefulWidget { this.replacementPendingLocoId, this.transferFromLabel, this.transferFromLocoId, + this.transferAllAllocations = false, this.onSelect, this.selectedKeys = const {}, }); @@ -24,6 +25,7 @@ class TractionPage extends StatefulWidget { final int? replacementPendingLocoId; final String? transferFromLabel; final int? transferFromLocoId; + final bool transferAllAllocations; final ValueChanged? onSelect; final Set selectedKeys; @@ -39,6 +41,12 @@ class _TractionPageState extends State { 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 _selectedKeys; @@ -1315,13 +1323,19 @@ class _TractionPageState extends State { 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( @@ -1351,10 +1365,23 @@ class _TractionPageState extends State { if (!navContext.mounted) return; try { final data = navContext.read(); - 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); @@ -1426,7 +1453,9 @@ class _TractionPageState extends State { 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), ), ), diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index e71f8b5..c0eb97a 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -724,10 +724,12 @@ Future 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 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 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: diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart index fc75d86..2830782 100644 --- a/lib/services/data_service/data_service_traction.dart +++ b/lib/services/data_service/data_service_traction.dart @@ -482,6 +482,23 @@ extension DataServiceTraction on DataService { } } + Future 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 adminDeleteLoco({required int locoId}) async { try { await api.delete('/loco/admin/delete/$locoId'); diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index d5ef0ec..3246cef 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -200,6 +200,22 @@ class _MyAppState extends State { (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 || @@ -215,6 +231,7 @@ class _MyAppState extends State { replacementPendingLocoId: replacementPendingLocoId, transferFromLabel: transferFromLabel, transferFromLocoId: transferFromLocoId, + transferAllAllocations: transferAllAllocations, ); }, ), diff --git a/pubspec.yaml b/pubspec.yaml index e42fd38..260dc2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.7.3+11 +version: 0.7.4+12 environment: sdk: ^3.8.1