diff --git a/lib/components/pages/new_traction.dart b/lib/components/pages/new_traction.dart new file mode 100644 index 0000000..b65ab10 --- /dev/null +++ b/lib/components/pages/new_traction.dart @@ -0,0 +1,616 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:mileograph_flutter/services/dataService.dart'; + +enum _SpeedUnit { kph, mph } + +class NewTractionPage extends StatefulWidget { + const NewTractionPage({super.key}); + + @override + State createState() => _NewTractionPageState(); +} + +class _NewTractionPageState extends State { + final _formKey = GlobalKey(); + late final Map _controllers; + bool _preserved = false; + bool _remoteControl = false; + bool _cabAirConditioning = false; + bool _cabDoorControl = false; + bool _submitting = false; + _SpeedUnit _speedUnit = _SpeedUnit.kph; + String _status = 'unknown'; + String _domain = 'unknown'; + String _type = 'O'; + + static const _typeOptions = ['D', 'E', 'U', 'S', 'DMU', 'EMU', 'SMU', 'O']; + + static const _domainOptions = [ + 'mainline', + 'heritage', + 'industrial', + 'museum', + 'private', + 'unknown', + ]; + + static const _statusOptions = [ + 'active', + 'stored', + 'overhaul', + 'withdrawn', + 'preserved', + 'scrapped', + 'unknown', + ]; + + @override + void initState() { + super.initState(); + _controllers = { + 'number': TextEditingController(), + 'evn': TextEditingController(), + 'name': TextEditingController(), + 'class': TextEditingController(), + 'operator': TextEditingController(), + 'notes': TextEditingController(), + 'livery': TextEditingController(), + 'location': TextEditingController(), + 'owner': TextEditingController(), + 'power_unit': TextEditingController(), + 'headlights': TextEditingController(), + 'pantograph': TextEditingController(), + 'misc': TextEditingController(), + 'coupling': TextEditingController(), + 'axle_arrangement': TextEditingController(), + 'track_gauge': TextEditingController(), + 'loco_braking': TextEditingController(), + 'train_braking': TextEditingController(), + 'max_speed': TextEditingController(), + 'buffer_type': TextEditingController(), + 'drawgear_strength': TextEditingController(), + 'train_heating': TextEditingController(), + 'route_restriction': TextEditingController(), + 'safety_systems': TextEditingController(), + 'width': TextEditingController(), + 'height': TextEditingController(), + 'length': TextEditingController(), + 'weight': TextEditingController(), + 'power': TextEditingController(), + 'tractive_effort': TextEditingController(), + 'electrical_voltage': TextEditingController(), + 'traction_motors': TextEditingController(), + 'build_date': TextEditingController(), + }; + } + + @override + void dispose() { + for (final controller in _controllers.values) { + controller.dispose(); + } + super.dispose(); + } + + String _value(String key, {String fallback = ''}) { + final text = _controllers[key]?.text.trim() ?? ''; + if (text.isEmpty) return fallback; + return text; + } + + String? _textOrNull(String key) { + final text = _controllers[key]?.text.trim() ?? ''; + if (text.isEmpty) return null; + return text; + } + + num? _parseNumber(String key) { + final raw = _controllers[key]?.text.trim(); + if (raw == null || raw.isEmpty) return null; + final parsed = double.tryParse(raw); + if (parsed == null) return null; + return parsed % 1 == 0 ? parsed.toInt() : parsed; + } + + bool get _statusIsActive => _status.toLowerCase() == 'active'; + + String? _validateBuildDate(String? input) { + final value = (input ?? '').trim(); + if (value.isEmpty) return null; + + final regex = RegExp( + r'^(\d{2})(\d{2}|[Xx]{2})-((0[1-9]|1[0-2])|[Xx]{2})-((0[1-9]|[12]\d|3[01])|[Xx]{2})$', + ); + final match = regex.firstMatch(value); + if (match == null) { + return 'Use YYYY-MM-DD; allow XX for unknown DD/YY'; + } + + final year = match.group(1)! + match.group(2)!; + final monthPart = match.group(3)!; + final dayPart = match.group(4)!; + final monthUnknown = monthPart.toLowerCase() == 'xx'; + final dayUnknown = dayPart.toLowerCase() == 'xx'; + + if (monthUnknown && !dayUnknown) { + return 'If month is XX, day must be XX'; + } + + // Validate actual calendar date when fully specified and year is numeric. + final yearHasUnknown = year.toLowerCase().contains('x'); + if (!monthUnknown && !dayUnknown && !yearHasUnknown) { + final month = int.parse(monthPart); + final day = int.parse(dayPart); + final yearInt = int.parse(year); + try { + final dt = DateTime(yearInt, month, day); + if (dt.year != yearInt || dt.month != month || dt.day != day) { + return 'Enter a valid calendar date'; + } + } catch (_) { + return 'Enter a valid calendar date'; + } + } + + return null; + } + + double? _maxSpeedInKph() { + final raw = _controllers['max_speed']?.text.trim(); + if (raw == null || raw.isEmpty) return null; + final parsed = double.tryParse(raw); + if (parsed == null) return null; + if (_speedUnit == _SpeedUnit.kph) return parsed; + return parsed * 1.60934; + } + + Map _buildPayload() { + final isActive = _statusIsActive; + final payload = { + 'number': _value('number'), + 'class': _value('class'), + 'type': _type, + 'status': _status, + 'operational': isActive, + 'gettable': isActive, + 'preserved': _preserved, + 'remote_control': _remoteControl, + 'cab_air_conditioning': _cabAirConditioning, + 'cab_door_control': _cabDoorControl, + }; + + void addIfPresent(String key, dynamic value) { + if (value == null) return; + if (value is String && value.trim().isEmpty) return; + payload[key] = value; + } + + addIfPresent('evn', _textOrNull('evn')); + addIfPresent('name', _textOrNull('name')); + addIfPresent('operator', _textOrNull('operator')); + addIfPresent('notes', _textOrNull('notes')); + addIfPresent('domain', _domain); + addIfPresent('livery', _textOrNull('livery')); + addIfPresent('owner', _textOrNull('owner')); + addIfPresent('location', _textOrNull('location')); + addIfPresent('power_unit', _textOrNull('power_unit')); + addIfPresent('headlights', _textOrNull('headlights')); + addIfPresent('pantograph', _textOrNull('pantograph')); + addIfPresent('misc', _textOrNull('misc')); + addIfPresent('coupling', _textOrNull('coupling')); + addIfPresent('axle_arrangement', _textOrNull('axle_arrangement')); + addIfPresent('track_gauge', _parseNumber('track_gauge')); + addIfPresent('loco_braking', _textOrNull('loco_braking')); + addIfPresent('train_braking', _textOrNull('train_braking')); + addIfPresent('max_speed', _maxSpeedInKph()); + addIfPresent('buffer_type', _textOrNull('buffer_type')); + addIfPresent('drawgear_strength', _textOrNull('drawgear_strength')); + addIfPresent('train_heating', _textOrNull('train_heating')); + addIfPresent('route_restriction', _textOrNull('route_restriction')); + addIfPresent('safety_systems', _textOrNull('safety_systems')); + addIfPresent('width', _parseNumber('width')); + addIfPresent('height', _parseNumber('height')); + addIfPresent('length', _parseNumber('length')); + addIfPresent('weight', _parseNumber('weight')); + addIfPresent('power', _parseNumber('power')); + addIfPresent('tractive_effort', _parseNumber('tractive_effort')); + addIfPresent('electrical_voltage', _textOrNull('electrical_voltage')); + addIfPresent('traction_motors', _textOrNull('traction_motors')); + addIfPresent('build_date', _textOrNull('build_date')); + + return payload; + } + + Future _handleSubmit() async { + final form = _formKey.currentState; + if (form == null) return; + if (!form.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please fill the required fields.')), + ); + return; + } + FocusScope.of(context).unfocus(); + setState(() => _submitting = true); + final messenger = ScaffoldMessenger.of(context); + try { + await context.read().createLoco(_buildPayload()); + if (!mounted) return; + messenger.showSnackBar( + const SnackBar(content: Text('Traction added successfully')), + ); + Navigator.of(context).pop(_controllers['class']?.text.trim()); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Failed to add traction: $e')), + ); + setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final isActive = _statusIsActive; + final size = MediaQuery.of(context).size; + final isNarrow = size.width < 720; + final fieldWidth = isNarrow ? double.infinity : 340.0; + + Widget textField( + String key, + String label, { + bool required = false, + int maxLines = 1, + String? helper, + String? suffixText, + TextInputType? keyboardType, + double? widthOverride, + String? Function(String?)? validator, + }) { + return SizedBox( + width: widthOverride ?? fieldWidth, + child: TextFormField( + controller: _controllers[key], + decoration: InputDecoration( + labelText: required ? '$label *' : label, + helperText: helper, + suffixText: suffixText, + border: const OutlineInputBorder(), + ), + keyboardType: keyboardType, + maxLines: maxLines, + validator: (val) { + if (required && (val == null || val.trim().isEmpty)) { + return 'Required'; + } + return validator?.call(val); + }, + ), + ); + } + + Widget numberField(String key, String label, {String? suffixText}) { + return textField( + key, + label, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + suffixText: suffixText, + ); + } + + Widget dropdownField({ + required String label, + required List options, + required String value, + required ValueChanged onChanged, + bool required = false, + double? widthOverride, + }) { + return SizedBox( + width: widthOverride ?? fieldWidth, + child: DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: required ? '$label *' : label, + border: const OutlineInputBorder(), + ), + items: options + .map((opt) => DropdownMenuItem(value: opt, child: Text(opt))) + .toList(), + onChanged: (val) { + if (val != null) onChanged(val); + }, + validator: required + ? (val) => val == null || val.isEmpty ? 'Required' : null + : null, + ), + ); + } + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + TextButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + label: const Text('Back'), + ), + ], + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Basics', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + textField('number', 'Number', required: true), + textField('class', 'Class', required: true), + dropdownField( + label: 'Type', + options: _typeOptions, + value: _type, + required: true, + onChanged: (val) => setState(() => _type = val), + ), + dropdownField( + label: 'Status', + options: _statusOptions, + value: _status, + required: true, + onChanged: (val) => setState(() => _status = val), + ), + textField('name', 'Name'), + textField('operator', 'Operator'), + textField('evn', 'EVN'), + dropdownField( + label: 'Domain', + options: _domainOptions, + value: _domain, + onChanged: (val) => setState(() => _domain = val), + ), + textField('livery', 'Livery'), + textField('owner', 'Owner'), + textField('location', 'Location'), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip( + avatar: Icon( + isActive ? Icons.check_circle : Icons.block, + color: isActive ? Colors.green : Colors.grey, + ), + label: Text('Gettable: ${isActive ? 'Yes' : 'No'}'), + ), + Chip( + avatar: Icon( + isActive ? Icons.check_circle : Icons.block, + color: isActive ? Colors.green : Colors.grey, + ), + label: Text( + 'Operational: ${isActive ? 'Yes' : 'No'}', + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Status controls availability: active sets gettable and operational to true, everything else sets them to false.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + Text( + 'Equipment & Capabilities', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: fieldWidth, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _preserved, + onChanged: (v) => setState(() => _preserved = v), + title: const Text('Preserved'), + ), + ), + SizedBox( + width: fieldWidth, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _remoteControl, + onChanged: (v) => + setState(() => _remoteControl = v), + title: const Text('Remote control'), + ), + ), + SizedBox( + width: fieldWidth, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _cabAirConditioning, + onChanged: (v) => + setState(() => _cabAirConditioning = v), + title: const Text('Cab air conditioning'), + ), + ), + SizedBox( + width: fieldWidth, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _cabDoorControl, + onChanged: (v) => + setState(() => _cabDoorControl = v), + title: const Text('Cab door control'), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + textField('power_unit', 'Power unit'), + textField('headlights', 'Headlights'), + textField('pantograph', 'Pantograph'), + textField('misc', 'Misc'), + textField('coupling', 'Coupling'), + textField('axle_arrangement', 'Axle arrangement'), + textField('loco_braking', 'Loco braking'), + textField('train_braking', 'Train braking'), + textField('buffer_type', 'Buffer type'), + textField('drawgear_strength', 'Drawgear strength'), + textField('train_heating', 'Train heating'), + textField('route_restriction', 'Route restriction'), + textField('safety_systems', 'Safety systems'), + ], + ), + const SizedBox(height: 16), + Text( + 'Dimensions & Performance', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + numberField('weight', 'Weight', suffixText: 'tonnes'), + numberField('length', 'Length', suffixText: 'mm'), + numberField('width', 'Width', suffixText: 'mm'), + numberField('height', 'Height', suffixText: 'mm'), + numberField( + 'track_gauge', + 'Track gauge', + suffixText: 'mm', + ), + numberField('power', 'Power', suffixText: 'kW'), + numberField( + 'tractive_effort', + 'Tractive effort', + suffixText: 'kN', + ), + SizedBox( + width: fieldWidth, + child: Row( + children: [ + Expanded( + child: numberField( + 'max_speed', + 'Max speed (${_speedUnit == _SpeedUnit.kph ? 'km/h' : 'mph'})', + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 120, + child: DropdownButtonFormField<_SpeedUnit>( + value: _speedUnit, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Units', + ), + items: const [ + DropdownMenuItem( + value: _SpeedUnit.kph, + child: Text('km/h'), + ), + DropdownMenuItem( + value: _SpeedUnit.mph, + child: Text('mph'), + ), + ], + onChanged: (val) { + if (val != null) { + setState(() => _speedUnit = val); + } + }, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Electrical & Build', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + textField('electrical_voltage', 'Electrical voltage'), + textField('traction_motors', 'Traction motors'), + textField( + 'build_date', + 'Build date', + helper: + 'Format YYYY-MM-DD, use XX for unknown DD/YY', + validator: _validateBuildDate, + ), + ], + ), + const SizedBox(height: 16), + textField( + 'notes', + 'Notes', + maxLines: 3, + helper: 'Optional notes', + widthOverride: double.infinity, + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _submitting ? null : _handleSubmit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.save), + label: Text(_submitting ? 'Submitting...' : 'Submit'), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index 1a87c38..3240407 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/dataService.dart'; import 'package:provider/provider.dart'; @@ -182,24 +183,53 @@ class _TractionPageState extends State { physics: const AlwaysScrollableScrollPhysics(), children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Fleet', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 2), + Text( + 'Traction', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, children: [ - Text('Fleet', style: Theme.of(context).textTheme.labelMedium), - const SizedBox(height: 2), - Text( - 'Traction', - style: Theme.of(context).textTheme.headlineSmall, + IconButton( + tooltip: 'Refresh', + onPressed: _refreshTraction, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () async { + final createdClass = await context.push( + '/traction/new', + ); + if (createdClass != null && createdClass.isNotEmpty) { + _classController.text = createdClass; + _selectedClass = createdClass; + if (mounted) { + _refreshTraction(); + } + } else if (mounted && createdClass == '') { + _refreshTraction(); + } + }, + icon: const Icon(Icons.add), + label: const Text('New Traction'), ), ], ), - IconButton( - tooltip: 'Refresh', - onPressed: _refreshTraction, - icon: const Icon(Icons.refresh), - ), ], ), const SizedBox(height: 12), @@ -482,6 +512,7 @@ class _TractionPageState extends State { final status = loco.status ?? 'Unknown'; final operatorName = loco.operator ?? ''; final domain = loco.domain ?? ''; + final hasMileageOrTrips = _hasMileageOrTrips(loco); final statusColors = _statusChipColors(context, status); return Card( child: Padding( @@ -495,17 +526,35 @@ class _TractionPageState extends State { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + Text( + loco.number, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + if (hasMileageOrTrips) + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.green.shade600, + ), + ), + ], + ), Text( - '${loco.locoClass} ${loco.number}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + loco.locoClass, + style: Theme.of(context).textTheme.labelMedium, ), if ((loco.name ?? '').isNotEmpty) - Text( - loco.name ?? '', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontStyle: FontStyle.italic, + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + loco.name ?? '', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontStyle: FontStyle.italic), ), ), ], @@ -665,11 +714,32 @@ class _TractionPageState extends State { onPressed: () => Navigator.of(ctx).pop(), ), const SizedBox(width: 8), - Text( - '${loco.locoClass} ${loco.number}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + loco.number, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w800), + ), + if (_hasMileageOrTrips(loco)) + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.green.shade600, + ), + ), + ], + ), + Text( + loco.locoClass, + style: Theme.of(context).textTheme.labelMedium, + ), + ], ), ], ), @@ -740,6 +810,12 @@ class _TractionPageState extends State { return value.toStringAsFixed(1); } + bool _hasMileageOrTrips(LocoSummary loco) { + final mileage = loco.mileage ?? 0; + final trips = loco.trips ?? loco.journeys ?? 0; + return mileage > 0 || trips > 0; + } + Widget _buildFilterInput( BuildContext context, EventField field, diff --git a/lib/main.dart b/lib/main.dart index 748b223..1124a09 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart'; +import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/trips.dart'; import 'package:provider/provider.dart'; @@ -93,6 +94,10 @@ class MyApp extends StatelessWidget { ), GoRoute(path: '/legs', builder: (_, __) => LegsPage()), GoRoute(path: '/traction', builder: (_, __) => TractionPage()), + GoRoute( + path: '/traction/new', + builder: (_, __) => const NewTractionPage(), + ), GoRoute(path: '/trips', builder: (_, __) => TripsPage()), GoRoute(path: '/add', builder: (_, __) => NewEntryPage()), ], @@ -164,7 +169,11 @@ class _MyHomePageState extends State { ]; int _getIndexFromLocation(String location) { - int newIndex = contentPages.indexWhere((path) => location == path); + int newIndex = contentPages.indexWhere((path) { + if (location == path) return true; + if (path == '/') return location == '/'; + return location.startsWith('$path/'); + }); if (newIndex < 0) { return 0; } @@ -188,19 +197,19 @@ class _MyHomePageState extends State { if (!_fetched) { _fetched = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - Future(() async { - final data = context.read(); - final auth = context.read(); - api.setTokenProvider(() => auth.token); - await auth.tryRestoreSession(); - if (!auth.isLoggedIn) return; - data.fetchEventFields(); - if (data.homepageStats == null) { - data.fetchHomepageStats(); - } - if (data.legs.isEmpty) { - data.fetchLegs(); + WidgetsBinding.instance.addPostFrameCallback((_) { + Future(() async { + final data = context.read(); + final auth = context.read(); + api.setTokenProvider(() => auth.token); + await auth.tryRestoreSession(); + if (!auth.isLoggedIn) return; + data.fetchEventFields(); + if (data.homepageStats == null) { + data.fetchHomepageStats(); + } + if (data.legs.isEmpty) { + data.fetchLegs(); } if (data.traction.isEmpty) { data.fetchHadTraction(); diff --git a/lib/services/dataService.dart b/lib/services/dataService.dart index 4b0b392..d84c9cb 100644 --- a/lib/services/dataService.dart +++ b/lib/services/dataService.dart @@ -235,6 +235,23 @@ class DataService extends ChangeNotifier { } } + Future createLoco(Map payload) async { + try { + final response = await api.put('/loco/new', payload); + final locoClass = payload['class']?.toString(); + if (locoClass != null && + locoClass.isNotEmpty && + !_locoClasses.contains(locoClass)) { + _locoClasses = [..._locoClasses, locoClass]; + } + _notifyAsync(); + return response; + } catch (e) { + debugPrint('Failed to create loco: $e'); + rethrow; + } + } + Future fetchOnThisDay({DateTime? date}) async { _isOnThisDayLoading = true; final target = date ?? DateTime.now();