initial codex commit
Some checks failed
Release / build (push) Failing after 48s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
Release / windows-build (push) Has been cancelled

This commit is contained in:
2025-12-11 01:08:30 +00:00
parent e6d7e71a36
commit 40ee16d2d5
20 changed files with 2902 additions and 283 deletions

View File

@@ -0,0 +1,653 @@
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';
class NewEntryPage extends StatefulWidget {
const NewEntryPage({super.key});
@override
State<NewEntryPage> createState() => _NewEntryPageState();
}
class _NewEntryPageState extends State<NewEntryPage> {
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 _initialised = false;
bool _useManualMileage = false;
RouteResult? _routeResult;
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
int? _selectedTripId;
bool _tripsRequested = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_tripsRequested) {
_tripsRequested = true;
context.read<DataService>().fetchTrips();
}
});
}
@override
void dispose() {
_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));
return Row(
children: [
Expanded(
child: DropdownButtonFormField<int>(
value: _selectedTripId,
decoration: const InputDecoration(
labelText: 'Trip',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(
value: null,
child: Text('No trip'),
),
...sorted.map(
(t) => DropdownMenuItem(
value: t.tripId,
child: Text(t.tripName),
),
),
],
onChanged: (val) => setState(() => _selectedTripId = val),
),
),
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);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Failed to add trip: $e')),
);
}
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<DataService>().fetchClassList();
if (!_tripsRequested) {
_tripsRequested = true;
context.read<DataService>().fetchTrips();
}
});
}
}
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;
});
}
}
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),
);
}
});
},
),
),
);
}
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);
}
Future<void> _pickTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _selectedTime,
);
if (picked != null) setState(() => _selectedTime = picked);
}
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 ?? 0,
"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 ?? 0,
"leg_start": startVal,
"leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_route": routeStations,
"leg_mileage": mileageVal,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"locos": tractionPayload,
};
await api.post('/add', body);
}
try {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Entry submitted')),
);
_formKey.currentState!.reset();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to submit: $e')),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@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',
[
SwitchListTile(
title: const Text('Use manual mileage'),
subtitle: const Text(
'Turn off to calculate mileage automatically'),
value: _useManualMileage,
onChanged: (val) {
setState(() {
_useManualMileage = val;
});
},
),
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'),
),
),
],
);
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),
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(),
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
setState(() {
final item = _tractionItems.removeAt(oldIndex);
_tractionItems.insert(newIndex, item);
});
},
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);
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
setState(() {
_tractionItems.removeAt(index);
});
},
),
],
),
),
);
},
);
}
Widget _section(String title, List<Widget> children) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
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,
);
}
}