Some checks failed
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m35s
Release / android-build (push) Successful in 4m50s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
643 lines
21 KiB
Dart
643 lines
21 KiB
Dart
import 'dart:async';
|
|
|
|
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 _useManualMileage = false;
|
|
RouteResult? _routeResult;
|
|
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
|
int? _selectedTripId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
Future.microtask(() {
|
|
if (!mounted) return;
|
|
final data = context.read<DataService>();
|
|
data.fetchClassList();
|
|
data.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')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
if (mounted) {
|
|
context.read<DataService>().refreshLegs();
|
|
}
|
|
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(),
|
|
buildDefaultDragHandles: false,
|
|
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,
|
|
);
|
|
}
|
|
}
|