858 lines
27 KiB
Dart
858 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:mileograph_flutter/components/calculator/calculator.dart';
|
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
|
import 'package:mileograph_flutter/objects/objects.dart';
|
|
import 'package:mileograph_flutter/services/apiService.dart';
|
|
import 'package:mileograph_flutter/services/dataService.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class NewEntryPage extends StatefulWidget {
|
|
const NewEntryPage({super.key});
|
|
|
|
@override
|
|
State<NewEntryPage> createState() => _NewEntryPageState();
|
|
}
|
|
|
|
class _NewEntryPageState extends State<NewEntryPage> {
|
|
static const _draftPrefsKey = 'new_entry_draft';
|
|
|
|
final _formKey = GlobalKey<FormState>();
|
|
DateTime _selectedDate = DateTime.now();
|
|
TimeOfDay _selectedTime = TimeOfDay.now();
|
|
final _startController = TextEditingController();
|
|
final _endController = TextEditingController();
|
|
final _headcodeController = TextEditingController();
|
|
final _notesController = TextEditingController();
|
|
final _mileageController = TextEditingController();
|
|
final _networkController = TextEditingController();
|
|
bool _submitting = false;
|
|
bool _useManualMileage = false;
|
|
RouteResult? _routeResult;
|
|
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
|
int? _selectedTripId;
|
|
bool _restoringDraft = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
for (final controller in [
|
|
_startController,
|
|
_endController,
|
|
_headcodeController,
|
|
_notesController,
|
|
_mileageController,
|
|
_networkController,
|
|
]) {
|
|
controller.addListener(_saveDraft);
|
|
}
|
|
Future.microtask(() {
|
|
if (!mounted) return;
|
|
final data = context.read<DataService>();
|
|
data.fetchClassList();
|
|
data.fetchTrips();
|
|
_loadDraft();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final controller in [
|
|
_startController,
|
|
_endController,
|
|
_headcodeController,
|
|
_notesController,
|
|
_mileageController,
|
|
_networkController,
|
|
]) {
|
|
controller.removeListener(_saveDraft);
|
|
}
|
|
_startController.dispose();
|
|
_endController.dispose();
|
|
_headcodeController.dispose();
|
|
_notesController.dispose();
|
|
_mileageController.dispose();
|
|
_networkController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Widget _buildTripSelector(BuildContext context) {
|
|
final trips = context.watch<DataService>().tripList;
|
|
final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId));
|
|
final tripIds = sorted.map((t) => t.tripId).toSet();
|
|
final selectedValue =
|
|
(_selectedTripId != null && tripIds.contains(_selectedTripId))
|
|
? _selectedTripId
|
|
: null;
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: DropdownButtonFormField<int?>(
|
|
value: selectedValue,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Trip',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
const DropdownMenuItem(value: null, child: Text('No trip')),
|
|
...sorted.map(
|
|
(t) =>
|
|
DropdownMenuItem<int?>(value: t.tripId, child: Text(t.tripName)),
|
|
),
|
|
],
|
|
onChanged: (val) {
|
|
setState(() => _selectedTripId = val);
|
|
_saveDraft();
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton.icon(
|
|
onPressed: () => _showAddTripDialog(context),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('New Trip'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _showAddTripDialog(BuildContext context) async {
|
|
final controller = TextEditingController();
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('New Trip'),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(labelText: 'Trip name'),
|
|
autofocus: true,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (!mounted) return;
|
|
if (result != null && result.isNotEmpty) {
|
|
final api = context.read<ApiService>();
|
|
final data = context.read<DataService>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
try {
|
|
await api.put('/trips/new', {"trip_name": result});
|
|
await data.fetchTrips();
|
|
if (!mounted) return;
|
|
final trips = data.tripList;
|
|
final match = trips.firstWhere(
|
|
(t) => t.tripName == result,
|
|
orElse: () => trips.isNotEmpty
|
|
? trips.first
|
|
: TripSummary(tripId: 0, tripName: result, tripMileage: 0),
|
|
);
|
|
setState(() => _selectedTripId = match.tripId);
|
|
_saveDraft();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text('Failed to add trip: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _openCalculator() async {
|
|
final result = await Navigator.of(context).push<RouteResult>(
|
|
MaterialPageRoute(
|
|
builder: (_) => _CalculatorPickerPage(
|
|
onResult: (res) => Navigator.of(context).pop(res),
|
|
),
|
|
),
|
|
);
|
|
if (result != null) {
|
|
setState(() {
|
|
_routeResult = result;
|
|
_mileageController.text = result.distance.toStringAsFixed(2);
|
|
_useManualMileage = false;
|
|
});
|
|
_saveDraft();
|
|
}
|
|
}
|
|
|
|
Future<void> _openTractionPicker() async {
|
|
final selectedKeys = _tractionItems
|
|
.where((e) => !e.isMarker && e.loco != null)
|
|
.map((e) => '${e.loco!.locoClass}-${e.loco!.number}')
|
|
.toSet();
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => TractionPage(
|
|
selectionMode: true,
|
|
selectedKeys: selectedKeys,
|
|
onSelect: (loco) {
|
|
final markerIndex = _tractionItems.indexWhere(
|
|
(element) => element.isMarker,
|
|
);
|
|
final key = '${loco.locoClass}-${loco.number}';
|
|
setState(() {
|
|
final existingIndex = _tractionItems.indexWhere(
|
|
(e) =>
|
|
!e.isMarker &&
|
|
e.loco != null &&
|
|
'${e.loco!.locoClass}-${e.loco!.number}' == key,
|
|
);
|
|
if (existingIndex != -1) {
|
|
_tractionItems.removeAt(existingIndex);
|
|
} else {
|
|
_tractionItems.insert(
|
|
markerIndex,
|
|
_TractionItem(loco: loco, powering: true),
|
|
);
|
|
}
|
|
});
|
|
_saveDraft();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickDate() async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _selectedDate,
|
|
firstDate: DateTime(1970),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (picked != null) setState(() => _selectedDate = picked);
|
|
_saveDraft();
|
|
}
|
|
|
|
Future<void> _pickTime() async {
|
|
final picked = await showTimePicker(
|
|
context: context,
|
|
initialTime: _selectedTime,
|
|
);
|
|
if (picked != null) {
|
|
setState(() => _selectedTime = picked);
|
|
_saveDraft();
|
|
}
|
|
}
|
|
|
|
DateTime get _legDateTime => DateTime(
|
|
_selectedDate.year,
|
|
_selectedDate.month,
|
|
_selectedDate.day,
|
|
_selectedTime.hour,
|
|
_selectedTime.minute,
|
|
);
|
|
|
|
List<Map<String, dynamic>> _buildTractionPayload() {
|
|
final markerIndex = _tractionItems.indexWhere(
|
|
(element) => element.isMarker,
|
|
);
|
|
final payload = <Map<String, dynamic>>[];
|
|
for (var i = 0; i < _tractionItems.length; i++) {
|
|
final item = _tractionItems[i];
|
|
if (item.isMarker || item.loco == null) continue;
|
|
int allocPos;
|
|
if (i > markerIndex) {
|
|
allocPos = -(i - markerIndex);
|
|
} else {
|
|
allocPos = (markerIndex - 1) - i;
|
|
}
|
|
payload.add({
|
|
"loco_type": item.loco!.type,
|
|
"loco_number": item.loco!.number,
|
|
"alloc_pos": allocPos,
|
|
"alloc_powering": item.powering ? 1 : 0,
|
|
});
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
if (!_useManualMileage && _routeResult == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Please calculate mileage first')),
|
|
);
|
|
return;
|
|
}
|
|
setState(() => _submitting = true);
|
|
final api = context.read<ApiService>();
|
|
final routeStations = _routeResult?.calculatedRoute ?? [];
|
|
final startVal = _useManualMileage
|
|
? _startController.text.trim()
|
|
: (routeStations.isNotEmpty ? routeStations.first : '');
|
|
final endVal = _useManualMileage
|
|
? _endController.text.trim()
|
|
: (routeStations.isNotEmpty ? routeStations.last : '');
|
|
final mileageVal = _useManualMileage
|
|
? double.tryParse(_mileageController.text.trim()) ?? 0
|
|
: (_routeResult?.distance ?? 0);
|
|
final tractionPayload = _buildTractionPayload();
|
|
|
|
if (_useManualMileage) {
|
|
final body = {
|
|
"leg_trip": _selectedTripId ?? null,
|
|
"leg_start": startVal,
|
|
"leg_end": endVal,
|
|
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
"leg_network": _networkController.text.trim(),
|
|
"leg_distance": mileageVal,
|
|
"isKilometers": false,
|
|
"leg_notes": _notesController.text.trim(),
|
|
"leg_headcode": _headcodeController.text.trim(),
|
|
"locos": tractionPayload,
|
|
};
|
|
await api.post('/add/manual', body);
|
|
} else {
|
|
final body = {
|
|
"leg_trip": _selectedTripId ?? null,
|
|
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
"leg_route": routeStations,
|
|
"leg_notes": _notesController.text.trim(),
|
|
"leg_headcode": _headcodeController.text.trim(),
|
|
"leg_network": _networkController.text.trim(),
|
|
"locos": tractionPayload,
|
|
};
|
|
await api.post('/add', body);
|
|
}
|
|
if (mounted) {
|
|
context.read<DataService>().refreshLegs();
|
|
}
|
|
try {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Entry submitted')));
|
|
_resetFormState(clearDraft: true);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Failed to submit: $e')));
|
|
} finally {
|
|
if (mounted) setState(() => _submitting = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _resetFormState({bool clearDraft = false}) async {
|
|
_formKey.currentState?.reset();
|
|
_startController.clear();
|
|
_endController.clear();
|
|
_headcodeController.clear();
|
|
_notesController.clear();
|
|
_mileageController.clear();
|
|
_networkController.clear();
|
|
final now = DateTime.now();
|
|
setState(() {
|
|
_selectedDate = now;
|
|
_selectedTime = TimeOfDay.fromDateTime(now);
|
|
_useManualMileage = false;
|
|
_routeResult = null;
|
|
_tractionItems
|
|
..clear()
|
|
..add(_TractionItem.marker());
|
|
_selectedTripId = null;
|
|
_submitting = false;
|
|
});
|
|
if (clearDraft) {
|
|
await _clearDraft();
|
|
} else {
|
|
_saveDraft();
|
|
}
|
|
}
|
|
|
|
Future<void> _saveDraft() async {
|
|
if (_restoringDraft) return;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final draft = {
|
|
"date": _selectedDate.toIso8601String(),
|
|
"time": {"hour": _selectedTime.hour, "minute": _selectedTime.minute},
|
|
"start": _startController.text,
|
|
"end": _endController.text,
|
|
"headcode": _headcodeController.text,
|
|
"notes": _notesController.text,
|
|
"mileage": _mileageController.text,
|
|
"network": _networkController.text,
|
|
"useManualMileage": _useManualMileage,
|
|
"selectedTripId": _selectedTripId,
|
|
"routeResult": _routeResult == null
|
|
? null
|
|
: {
|
|
"input_route": _routeResult!.inputRoute,
|
|
"calculated_route": _routeResult!.calculatedRoute,
|
|
"costs": _routeResult!.costs,
|
|
"distance": _routeResult!.distance,
|
|
},
|
|
"tractionItems": _serializeTractionItems(),
|
|
};
|
|
await prefs.setString(_draftPrefsKey, jsonEncode(draft));
|
|
}
|
|
|
|
Future<void> _clearDraft() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_draftPrefsKey);
|
|
}
|
|
|
|
Future<void> _loadDraft() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final raw = prefs.getString(_draftPrefsKey);
|
|
if (raw == null) return;
|
|
try {
|
|
final data = jsonDecode(raw);
|
|
if (data is! Map) return;
|
|
_restoringDraft = true;
|
|
setState(() {
|
|
if (data['date'] is String) {
|
|
_selectedDate = DateTime.tryParse(data['date']) ?? _selectedDate;
|
|
}
|
|
if (data['time'] is Map) {
|
|
final time = data['time'] as Map;
|
|
final hour = time['hour'] as int?;
|
|
final minute = time['minute'] as int?;
|
|
if (hour != null && minute != null) {
|
|
_selectedTime = TimeOfDay(hour: hour, minute: minute);
|
|
}
|
|
}
|
|
_useManualMileage = data['useManualMileage'] ?? _useManualMileage;
|
|
_selectedTripId = data['selectedTripId'];
|
|
if (data['routeResult'] is Map<String, dynamic>) {
|
|
_routeResult =
|
|
RouteResult.fromJson(Map<String, dynamic>.from(data['routeResult']));
|
|
_mileageController.text = _routeResult!.distance.toStringAsFixed(2);
|
|
}
|
|
if (data['tractionItems'] is List) {
|
|
_restoreTractionItems(List<Map<String, dynamic>>.from(
|
|
data['tractionItems'].cast<Map>(),
|
|
));
|
|
}
|
|
});
|
|
_startController.text = data['start'] ?? '';
|
|
_endController.text = data['end'] ?? '';
|
|
_headcodeController.text = data['headcode'] ?? '';
|
|
_notesController.text = data['notes'] ?? '';
|
|
_mileageController.text = data['mileage'] ?? '';
|
|
_networkController.text = data['network'] ?? '';
|
|
} catch (_) {
|
|
// Ignore corrupt draft data
|
|
} finally {
|
|
_restoringDraft = false;
|
|
}
|
|
}
|
|
|
|
List<Map<String, dynamic>> _serializeTractionItems() {
|
|
return _tractionItems
|
|
.map(
|
|
(item) => {
|
|
"isMarker": item.isMarker,
|
|
"powering": item.powering,
|
|
"loco": item.loco == null
|
|
? null
|
|
: {
|
|
"id": item.loco!.id,
|
|
"type": item.loco!.type,
|
|
"number": item.loco!.number,
|
|
"class": item.loco!.locoClass,
|
|
"name": item.loco!.name,
|
|
"operator": item.loco!.operator,
|
|
"notes": item.loco!.notes,
|
|
"evn": item.loco!.evn,
|
|
},
|
|
},
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
void _restoreTractionItems(List<Map<String, dynamic>> items) {
|
|
final restored = <_TractionItem>[];
|
|
for (final item in items) {
|
|
final locoData = item['loco'] as Map<String, dynamic>?;
|
|
LocoSummary? loco;
|
|
if (locoData != null) {
|
|
loco = LocoSummary(
|
|
locoId: locoData['id'] ?? 0,
|
|
locoType: locoData['type'] ?? '',
|
|
locoNumber: locoData['number'] ?? '',
|
|
locoName: locoData['name'] ?? '',
|
|
locoClass: locoData['class'] ?? '',
|
|
locoOperator: locoData['operator'] ?? '',
|
|
locoNotes: locoData['notes'],
|
|
locoEvn: locoData['evn'],
|
|
);
|
|
}
|
|
restored.add(
|
|
_TractionItem(
|
|
loco: loco,
|
|
powering: item['powering'] ?? true,
|
|
isMarker: item['isMarker'] ?? false,
|
|
),
|
|
);
|
|
}
|
|
if (restored.where((e) => e.isMarker).isEmpty) {
|
|
restored.insert(0, _TractionItem.marker());
|
|
}
|
|
_tractionItems
|
|
..clear()
|
|
..addAll(restored);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isMobile = MediaQuery.of(context).size.width < 700;
|
|
return Scaffold(
|
|
appBar: null,
|
|
body: Form(
|
|
key: _formKey,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final twoCol = !isMobile && constraints.maxWidth > 1000;
|
|
|
|
final detailPanel = _section('Details', [
|
|
_buildTripSelector(context),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: _pickDate,
|
|
icon: const Icon(Icons.calendar_today),
|
|
label: Text(DateFormat.yMMMd().format(_selectedDate)),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: _pickTime,
|
|
icon: const Icon(Icons.schedule),
|
|
label: Text(_selectedTime.format(context)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_useManualMileage)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _startController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'From',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (v) => !_useManualMileage
|
|
? null
|
|
: (v == null || v.isEmpty ? 'Required' : null),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _endController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'To',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (v) => !_useManualMileage
|
|
? null
|
|
: (v == null || v.isEmpty ? 'Required' : null),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
TextFormField(
|
|
controller: _headcodeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Headcode',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
TextFormField(
|
|
controller: _networkController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Network',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
TextFormField(
|
|
controller: _notesController,
|
|
maxLines: 3,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Notes',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
]);
|
|
|
|
final tractionPanel = _section('Traction', [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _openTractionPicker,
|
|
icon: const Icon(Icons.search),
|
|
label: const Text('Search traction'),
|
|
),
|
|
),
|
|
_buildTractionList(),
|
|
]);
|
|
|
|
final mileagePanel = _section(
|
|
'Mileage',
|
|
[
|
|
if (_useManualMileage)
|
|
TextFormField(
|
|
controller: _mileageController,
|
|
keyboardType: const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Mileage (mi)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
)
|
|
else if (_routeResult != null)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Calculated mileage'),
|
|
subtitle: Text(
|
|
'${_routeResult!.distance.toStringAsFixed(2)} mi',
|
|
),
|
|
),
|
|
if (!_useManualMileage)
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _openCalculator,
|
|
icon: const Icon(Icons.calculate),
|
|
label: const Text('Open mileage calculator'),
|
|
),
|
|
),
|
|
],
|
|
trailing: FilterChip(
|
|
label: Text(_useManualMileage ? 'Manual' : 'Automatic'),
|
|
selected: _useManualMileage,
|
|
onSelected: (val) {
|
|
setState(() => _useManualMileage = val);
|
|
_saveDraft();
|
|
},
|
|
),
|
|
);
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
detailPanel,
|
|
const SizedBox(height: 16),
|
|
twoCol
|
|
? Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: tractionPanel),
|
|
const SizedBox(width: 16),
|
|
Expanded(child: mileagePanel),
|
|
],
|
|
)
|
|
: Column(
|
|
children: [
|
|
tractionPanel,
|
|
const SizedBox(height: 16),
|
|
mileagePanel,
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: _submitting
|
|
? null
|
|
: () => _resetFormState(clearDraft: true),
|
|
icon: const Icon(Icons.clear),
|
|
label: const Text('Clear form'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ElevatedButton.icon(
|
|
onPressed: _submitting ? null : _submit,
|
|
icon: _submitting
|
|
? const SizedBox(
|
|
height: 16,
|
|
width: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.send),
|
|
label: Text(_submitting ? 'Submitting...' : 'Submit entry'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTractionList() {
|
|
if (_tractionItems.length == 1) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
child: Text('No traction selected yet.'),
|
|
);
|
|
}
|
|
return ReorderableListView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
buildDefaultDragHandles: false,
|
|
onReorder: (oldIndex, newIndex) {
|
|
if (newIndex > oldIndex) newIndex -= 1;
|
|
setState(() {
|
|
final item = _tractionItems.removeAt(oldIndex);
|
|
_tractionItems.insert(newIndex, item);
|
|
});
|
|
_saveDraft();
|
|
},
|
|
itemCount: _tractionItems.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _tractionItems[index];
|
|
if (item.isMarker) {
|
|
return Card(
|
|
key: const ValueKey('marker'),
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: const ListTile(
|
|
leading: Icon(Icons.train),
|
|
title: Text('Rolling stock marker'),
|
|
subtitle: Text(
|
|
'Place locomotives above/below. Positions set relative to this.',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final loco = item.loco!;
|
|
final markerIndex = _tractionItems.indexWhere(
|
|
(element) => element.isMarker,
|
|
);
|
|
final pos = index > markerIndex
|
|
? -(index - markerIndex)
|
|
: (markerIndex - 1) - index;
|
|
return Card(
|
|
key: ValueKey('${loco.locoClass}-${loco.number}-$index'),
|
|
child: ListTile(
|
|
leading: ReorderableDragStartListener(
|
|
index: index,
|
|
child: const Icon(Icons.drag_indicator),
|
|
),
|
|
title: Text('${loco.locoClass} ${loco.number}'),
|
|
subtitle: Text('${loco.name ?? ''} · Position $pos'),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Powering'),
|
|
Switch(
|
|
value: item.powering,
|
|
onChanged: (v) {
|
|
setState(() {
|
|
_tractionItems[index] = item.copyWith(powering: v);
|
|
});
|
|
_saveDraft();
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete),
|
|
onPressed: () {
|
|
setState(() {
|
|
_tractionItems.removeAt(index);
|
|
});
|
|
_saveDraft();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _section(String title, List<Widget> children, {Widget? trailing}) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
if (trailing != null) trailing,
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
...children.map(
|
|
(w) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: w,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CalculatorPickerPage extends StatelessWidget {
|
|
const _CalculatorPickerPage({required this.onResult});
|
|
final ValueChanged<RouteResult> onResult;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
title: const Text('Mileage calculator'),
|
|
),
|
|
body: RouteCalculator(onApplyRoute: onResult),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TractionItem {
|
|
final LocoSummary? loco;
|
|
final bool powering;
|
|
final bool isMarker;
|
|
|
|
_TractionItem({
|
|
required this.loco,
|
|
this.powering = true,
|
|
this.isMarker = false,
|
|
});
|
|
|
|
factory _TractionItem.marker() =>
|
|
_TractionItem(loco: null, powering: false, isMarker: true);
|
|
|
|
_TractionItem copyWith({LocoSummary? loco, bool? powering, bool? isMarker}) {
|
|
return _TractionItem(
|
|
loco: loco ?? this.loco,
|
|
powering: powering ?? this.powering,
|
|
isMarker: isMarker ?? this.isMarker,
|
|
);
|
|
}
|
|
}
|