add new traction page
Some checks failed
Some checks failed
This commit is contained in:
616
lib/components/pages/new_traction.dart
Normal file
616
lib/components/pages/new_traction.dart
Normal file
@@ -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<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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TractionPage> {
|
||||
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<String>(
|
||||
'/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<TractionPage> {
|
||||
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<TractionPage> {
|
||||
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<TractionPage> {
|
||||
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<TractionPage> {
|
||||
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,
|
||||
|
||||
@@ -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<MyHomePage> {
|
||||
];
|
||||
|
||||
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<MyHomePage> {
|
||||
|
||||
if (!_fetched) {
|
||||
_fetched = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future(() async {
|
||||
final data = context.read<DataService>();
|
||||
final auth = context.read<AuthService>();
|
||||
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<DataService>();
|
||||
final auth = context.read<AuthService>();
|
||||
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();
|
||||
|
||||
@@ -235,6 +235,23 @@ class DataService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> createLoco(Map<String, dynamic> 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<void> fetchOnThisDay({DateTime? date}) async {
|
||||
_isOnThisDayLoading = true;
|
||||
final target = date ?? DateTime.now();
|
||||
|
||||
Reference in New Issue
Block a user