617 lines
23 KiB
Dart
617 lines
23 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import 'package:mileograph_flutter/services/data_service.dart';
|
|
|
|
enum _SpeedUnit { kph, mph }
|
|
|
|
class NewTractionPage extends StatefulWidget {
|
|
const NewTractionPage({super.key});
|
|
|
|
@override
|
|
State<NewTractionPage> createState() => _NewTractionPageState();
|
|
}
|
|
|
|
class _NewTractionPageState extends State<NewTractionPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late final Map<String, TextEditingController> _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<String, dynamic> _buildPayload() {
|
|
final isActive = _statusIsActive;
|
|
final payload = <String, dynamic>{
|
|
'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<void> _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<DataService>().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<String> options,
|
|
required String value,
|
|
required ValueChanged<String> onChanged,
|
|
bool required = false,
|
|
double? widthOverride,
|
|
}) {
|
|
return SizedBox(
|
|
width: widthOverride ?? fieldWidth,
|
|
child: DropdownButtonFormField<String>(
|
|
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'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|