Files
mileograph_flutter/lib/components/pages/new_entry.dart
Pete Gregory 11a5a42ad4
Some checks failed
Release / meta (push) Successful in 20s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
add support for token validation on login page
2025-12-14 12:15:39 +00:00

883 lines
28 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.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,
"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,
"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'] is String ? data['headcode'].toUpperCase() : '';
_notesController.text = data['notes'] ?? '';
_mileageController.text = data['mileage'] ?? '';
_networkController.text =
data['network'] is String ? data['network'].toUpperCase() : '';
} 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,
textCapitalization: TextCapitalization.characters,
inputFormatters: const [_UpperCaseTextFormatter()],
decoration: const InputDecoration(
labelText: 'Headcode',
border: OutlineInputBorder(),
),
),
TextFormField(
controller: _networkController,
textCapitalization: TextCapitalization.characters,
inputFormatters: const [_UpperCaseTextFormatter()],
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 _UpperCaseTextFormatter extends TextInputFormatter {
const _UpperCaseTextFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return newValue.copyWith(
text: newValue.text.toUpperCase(),
selection: newValue.selection,
);
}
}
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,
);
}
}