From 334d6e3e18d6ef921796ff89861f159b381feb32 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Wed, 17 Dec 2025 16:32:53 +0000 Subject: [PATCH] major refactor --- .gitea/workflows/release.yml | 10 - lib/app.dart | 28 + lib/components/calculator/calculator.dart | 102 +- lib/components/login/login.dart | 7 + lib/components/pages/new_entry.dart | 1680 +---------------- lib/components/pages/new_entry/new_entry.dart | 26 + .../new_entry/new_entry_draft_logic.dart | 340 ++++ .../pages/new_entry/new_entry_drafts.dart | 179 ++ .../pages/new_entry/new_entry_models.dart | 25 + .../pages/new_entry/new_entry_page.dart | 752 ++++++++ .../new_entry/new_entry_picker_pages.dart | 21 + .../new_entry/new_entry_submit_logic.dart | 216 +++ .../new_entry/new_entry_traction_logic.dart | 147 ++ lib/components/pages/traction.dart | 728 +------ lib/components/pages/traction/traction.dart | 14 + .../pages/traction/traction_page.dart | 641 +++++++ .../pages/traction/traction_persistence.dart | 88 + lib/components/pages/trips.dart | 127 +- lib/main.dart | 353 +--- lib/objects/objects.dart | 82 +- lib/services/api_service.dart | 95 +- lib/services/authservice.dart | 5 +- lib/services/data_service.dart | 557 +----- lib/services/data_service/data_service.dart | 12 + .../data_service/data_service_core.dart | 370 ++++ .../data_service/data_service_traction.dart | 118 ++ .../data_service/data_service_trips.dart | 87 + lib/ui/app_shell.dart | 273 +++ test/widget_test.dart | 32 +- 29 files changed, 3614 insertions(+), 3501 deletions(-) create mode 100644 lib/app.dart create mode 100644 lib/components/pages/new_entry/new_entry.dart create mode 100644 lib/components/pages/new_entry/new_entry_draft_logic.dart create mode 100644 lib/components/pages/new_entry/new_entry_drafts.dart create mode 100644 lib/components/pages/new_entry/new_entry_models.dart create mode 100644 lib/components/pages/new_entry/new_entry_page.dart create mode 100644 lib/components/pages/new_entry/new_entry_picker_pages.dart create mode 100644 lib/components/pages/new_entry/new_entry_submit_logic.dart create mode 100644 lib/components/pages/new_entry/new_entry_traction_logic.dart create mode 100644 lib/components/pages/traction/traction.dart create mode 100644 lib/components/pages/traction/traction_page.dart create mode 100644 lib/components/pages/traction/traction_persistence.dart create mode 100644 lib/services/data_service/data_service.dart create mode 100644 lib/services/data_service/data_service_core.dart create mode 100644 lib/services/data_service/data_service_traction.dart create mode 100644 lib/services/data_service/data_service_trips.dart create mode 100644 lib/ui/app_shell.dart diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1cbb00b..2d79293 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -15,8 +15,6 @@ env: jobs: meta: - runs-on: - - mileograph outputs: base_version: ${{ steps.meta.outputs.base }} release_tag: ${{ steps.meta.outputs.release_tag }} @@ -65,8 +63,6 @@ jobs: fi android-build: - runs-on: - - mileograph needs: meta steps: - name: Checkout @@ -186,8 +182,6 @@ jobs: path: mileograph-${{ needs.meta.outputs.base_version }}.apk linux-build: - runs-on: - - mileograph needs: meta steps: - name: Checkout @@ -239,8 +233,6 @@ jobs: path: app-linux-x64.tar.gz release-dev: - runs-on: - - mileograph needs: - meta - android-build @@ -314,8 +306,6 @@ jobs: >/dev/null release-master: - runs-on: - - mileograph needs: - meta - android-build diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..8183d9b --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/ui/app_shell.dart'; +import 'package:provider/provider.dart'; + +class App extends StatelessWidget { + const App({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider( + create: (_) => ApiService(baseUrl: 'https://mileograph.co.uk/api/v1'), + ), + ChangeNotifierProvider( + create: (context) => AuthService(api: context.read()), + ), + ChangeNotifierProvider( + create: (context) => DataService(api: context.read()), + ), + ], + child: const MyApp(), + ); + } +} diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index 12a213e..c0cbaaa 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -25,9 +25,6 @@ class StationAutocomplete extends StatefulWidget { class _StationAutocompleteState extends State { late final TextEditingController _controller; - // Simulated list of over 10,000 stations - final List stations = List.generate(10000, (i) => 'Station $i'); - @override void initState() { super.initState(); @@ -50,15 +47,7 @@ class _StationAutocompleteState extends State { if (textEditingValue.text.isEmpty) { return const Iterable.empty(); } - final query = textEditingValue.text.toLowerCase(); - final matches = widget.allStations - .map((s) => s.name) - .where((name) => name.toLowerCase().contains(query)) - .toList(); - - matches.sort((a, b) => a.length.compareTo(b.length)); - - return matches.take(10); + return _findTopMatches(textEditingValue.text); }, onSelected: (String selection) { _controller.text = selection; @@ -73,19 +62,12 @@ class _StationAutocompleteState extends State { focusNode: focusNode, textInputAction: TextInputAction.done, onSubmitted: (_) { - final query = textEditingController.text.toLowerCase(); - final matches = widget.allStations - .map((s) => s.name) - .where((name) => name.toLowerCase().contains(query)) - .toList(); - - if (matches.isNotEmpty) { - matches.sort((a, b) => a.length.compareTo(b.length)); - final firstMatch = matches.first; - _controller.text = firstMatch; - widget.onChanged(firstMatch); - focusNode.unfocus(); // optionally close keyboard - } + final matches = _findTopMatches(textEditingController.text); + final firstMatch = matches.isEmpty ? null : matches.first; + if (firstMatch == null) return; + _controller.text = firstMatch; + widget.onChanged(firstMatch); + focusNode.unfocus(); // optionally close keyboard }, decoration: const InputDecoration( labelText: 'Select station', @@ -95,6 +77,42 @@ class _StationAutocompleteState extends State { }, ); } + + Iterable _findTopMatches(String rawQuery) { + final query = rawQuery.trim().toLowerCase(); + if (query.isEmpty) return const []; + + // Keep a bounded, sorted list (by shortest name, then alpha) without + // sorting the entire match set. + final best = []; + for (final station in widget.allStations) { + final name = station.name; + if (name.isEmpty) continue; + if (!name.toLowerCase().contains(query)) continue; + + _insertCandidate(best, name, max: 10); + } + return best; + } + + void _insertCandidate(List best, String candidate, {required int max}) { + final existingIndex = best.indexOf(candidate); + if (existingIndex >= 0) return; + + int insertAt = 0; + while (insertAt < best.length && + _candidateCompare(best[insertAt], candidate) <= 0) { + insertAt++; + } + best.insert(insertAt, candidate); + if (best.length > max) best.removeLast(); + } + + int _candidateCompare(String a, String b) { + final byLength = a.length.compareTo(b.length); + if (byLength != 0) return byLength; + return a.compareTo(b); + } } class RouteCalculator extends StatefulWidget { @@ -146,20 +164,28 @@ class _RouteCalculatorState extends State { _routeResult = null; }); final api = context.read(); // context is valid here - final res = await api.post('/route/distance2', { - 'route': stations.where((s) => s.trim().isNotEmpty).toList(), - }); + try { + final res = await api.post('/route/distance2', { + 'route': stations.where((s) => s.trim().isNotEmpty).toList(), + }); - if (res['error'] == false) { - setState(() { - _routeResult = RouteResult.fromJson(res); - }); - final distance = (_routeResult?.distance ?? 0); - widget.onDistanceComputed?.call(distance); - } else { - setState(() { - _errorMessage = RouteError.fromJson(res["error_obj"][0]).msg; - }); + if (res is Map && res['error'] == false) { + setState(() { + _routeResult = RouteResult.fromJson(Map.from(res)); + }); + final distance = (_routeResult?.distance ?? 0); + widget.onDistanceComputed?.call(distance); + } else if (res is Map && res['error_obj'] is List && res['error_obj'].isNotEmpty) { + setState(() { + _errorMessage = RouteError.fromJson( + Map.from(res['error_obj'][0] as Map), + ).msg; + }); + } else { + setState(() => _errorMessage = 'Failed to calculate route.'); + } + } catch (e) { + setState(() => _errorMessage = 'Failed to calculate route: $e'); } } diff --git a/lib/components/login/login.dart b/lib/components/login/login.dart index af8a5aa..fb91d89 100644 --- a/lib/components/login/login.dart +++ b/lib/components/login/login.dart @@ -151,6 +151,13 @@ class _LoginPanelContentState extends State { bool _loggingIn = false; + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + Future login() async { final username = _usernameController.text; final password = _passwordController.text; diff --git a/lib/components/pages/new_entry.dart b/lib/components/pages/new_entry.dart index 033078d..c4f32b8 100644 --- a/lib/components/pages/new_entry.dart +++ b/lib/components/pages/new_entry.dart @@ -1,1680 +1,2 @@ -import 'dart:async'; -import 'dart:convert'; +export 'new_entry/new_entry.dart'; -import 'package:collection/collection.dart'; -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/api_service.dart'; -import 'package:mileograph_flutter/services/data_service.dart'; -import 'package:mileograph_flutter/services/navigation_guard.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class NewEntryPage extends StatefulWidget { - const NewEntryPage({super.key, this.editLegId}); - - final int? editLegId; - - @override - State createState() => _NewEntryPageState(); -} - -class _NewEntryPageState extends State { - static const _draftPrefsKey = 'new_entry_draft'; - static const _draftListPrefsKey = 'new_entry_drafts_list'; - - final _formKey = GlobalKey(); - 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; - bool _loadingEdit = false; - String? _loadError; - Map? _lastSubmittedSnapshot; - Map? _loadedDraftSnapshot; - final DeepCollectionEquality _snapshotEquality = - const DeepCollectionEquality(); - String? _activeDraftId; - - bool get _isEditing => widget.editLegId != null; - bool get _draftPersistenceEnabled => - false; // legacy single draft disabled in favor of draft list - - @override - void initState() { - super.initState(); - NavigationGuard.register(_handleExitIntent); - // legacy single-draft auto-save listeners removed in favor of explicit multi-draft flow - Future.microtask(() { - if (!mounted) return; - final data = context.read(); - data.fetchClassList(); - data.fetchTrips(); - if (_draftPersistenceEnabled) { - _loadDraft(); - } - if (_isEditing && widget.editLegId != null) { - _loadLegForEdit(widget.editLegId!); - } - }); - } - - @override - void dispose() { - NavigationGuard.unregister(_handleExitIntent); - _startController.dispose(); - _endController.dispose(); - _headcodeController.dispose(); - _notesController.dispose(); - _mileageController.dispose(); - _networkController.dispose(); - super.dispose(); - } - - Widget _buildTripSelector(BuildContext context) { - final trips = context.watch().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( - value: selectedValue, - 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); - _saveDraft(); - }, - ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: () => _showAddTripDialog(context), - icon: const Icon(Icons.add), - label: const Text('New Trip'), - ), - ], - ); - } - - Future _showAddTripDialog(BuildContext context) async { - final controller = TextEditingController(); - final result = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('New Trip'), - content: TextField( - controller: controller, - decoration: const InputDecoration(labelText: 'Trip name'), - autofocus: true, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()), - child: const Text('Add'), - ), - ], - ), - ); - if (!context.mounted) { - controller.dispose(); - return; - } - if (result != null && result.isNotEmpty) { - final api = context.read(); - final data = context.read(); - final messenger = ScaffoldMessenger.maybeOf(context); - try { - await api.put('/trips/new', {"trip_name": result}); - await data.fetchTrips(); - if (!context.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 (!context.mounted) return; - messenger?.showSnackBar( - SnackBar(content: Text('Failed to add trip: $e')), - ); - } finally { - controller.dispose(); - } - } else { - controller.dispose(); - } - } - - Future _openCalculator() async { - final result = await Navigator.of(context).push( - 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 _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 _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 _pickTime() async { - final picked = await showTimePicker( - context: context, - initialTime: _selectedTime, - ); - if (picked != null) { - setState(() => _selectedTime = picked); - _saveDraft(); - } - } - - Future _loadLegForEdit(int legId) async { - setState(() { - _loadingEdit = true; - _loadError = null; - }); - try { - final api = context.read(); - final json = await api.get('/legs/by-id/$legId'); - if (!mounted) return; - if (json is! Map) { - throw Exception('Unexpected response for leg $legId'); - } - final beginTime = - DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate; - final routeStations = _parseRouteStations(json['leg_route']); - final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0; - final useManual = routeStations.isEmpty; - final routeResult = useManual - ? null - : RouteResult( - inputRoute: routeStations, - calculatedRoute: routeStations, - costs: const [], - distance: mileageVal, - ); - final tractionItems = _buildTractionFromApi( - (json['locos'] as List? ?? []) - .whereType() - .map((e) => Map.from(e)) - .toList(), - ); - - _restoringDraft = true; - setState(() { - final tripRaw = json['leg_trip']; - final tripId = tripRaw is num ? tripRaw.toInt() : null; - _selectedTripId = tripId == null || tripId == 0 ? null : tripId; - _selectedDate = beginTime; - _selectedTime = TimeOfDay.fromDateTime(beginTime); - _useManualMileage = useManual; - _routeResult = routeResult; - _startController.text = json['leg_start'] ?? ''; - _endController.text = json['leg_end'] ?? ''; - _headcodeController.text = (json['leg_headcode'] as String? ?? '') - .toUpperCase(); - _notesController.text = json['leg_notes'] ?? ''; - _networkController.text = (json['leg_network'] as String? ?? '') - .toUpperCase(); - _mileageController.text = mileageVal == 0 - ? '' - : mileageVal.toStringAsFixed(2); - _tractionItems - ..clear() - ..addAll(tractionItems); - if (_tractionItems.where((e) => e.isMarker).isEmpty) { - _tractionItems.insert(0, _TractionItem.marker()); - } - _lastSubmittedSnapshot = null; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _loadError = 'Failed to load entry: $e'; - }); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to load entry: $e'))); - } finally { - _restoringDraft = false; - if (mounted) { - setState(() => _loadingEdit = false); - } - } - } - - List _parseRouteStations(dynamic raw) { - if (raw is List) { - return raw.map((e) => e.toString()).toList(); - } - if (raw is String) { - final trimmed = raw.trim(); - if (trimmed.isEmpty) return []; - try { - final decoded = jsonDecode(trimmed); - if (decoded is List) { - return decoded.map((e) => e.toString()).toList(); - } - } catch (_) { - // ignore and try alternative parsing - } - if (trimmed.startsWith('[') && trimmed.endsWith(']')) { - try { - final replaced = trimmed.replaceAll("'", '"'); - final decoded = jsonDecode(replaced); - if (decoded is List) { - return decoded.map((e) => e.toString()).toList(); - } - } catch (_) {} - } - if (trimmed.contains('->')) { - return trimmed - .split('->') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(); - } - if (trimmed.contains(',')) { - return trimmed - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(); - } - return [trimmed]; - } - return []; - } - - List<_TractionItem> _buildTractionFromApi( - List> locoData, - ) { - if (locoData.isEmpty) return [_TractionItem.marker()]; - final sorted = [...locoData] - ..sort((a, b) { - return _allocPos(b).compareTo(_allocPos(a)); - }); - final leading = sorted.where((e) => _allocPos(e) >= 0); - final trailing = sorted.where((e) => _allocPos(e) < 0); - return [ - ...leading.map(_mapLocoToTractionItem), - _TractionItem.marker(), - ...trailing.map(_mapLocoToTractionItem), - ]; - } - - int _allocPos(Map loco) => - (loco['alloc_pos'] as num?)?.toInt() ?? 0; - - _TractionItem _mapLocoToTractionItem(Map loco) { - final poweringRaw = loco['alloc_powering']; - final powering = poweringRaw == true || poweringRaw == 1; - return _TractionItem(loco: LocoSummary.fromJson(loco), powering: powering); - } - - DateTime get _legDateTime => DateTime( - _selectedDate.year, - _selectedDate.month, - _selectedDate.day, - _selectedTime.hour, - _selectedTime.minute, - ); - - List> _buildTractionPayload() { - final markerIndex = _tractionItems.indexWhere( - (element) => element.isMarker, - ); - final payload = >[]; - 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 _handleExitIntent() async { - if (!mounted) return false; - if (_isEditing) return true; - if (_formIsEmpty()) return true; - if (_activeDraftId != null && !_draftChangedFromBaseline()) { - return true; - } - final choice = await _promptSaveDraft(); - if (choice == _ExitChoice.cancel) return false; - if (choice == _ExitChoice.save) { - await _saveDraftEntry(draftId: _activeDraftId); - } else if (choice == _ExitChoice.discard) { - await _resetFormState(clearDraft: true); - _activeDraftId = null; - } - return true; - } - - bool _draftChangedFromBaseline() { - if (_loadedDraftSnapshot == null) return true; - final current = _buildDraftSnapshot( - id: _activeDraftId ?? 'temp', - includeTimestamp: false, - ); - return !_snapshotEquality.equals(_loadedDraftSnapshot, current); - } - - bool _formIsEmpty() { - return _startController.text.trim().isEmpty && - _endController.text.trim().isEmpty && - _headcodeController.text.trim().isEmpty && - _notesController.text.trim().isEmpty && - _networkController.text.trim().isEmpty && - _mileageController.text.trim().isEmpty && - _routeResult == null && - _tractionItems.length <= 1; - } - - Future<_ExitChoice> _promptSaveDraft() async { - if (!mounted) return _ExitChoice.cancel; - final result = await showDialog<_ExitChoice>( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (_) => AlertDialog( - title: const Text('Save draft?'), - content: const Text( - 'Do you want to save this entry as a draft before leaving?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(_ExitChoice.discard), - child: const Text('No'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_ExitChoice.save), - child: const Text('Yes'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel), - child: const Text('Cancel'), - ), - ], - ), - ); - return result ?? _ExitChoice.cancel; - } - - Future _openDrafts() async { - final selected = await Navigator.of(context).push<_StoredDraft>( - MaterialPageRoute( - builder: (_) => _DraftListPage( - loadDrafts: _loadSavedDrafts, - onDeleteDraft: _deleteDraft, - ), - ), - ); - if (selected != null) { - _activeDraftId = selected.id; - await _loadDraftEntry(selected.data); - } - } - - Future _validateRequiredFields() async { - final missing = []; - - if (_useManualMileage) { - if (_startController.text.trim().isEmpty) missing.add('From'); - if (_endController.text.trim().isEmpty) missing.add('To'); - final mileageText = _mileageController.text.trim(); - if (double.tryParse(mileageText) == null) { - missing.add('Mileage'); - } - } else { - if (_routeResult == null || _routeResult!.calculatedRoute.isEmpty) { - missing.add('Route'); - } - } - - if (_networkController.text.trim().isEmpty) { - missing.add('Network'); - } - - if (missing.isEmpty) return true; - if (!mounted) return false; - - final fieldList = missing.join(', '); - await showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Required field missing'), - content: Text( - missing.length == 1 - ? 'Please fill the following field: $fieldList.' - : 'Please fill the following fields: $fieldList.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], - ), - ); - return false; - } - - Future _submit() async { - if (!_formKey.currentState!.validate()) return; - if (!await _validateRequiredFields()) return; - 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(); - final snapshot = _buildSubmissionSnapshot( - routeStations: routeStations, - startVal: startVal, - endVal: endVal, - mileageVal: mileageVal, - tractionPayload: tractionPayload, - ); - if (_lastSubmittedSnapshot != null && - _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { - final confirmed = await _confirmDuplicateSubmission(); - if (!confirmed) return; - } - if (!mounted) return; - final api = context.read(); - final dataService = context.read(); - final messenger = ScaffoldMessenger.maybeOf(context); - setState(() => _submitting = true); - final isEditingExisting = _isEditing && widget.editLegId != null; - - try { - if (_useManualMileage) { - final body = { - if (isEditingExisting) "leg_id": widget.editLegId, - "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, - }; - if (isEditingExisting) { - await api.put('/update', body); - } else { - await api.post('/add/manual', body); - } - } else { - final body = { - if (isEditingExisting) "leg_id": widget.editLegId, - "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, - }; - if (isEditingExisting) { - await api.put('/update', body); - } else { - await api.post('/add', body); - } - } - if (!mounted) return; - dataService.refreshLegs(); - if (!mounted) return; - messenger?.showSnackBar( - SnackBar( - content: Text( - isEditingExisting ? 'Entry updated' : 'Entry submitted', - ), - ), - ); - _lastSubmittedSnapshot = snapshot; - _activeDraftId = null; - } catch (e) { - if (!mounted) return; - messenger?.showSnackBar( - SnackBar(content: Text('Failed to submit: $e')), - ); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - Map _buildSubmissionSnapshot({ - required List routeStations, - required String startVal, - required String endVal, - required double mileageVal, - required List> tractionPayload, - }) { - return { - "legId": widget.editLegId, - "useManualMileage": _useManualMileage, - "tripId": _selectedTripId, - "legDateTime": _legDateTime.toIso8601String(), - "start": startVal, - "end": endVal, - "routeStations": routeStations, - "mileage": mileageVal, - "network": _networkController.text.trim(), - "notes": _notesController.text.trim(), - "headcode": _headcodeController.text.trim(), - "locos": tractionPayload, - "routeResult": _routeResult == null - ? null - : { - "input_route": _routeResult!.inputRoute, - "calculated_route": _routeResult!.calculatedRoute, - "costs": _routeResult!.costs, - "distance": _routeResult!.distance, - }, - }; - } - - Future _confirmDuplicateSubmission() async { - if (!mounted) return false; - final result = await showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Duplicate entry?'), - content: const Text('Entry already added, are you sure?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Submit anyway'), - ), - ], - ), - ); - return result ?? false; - } - - Future _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; - _activeDraftId = null; - }); - if (clearDraft) { - await _clearDraft(); - } - } - - Future _saveDraft() async { - if (_restoringDraft || !_draftPersistenceEnabled) 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 _clearDraft() async { - if (!_draftPersistenceEnabled) return; - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_draftPrefsKey); - } - - Future> _loadSavedDrafts() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_draftListPrefsKey); - if (raw == null || raw.isEmpty) return []; - try { - final decoded = jsonDecode(raw); - if (decoded is! List) return []; - return decoded - .whereType() - .map((e) => _StoredDraft.fromJson(Map.from(e))) - .toList() - ..sort((a, b) => b.savedAt.compareTo(a.savedAt)); - } catch (_) { - return []; - } - } - - Future _deleteDraft(String id) async { - final drafts = await _loadSavedDrafts(); - drafts.removeWhere((d) => d.id == id); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString( - _draftListPrefsKey, - jsonEncode(drafts.map((e) => e.toJson()).toList()), - ); - if (_activeDraftId == id) { - _activeDraftId = null; - } - } - - Future _saveDraftEntry({String? draftId}) async { - final id = draftId ?? DateTime.now().microsecondsSinceEpoch.toString(); - final snapshot = _buildDraftSnapshot(id: id); - final drafts = await _loadSavedDrafts(); - final now = DateTime.now(); - final existingIndex = drafts.indexWhere((d) => d.id == id); - final newDraft = _StoredDraft(id: id, savedAt: now, data: snapshot); - if (existingIndex >= 0) { - drafts[existingIndex] = newDraft; - } else { - drafts.insert(0, newDraft); - } - final prefs = await SharedPreferences.getInstance(); - await prefs.setString( - _draftListPrefsKey, - jsonEncode(drafts.map((e) => e.toJson()).toList()), - ); - _activeDraftId = id; - _loadedDraftSnapshot = _buildDraftSnapshot(id: id, includeTimestamp: false); - return id; - } - - Map _buildDraftSnapshot({ - required String id, - bool includeTimestamp = true, - }) { - 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(); - final payload = _useManualMileage - ? { - "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, - } - : { - "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, - "leg_mileage": _routeResult?.distance ?? mileageVal, - }; - return { - "id": id, - if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(), - "mode": _useManualMileage ? 'manual' : 'auto', - "payload": payload, - "routeResult": _routeResult == null - ? null - : { - "input_route": _routeResult!.inputRoute, - "calculated_route": _routeResult!.calculatedRoute, - "costs": _routeResult!.costs, - "distance": _routeResult!.distance, - }, - "tractionItems": _serializeTractionItems(), - }; - } - - Future _loadDraftEntry(Map data) async { - if (!mounted) return; - final payloadRaw = data['payload']; - if (payloadRaw is! Map) return; - final payload = Map.from(payloadRaw); - final mode = data['mode'] as String?; - final useManual = - mode == 'manual' || - (payload.containsKey('leg_distance') && - !payload.containsKey('leg_route')); - final beginStr = payload['leg_begin_time'] as String?; - final beginTime = beginStr == null - ? DateTime.now() - : DateTime.tryParse(beginStr) ?? DateTime.now(); - final tripRaw = payload['leg_trip']; - final tripId = tripRaw is num ? tripRaw.toInt() : null; - - List routeStations = []; - RouteResult? restoredRouteResult; - if (!useManual) { - if (payload['leg_route'] is List) { - routeStations = (payload['leg_route'] as List) - .map((e) => e.toString()) - .toList(); - } - final rr = data['routeResult']; - if (rr is Map) { - restoredRouteResult = RouteResult( - inputRoute: - (rr['input_route'] as List?)?.map((e) => e.toString()).toList() ?? - routeStations, - calculatedRoute: - (rr['calculated_route'] as List?) - ?.map((e) => e.toString()) - .toList() ?? - routeStations, - costs: - (rr['costs'] as List?) - ?.map((e) => (e as num).toDouble()) - .toList() ?? - [], - distance: - (rr['distance'] as num?)?.toDouble() ?? - (payload['leg_mileage'] as num?)?.toDouble() ?? - 0, - ); - } else if (routeStations.isNotEmpty) { - restoredRouteResult = RouteResult( - inputRoute: routeStations, - calculatedRoute: routeStations, - costs: const [], - distance: (payload['leg_mileage'] as num?)?.toDouble() ?? 0, - ); - } - } - - _restoringDraft = true; - setState(() { - _useManualMileage = useManual; - _selectedDate = beginTime; - _selectedTime = TimeOfDay.fromDateTime(beginTime); - _selectedTripId = tripId == null || tripId == 0 ? null : tripId; - _routeResult = restoredRouteResult; - _headcodeController.text = (payload['leg_headcode'] as String? ?? '') - .toUpperCase(); - _networkController.text = (payload['leg_network'] as String? ?? '') - .toUpperCase(); - _notesController.text = payload['leg_notes'] ?? ''; - - if (useManual) { - _startController.text = payload['leg_start'] ?? ''; - _endController.text = payload['leg_end'] ?? ''; - final miles = (payload['leg_distance'] as num?)?.toDouble(); - _mileageController.text = miles == null || miles == 0 - ? '' - : miles.toStringAsFixed(2); - } else { - _startController.text = routeStations.isNotEmpty - ? routeStations.first - : ''; - _endController.text = routeStations.isNotEmpty - ? routeStations.last - : ''; - final dist = _routeResult?.distance ?? 0; - _mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2); - } - - final tractionRaw = data['tractionItems']; - if (tractionRaw is List) { - _restoreTractionItems( - List>.from(tractionRaw.cast()), - ); - } else { - _tractionItems - ..clear() - ..add(_TractionItem.marker()); - } - _lastSubmittedSnapshot = null; - final idRaw = data['id']; - if (idRaw != null) { - _activeDraftId = idRaw.toString(); - } - }); - final baselineId = - _activeDraftId ?? data['id']?.toString() ?? DateTime.now().toString(); - _loadedDraftSnapshot = _buildDraftSnapshot( - id: baselineId, - includeTimestamp: false, - ); - _restoringDraft = false; - } - - Future _loadDraft() async { - // legacy single draft no-op - } - - List> _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> items) { - final restored = <_TractionItem>[]; - for (final item in items) { - final locoData = item['loco'] as Map?; - 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) { - Widget body; - if (_isEditing && _loadingEdit) { - body = const Center(child: CircularProgressIndicator()); - } else if (_isEditing && _loadError != null) { - body = Center(child: Text(_loadError!)); - } else { - final isMobile = MediaQuery.of(context).size.width < 700; - body = Form( - key: _formKey, - child: LayoutBuilder( - builder: (context, constraints) { - final twoCol = !isMobile && constraints.maxWidth > 1000; - final tractionEmpty = _tractionItems.length == 1; - final mileageEmpty = !_useManualMileage && _routeResult == null; - final balancePanels = twoCol && tractionEmpty && mileageEmpty; - final balancedHeight = balancePanels ? 165.0 : null; - - final detailPanel = _section('Details', [ - Row( - children: [ - TextButton.icon( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: _isEditing ? null : _openDrafts, - icon: const Icon(Icons.list_alt, size: 16), - label: const Text('Drafts'), - ), - const Spacer(), - TextButton.icon( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: _submitting - ? null - : () => _resetFormState(clearDraft: true), - icon: const Icon(Icons.clear, size: 16), - label: const Text('Clear form'), - ), - ], - ), - _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(), - ], minHeight: balancedHeight); - - final mileagePanel = _section( - 'Mileage', - [ - if (!_useManualMileage) - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - minimumSize: const Size(0, 32), - ), - onPressed: _openCalculator, - icon: const Icon(Icons.calculate, size: 18), - label: const Text('Open mileage calculator'), - ), - ), - 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', - ), - ) - else - const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Text( - 'No route selected. Use the calculator to add a route.', - ), - ), - ], - trailing: FilterChip( - label: Text(_useManualMileage ? 'Manual' : 'Automatic'), - selected: _useManualMileage, - onSelected: (val) { - setState(() => _useManualMileage = val); - _saveDraft(); - }, - ), - minHeight: balancedHeight, - ); - - 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 - ? (_isEditing ? 'Saving...' : 'Submitting...') - : (_isEditing ? 'Save changes' : 'Submit entry'), - ), - ), - ], - ), - ); - }, - ), - ); - } - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) async { - if (didPop) return; - final allow = await _handleExitIntent(); - if (allow && context.mounted) { - Navigator.of(context).maybePop(); - } - }, - child: Scaffold( - appBar: _isEditing - ? AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () async { - if (!await _handleExitIntent()) return; - if (!context.mounted) return; - Navigator.of(context).maybePop(); - }, - ), - title: const Text('Edit entry'), - ) - : null, - body: body, - ), - ); - } - - 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 children, { - Widget? trailing, - double? minHeight, - }) { - Widget card = 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, - ), - ), - ], - ), - ), - ); - - if (minHeight != null) { - card = ConstrainedBox( - constraints: BoxConstraints(minHeight: minHeight), - child: card, - ); - } - - return card; - } -} - -class _UpperCaseTextFormatter extends TextInputFormatter { - const _UpperCaseTextFormatter(); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - return newValue.copyWith( - text: newValue.text.toUpperCase(), - selection: newValue.selection, - ); - } -} - -enum _ExitChoice { save, discard, cancel } - -class _StoredDraft { - final String id; - final DateTime savedAt; - final Map data; - - _StoredDraft({required this.id, required this.savedAt, required this.data}); - - factory _StoredDraft.fromJson(Map json) { - final savedAt = DateTime.tryParse(json['saved_at'] ?? '') ?? DateTime.now(); - final data = Map.from(json['data'] as Map? ?? {}); - final embeddedId = data['id']?.toString(); - return _StoredDraft( - id: - json['id']?.toString() ?? - embeddedId ?? - savedAt.microsecondsSinceEpoch.toString(), - savedAt: savedAt, - data: data, - ); - } - - Map toJson() { - return {"id": id, "saved_at": savedAt.toIso8601String(), "data": data}; - } -} - -class _DraftListPage extends StatelessWidget { - const _DraftListPage({required this.loadDrafts, required this.onDeleteDraft}); - final Future> Function() loadDrafts; - final Future Function(String id) onDeleteDraft; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Drafts')), - body: FutureBuilder>( - future: loadDrafts(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final drafts = snapshot.data ?? const []; - if (drafts.isEmpty) { - return const Center(child: Text('No drafts saved yet.')); - } - return _DraftListBody(drafts: drafts, onDelete: onDeleteDraft); - }, - ), - ); - } -} - -class _DraftListBody extends StatefulWidget { - const _DraftListBody({required this.drafts, required this.onDelete}); - final List<_StoredDraft> drafts; - final Future Function(String id) onDelete; - - @override - State<_DraftListBody> createState() => _DraftListBodyState(); -} - -class _DraftListBodyState extends State<_DraftListBody> { - late final List<_StoredDraft> _drafts = List.of(widget.drafts); - - @override - Widget build(BuildContext context) { - return ListView.separated( - itemCount: _drafts.length, - separatorBuilder: (context, _) => const Divider(height: 0), - itemBuilder: (context, index) { - final draft = _drafts[index]; - final routeLine = _draftSubtitle(draft); - final metaLine = _draftMetaLine(draft); - return ListTile( - title: Text(DateFormat.yMMMd().add_jm().format(draft.savedAt)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (routeLine != null) Text(routeLine), - if (metaLine.isNotEmpty) Text(metaLine), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Delete draft', - icon: const Icon(Icons.delete), - onPressed: () => _confirmDelete(context, draft), - ), - const Icon(Icons.chevron_right), - ], - ), - onTap: () => Navigator.of(context).pop(draft), - ); - }, - ); - } - - Future _confirmDelete(BuildContext context, _StoredDraft draft) async { - final confirmed = await showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: const Text('Delete draft?'), - content: const Text('This draft will be removed permanently.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(true), - child: const Text('Delete'), - ), - ], - ), - ); - if (confirmed != true) return; - await widget.onDelete(draft.id); - if (!mounted) return; - setState(() { - _drafts.removeWhere((d) => d.id == draft.id); - }); - } - - String? _draftSubtitle(_StoredDraft draft) { - final payload = draft.data['payload']; - if (payload is! Map) return null; - final map = Map.from(payload); - String start = map['leg_start']?.toString() ?? ''; - String end = map['leg_end']?.toString() ?? ''; - if (start.isEmpty && end.isEmpty) { - if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) { - start = (map['leg_route'] as List).first.toString(); - end = (map['leg_route'] as List).last.toString(); - } - } - if (start.isEmpty && end.isEmpty) return null; - if (start.isNotEmpty && end.isNotEmpty) { - return '$start โ†’ $end'; - } - return start.isNotEmpty ? start : end; - } - - String _draftMetaLine(_StoredDraft draft) { - final payload = draft.data['payload']; - if (payload is! Map) return ''; - final map = Map.from(payload); - final parts = []; - if ((map['leg_trip'] as int? ?? 0) != 0) { - parts.add('Trip ${map['leg_trip']}'); - } - final headcode = (map['leg_headcode'] as String? ?? '').trim(); - if (headcode.isNotEmpty) parts.add('Headcode $headcode'); - final network = (map['leg_network'] as String? ?? '').trim(); - if (network.isNotEmpty) parts.add('Network $network'); - final notes = (map['leg_notes'] as String? ?? '').trim(); - if (notes.isNotEmpty) parts.add('Notes'); - final mileage = - (map['leg_distance'] as num?)?.toDouble() ?? - (map['leg_mileage'] as num?)?.toDouble(); - if (mileage != null && mileage > 0) { - parts.add('${mileage.toStringAsFixed(1)} mi'); - } else if (map['leg_route'] is List && - (map['leg_route'] as List).isNotEmpty) { - parts.add('Route ${(map['leg_route'] as List).length} stops'); - } - final locos = map['locos']; - if (locos is List && locos.isNotEmpty) { - parts.add('${locos.length} traction'); - } - return parts.join(' โ€ข '); - } -} - -class _CalculatorPickerPage extends StatelessWidget { - const _CalculatorPickerPage({required this.onResult}); - final ValueChanged 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, - ); - } -} diff --git a/lib/components/pages/new_entry/new_entry.dart b/lib/components/pages/new_entry/new_entry.dart new file mode 100644 index 0000000..9bb48a1 --- /dev/null +++ b/lib/components/pages/new_entry/new_entry.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +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/api_service.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/navigation_guard.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'new_entry_page.dart'; +part 'new_entry_drafts.dart'; +part 'new_entry_picker_pages.dart'; +part 'new_entry_models.dart'; +part 'new_entry_draft_logic.dart'; +part 'new_entry_submit_logic.dart'; +part 'new_entry_traction_logic.dart'; + +const String _kDraftPrefsKey = 'new_entry_draft'; +const String _kDraftListPrefsKey = 'new_entry_drafts_list'; diff --git a/lib/components/pages/new_entry/new_entry_draft_logic.dart b/lib/components/pages/new_entry/new_entry_draft_logic.dart new file mode 100644 index 0000000..54e12e5 --- /dev/null +++ b/lib/components/pages/new_entry/new_entry_draft_logic.dart @@ -0,0 +1,340 @@ +part of 'new_entry.dart'; + +extension _NewEntryDraftLogic on _NewEntryPageState { + Future _handleExitIntent() async { + if (!mounted) return false; + if (_isEditing) return true; + if (_formIsEmpty()) return true; + if (_activeDraftId != null && !_draftChangedFromBaseline()) { + return true; + } + final choice = await _promptSaveDraft(); + if (choice == _ExitChoice.cancel) return false; + if (choice == _ExitChoice.save) { + await _saveDraftEntry(draftId: _activeDraftId); + } else if (choice == _ExitChoice.discard) { + await _resetFormState(clearDraft: true); + _activeDraftId = null; + } + return true; + } + + bool _draftChangedFromBaseline() { + if (_loadedDraftSnapshot == null) return true; + final current = _buildDraftSnapshot( + id: _activeDraftId ?? 'temp', + includeTimestamp: false, + ); + return !_snapshotEquality.equals(_loadedDraftSnapshot, current); + } + + bool _formIsEmpty() { + return _startController.text.trim().isEmpty && + _endController.text.trim().isEmpty && + _headcodeController.text.trim().isEmpty && + _notesController.text.trim().isEmpty && + _networkController.text.trim().isEmpty && + _mileageController.text.trim().isEmpty && + _routeResult == null && + _tractionItems.length <= 1; + } + + Future<_ExitChoice> _promptSaveDraft() async { + if (!mounted) return _ExitChoice.cancel; + final result = await showDialog<_ExitChoice>( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (_) => AlertDialog( + title: const Text('Save draft?'), + content: const Text( + 'Do you want to save this entry as a draft before leaving?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(_ExitChoice.discard), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_ExitChoice.save), + child: const Text('Yes'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel), + child: const Text('Cancel'), + ), + ], + ), + ); + return result ?? _ExitChoice.cancel; + } + + Future _openDrafts() async { + final selected = await Navigator.of(context).push<_StoredDraft>( + MaterialPageRoute( + builder: (_) => _DraftListPage( + loadDrafts: _loadSavedDrafts, + onDeleteDraft: _deleteDraft, + ), + ), + ); + if (selected != null) { + _activeDraftId = selected.id; + await _loadDraftEntry(selected.data); + } + } + + Future _saveDraft() async { + if (_restoringDraft || !_draftPersistenceEnabled) 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(_kDraftPrefsKey, jsonEncode(draft)); + } + + Future _clearDraft() async { + if (!_draftPersistenceEnabled) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kDraftPrefsKey); + } + + Future> _loadSavedDrafts() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kDraftListPrefsKey); + if (raw == null || raw.isEmpty) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + return decoded + .whereType() + .map((e) => _StoredDraft.fromJson(Map.from(e))) + .toList() + ..sort((a, b) => b.savedAt.compareTo(a.savedAt)); + } catch (_) { + return []; + } + } + + Future _deleteDraft(String id) async { + final drafts = await _loadSavedDrafts(); + drafts.removeWhere((d) => d.id == id); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _kDraftListPrefsKey, + jsonEncode(drafts.map((e) => e.toJson()).toList()), + ); + if (_activeDraftId == id) { + _activeDraftId = null; + } + } + + Future _saveDraftEntry({String? draftId}) async { + final id = draftId ?? DateTime.now().microsecondsSinceEpoch.toString(); + final snapshot = _buildDraftSnapshot(id: id); + final drafts = await _loadSavedDrafts(); + final now = DateTime.now(); + final existingIndex = drafts.indexWhere((d) => d.id == id); + final newDraft = _StoredDraft(id: id, savedAt: now, data: snapshot); + if (existingIndex >= 0) { + drafts[existingIndex] = newDraft; + } else { + drafts.insert(0, newDraft); + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _kDraftListPrefsKey, + jsonEncode(drafts.map((e) => e.toJson()).toList()), + ); + _activeDraftId = id; + _loadedDraftSnapshot = _buildDraftSnapshot(id: id, includeTimestamp: false); + return id; + } + + Map _buildDraftSnapshot({ + required String id, + bool includeTimestamp = true, + }) { + 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(); + final payload = _useManualMileage + ? { + "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, + } + : { + "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, + "leg_mileage": _routeResult?.distance ?? mileageVal, + }; + return { + "id": id, + if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(), + "mode": _useManualMileage ? 'manual' : 'auto', + "payload": payload, + "routeResult": _routeResult == null + ? null + : { + "input_route": _routeResult!.inputRoute, + "calculated_route": _routeResult!.calculatedRoute, + "costs": _routeResult!.costs, + "distance": _routeResult!.distance, + }, + "tractionItems": _serializeTractionItems(), + }; + } + + Future _loadDraftEntry(Map data) async { + if (!mounted) return; + final payloadRaw = data['payload']; + if (payloadRaw is! Map) return; + final payload = Map.from(payloadRaw); + final mode = data['mode'] as String?; + final useManual = + mode == 'manual' || + (payload.containsKey('leg_distance') && + !payload.containsKey('leg_route')); + final beginStr = payload['leg_begin_time'] as String?; + final beginTime = beginStr == null + ? DateTime.now() + : DateTime.tryParse(beginStr) ?? DateTime.now(); + final tripRaw = payload['leg_trip']; + final tripId = tripRaw is num ? tripRaw.toInt() : null; + + List routeStations = []; + RouteResult? restoredRouteResult; + if (!useManual) { + if (payload['leg_route'] is List) { + routeStations = (payload['leg_route'] as List) + .map((e) => e.toString()) + .toList(); + } + final rr = data['routeResult']; + if (rr is Map) { + restoredRouteResult = RouteResult( + inputRoute: + (rr['input_route'] as List?)?.map((e) => e.toString()).toList() ?? + routeStations, + calculatedRoute: + (rr['calculated_route'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + routeStations, + costs: + (rr['costs'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList() ?? + [], + distance: + (rr['distance'] as num?)?.toDouble() ?? + (payload['leg_mileage'] as num?)?.toDouble() ?? + 0, + ); + } else if (routeStations.isNotEmpty) { + restoredRouteResult = RouteResult( + inputRoute: routeStations, + calculatedRoute: routeStations, + costs: const [], + distance: (payload['leg_mileage'] as num?)?.toDouble() ?? 0, + ); + } + } + + _restoringDraft = true; + _setState(() { + _useManualMileage = useManual; + _selectedDate = beginTime; + _selectedTime = TimeOfDay.fromDateTime(beginTime); + _selectedTripId = tripId == null || tripId == 0 ? null : tripId; + _routeResult = restoredRouteResult; + _headcodeController.text = (payload['leg_headcode'] as String? ?? '') + .toUpperCase(); + _networkController.text = (payload['leg_network'] as String? ?? '') + .toUpperCase(); + _notesController.text = payload['leg_notes'] ?? ''; + + if (useManual) { + _startController.text = payload['leg_start'] ?? ''; + _endController.text = payload['leg_end'] ?? ''; + final miles = (payload['leg_distance'] as num?)?.toDouble(); + _mileageController.text = miles == null || miles == 0 + ? '' + : miles.toStringAsFixed(2); + } else { + _startController.text = + routeStations.isNotEmpty ? routeStations.first : ''; + _endController.text = + routeStations.isNotEmpty ? routeStations.last : ''; + final dist = _routeResult?.distance ?? 0; + _mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2); + } + + final tractionRaw = data['tractionItems']; + if (tractionRaw is List) { + _restoreTractionItems( + List>.from(tractionRaw.cast()), + ); + } else { + _tractionItems + ..clear() + ..add(_TractionItem.marker()); + } + _lastSubmittedSnapshot = null; + final idRaw = data['id']; + if (idRaw != null) { + _activeDraftId = idRaw.toString(); + } + }); + final baselineId = + _activeDraftId ?? data['id']?.toString() ?? DateTime.now().toString(); + _loadedDraftSnapshot = _buildDraftSnapshot( + id: baselineId, + includeTimestamp: false, + ); + _restoringDraft = false; + } + + Future _loadDraft() async { + // legacy single draft no-op + } +} diff --git a/lib/components/pages/new_entry/new_entry_drafts.dart b/lib/components/pages/new_entry/new_entry_drafts.dart new file mode 100644 index 0000000..1ca8339 --- /dev/null +++ b/lib/components/pages/new_entry/new_entry_drafts.dart @@ -0,0 +1,179 @@ +part of 'new_entry.dart'; + +enum _ExitChoice { save, discard, cancel } + +class _StoredDraft { + final String id; + final DateTime savedAt; + final Map data; + + _StoredDraft({required this.id, required this.savedAt, required this.data}); + + factory _StoredDraft.fromJson(Map json) { + final savedAt = DateTime.tryParse(json['saved_at'] ?? '') ?? DateTime.now(); + final data = Map.from(json['data'] as Map? ?? {}); + final embeddedId = data['id']?.toString(); + return _StoredDraft( + id: + json['id']?.toString() ?? + embeddedId ?? + savedAt.microsecondsSinceEpoch.toString(), + savedAt: savedAt, + data: data, + ); + } + + Map toJson() { + return {"id": id, "saved_at": savedAt.toIso8601String(), "data": data}; + } +} + +class _DraftListPage extends StatelessWidget { + const _DraftListPage({required this.loadDrafts, required this.onDeleteDraft}); + final Future> Function() loadDrafts; + final Future Function(String id) onDeleteDraft; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Drafts')), + body: FutureBuilder>( + future: loadDrafts(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + final drafts = snapshot.data ?? const []; + if (drafts.isEmpty) { + return const Center(child: Text('No drafts saved yet.')); + } + return _DraftListBody(drafts: drafts, onDelete: onDeleteDraft); + }, + ), + ); + } +} + +class _DraftListBody extends StatefulWidget { + const _DraftListBody({required this.drafts, required this.onDelete}); + final List<_StoredDraft> drafts; + final Future Function(String id) onDelete; + + @override + State<_DraftListBody> createState() => _DraftListBodyState(); +} + +class _DraftListBodyState extends State<_DraftListBody> { + late final List<_StoredDraft> _drafts = List.of(widget.drafts); + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemCount: _drafts.length, + separatorBuilder: (context, _) => const Divider(height: 0), + itemBuilder: (context, index) { + final draft = _drafts[index]; + final routeLine = _draftSubtitle(draft); + final metaLine = _draftMetaLine(draft); + return ListTile( + title: Text(DateFormat.yMMMd().add_jm().format(draft.savedAt)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (routeLine != null) Text(routeLine), + if (metaLine.isNotEmpty) Text(metaLine), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Delete draft', + icon: const Icon(Icons.delete), + onPressed: () => _confirmDelete(context, draft), + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => Navigator.of(context).pop(draft), + ); + }, + ); + } + + Future _confirmDelete(BuildContext context, _StoredDraft draft) async { + final confirmed = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Delete draft?'), + content: const Text('This draft will be removed permanently.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + await widget.onDelete(draft.id); + if (!mounted) return; + setState(() { + _drafts.removeWhere((d) => d.id == draft.id); + }); + } + + String? _draftSubtitle(_StoredDraft draft) { + final payload = draft.data['payload']; + if (payload is! Map) return null; + final map = Map.from(payload); + String start = map['leg_start']?.toString() ?? ''; + String end = map['leg_end']?.toString() ?? ''; + if (start.isEmpty && end.isEmpty) { + if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) { + start = (map['leg_route'] as List).first.toString(); + end = (map['leg_route'] as List).last.toString(); + } + } + if (start.isEmpty && end.isEmpty) return null; + if (start.isNotEmpty && end.isNotEmpty) { + return '$start โ†’ $end'; + } + return start.isNotEmpty ? start : end; + } + + String _draftMetaLine(_StoredDraft draft) { + final payload = draft.data['payload']; + if (payload is! Map) return ''; + final map = Map.from(payload); + final parts = []; + if ((map['leg_trip'] as int? ?? 0) != 0) { + parts.add('Trip ${map['leg_trip']}'); + } + final headcode = (map['leg_headcode'] as String? ?? '').trim(); + if (headcode.isNotEmpty) parts.add('Headcode $headcode'); + final network = (map['leg_network'] as String? ?? '').trim(); + if (network.isNotEmpty) parts.add('Network $network'); + final notes = (map['leg_notes'] as String? ?? '').trim(); + if (notes.isNotEmpty) parts.add('Notes'); + final mileage = + (map['leg_distance'] as num?)?.toDouble() ?? + (map['leg_mileage'] as num?)?.toDouble(); + if (mileage != null && mileage > 0) { + parts.add('${mileage.toStringAsFixed(1)} mi'); + } else if (map['leg_route'] is List && + (map['leg_route'] as List).isNotEmpty) { + parts.add('Route ${(map['leg_route'] as List).length} stops'); + } + final locos = map['locos']; + if (locos is List && locos.isNotEmpty) { + parts.add('${locos.length} traction'); + } + return parts.join(' โ€ข '); + } +} + diff --git a/lib/components/pages/new_entry/new_entry_models.dart b/lib/components/pages/new_entry/new_entry_models.dart new file mode 100644 index 0000000..a393d29 --- /dev/null +++ b/lib/components/pages/new_entry/new_entry_models.dart @@ -0,0 +1,25 @@ +part of 'new_entry.dart'; + +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, + ); + } +} + diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart new file mode 100644 index 0000000..e59e2d2 --- /dev/null +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -0,0 +1,752 @@ +part of 'new_entry.dart'; + +class NewEntryPage extends StatefulWidget { + const NewEntryPage({super.key, this.editLegId}); + + final int? editLegId; + + @override + State createState() => _NewEntryPageState(); +} + +class _NewEntryPageState extends State { + final _formKey = GlobalKey(); + 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; + bool _loadingEdit = false; + String? _loadError; + Map? _lastSubmittedSnapshot; + Map? _loadedDraftSnapshot; + final DeepCollectionEquality _snapshotEquality = + const DeepCollectionEquality(); + String? _activeDraftId; + + bool get _isEditing => widget.editLegId != null; + bool get _draftPersistenceEnabled => + false; // legacy single draft disabled in favor of draft list + + @override + void initState() { + super.initState(); + NavigationGuard.register(_handleExitIntent); + // legacy single-draft auto-save listeners removed in favor of explicit multi-draft flow + Future.microtask(() { + if (!mounted) return; + final data = context.read(); + data.fetchClassList(); + data.fetchTrips(); + if (_draftPersistenceEnabled) { + _loadDraft(); + } + if (_isEditing && widget.editLegId != null) { + _loadLegForEdit(widget.editLegId!); + } + }); + } + + @override + void dispose() { + NavigationGuard.unregister(_handleExitIntent); + _startController.dispose(); + _endController.dispose(); + _headcodeController.dispose(); + _notesController.dispose(); + _mileageController.dispose(); + _networkController.dispose(); + super.dispose(); + } + + void _setState(VoidCallback fn) { + if (!mounted) return; + // ignore: invalid_use_of_protected_member + setState(fn); + } + + Widget _buildTripSelector(BuildContext context) { + final trips = context.watch().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( + value: selectedValue, + 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); + _saveDraft(); + }, + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () => _showAddTripDialog(context), + icon: const Icon(Icons.add), + label: const Text('New Trip'), + ), + ], + ); + } + + Future _showAddTripDialog(BuildContext context) async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('New Trip'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Trip name'), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()), + child: const Text('Add'), + ), + ], + ), + ); + if (!context.mounted) { + controller.dispose(); + return; + } + if (result != null && result.isNotEmpty) { + final api = context.read(); + final data = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + await api.put('/trips/new', {"trip_name": result}); + await data.fetchTrips(); + if (!context.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 (!context.mounted) return; + messenger?.showSnackBar( + SnackBar(content: Text('Failed to add trip: $e')), + ); + } finally { + controller.dispose(); + } + } else { + controller.dispose(); + } + } + + Future _openCalculator() async { + final result = await Navigator.of(context).push( + 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 _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 _pickTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _selectedTime, + ); + if (picked != null) { + setState(() => _selectedTime = picked); + _saveDraft(); + } + } + + Future _loadLegForEdit(int legId) async { + setState(() { + _loadingEdit = true; + _loadError = null; + }); + try { + final api = context.read(); + final json = await api.get('/legs/by-id/$legId'); + if (!mounted) return; + if (json is! Map) { + throw Exception('Unexpected response for leg $legId'); + } + final beginTime = + DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate; + final routeStations = _parseRouteStations(json['leg_route']); + final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0; + final useManual = routeStations.isEmpty; + final routeResult = useManual + ? null + : RouteResult( + inputRoute: routeStations, + calculatedRoute: routeStations, + costs: const [], + distance: mileageVal, + ); + final tractionItems = _buildTractionFromApi( + (json['locos'] as List? ?? []) + .whereType() + .map((e) => Map.from(e)) + .toList(), + ); + + _restoringDraft = true; + setState(() { + final tripRaw = json['leg_trip']; + final tripId = tripRaw is num ? tripRaw.toInt() : null; + _selectedTripId = tripId == null || tripId == 0 ? null : tripId; + _selectedDate = beginTime; + _selectedTime = TimeOfDay.fromDateTime(beginTime); + _useManualMileage = useManual; + _routeResult = routeResult; + _startController.text = json['leg_start'] ?? ''; + _endController.text = json['leg_end'] ?? ''; + _headcodeController.text = (json['leg_headcode'] as String? ?? '') + .toUpperCase(); + _notesController.text = json['leg_notes'] ?? ''; + _networkController.text = (json['leg_network'] as String? ?? '') + .toUpperCase(); + _mileageController.text = mileageVal == 0 + ? '' + : mileageVal.toStringAsFixed(2); + _tractionItems + ..clear() + ..addAll(tractionItems); + if (_tractionItems.where((e) => e.isMarker).isEmpty) { + _tractionItems.insert(0, _TractionItem.marker()); + } + _lastSubmittedSnapshot = null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loadError = 'Failed to load entry: $e'; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to load entry: $e'))); + } finally { + _restoringDraft = false; + if (mounted) { + setState(() => _loadingEdit = false); + } + } + } + + List _parseRouteStations(dynamic raw) { + if (raw is List) { + return raw.map((e) => e.toString()).toList(); + } + if (raw is String) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return []; + try { + final decoded = jsonDecode(trimmed); + if (decoded is List) { + return decoded.map((e) => e.toString()).toList(); + } + } catch (_) { + // ignore and try alternative parsing + } + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + final replaced = trimmed.replaceAll("'", '"'); + final decoded = jsonDecode(replaced); + if (decoded is List) { + return decoded.map((e) => e.toString()).toList(); + } + } catch (_) {} + } + if (trimmed.contains('->')) { + return trimmed + .split('->') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + if (trimmed.contains(',')) { + return trimmed + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + return [trimmed]; + } + return []; + } + + DateTime get _legDateTime => DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day, + _selectedTime.hour, + _selectedTime.minute, + ); + + @override + Widget build(BuildContext context) { + Widget body; + if (_isEditing && _loadingEdit) { + body = const Center(child: CircularProgressIndicator()); + } else if (_isEditing && _loadError != null) { + body = Center(child: Text(_loadError!)); + } else { + final isMobile = MediaQuery.of(context).size.width < 700; + body = Form( + key: _formKey, + child: LayoutBuilder( + builder: (context, constraints) { + final twoCol = !isMobile && constraints.maxWidth > 1000; + final tractionEmpty = _tractionItems.length == 1; + final mileageEmpty = !_useManualMileage && _routeResult == null; + final balancePanels = twoCol && tractionEmpty && mileageEmpty; + final balancedHeight = balancePanels ? 165.0 : null; + + final detailPanel = _section('Details', [ + Row( + children: [ + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _isEditing ? null : _openDrafts, + icon: const Icon(Icons.list_alt, size: 16), + label: const Text('Drafts'), + ), + const Spacer(), + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _submitting + ? null + : () => _resetFormState(clearDraft: true), + icon: const Icon(Icons.clear, size: 16), + label: const Text('Clear form'), + ), + ], + ), + _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(), + ], minHeight: balancedHeight); + + final mileagePanel = _section( + 'Mileage', + [ + if (!_useManualMileage) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + minimumSize: const Size(0, 32), + ), + onPressed: _openCalculator, + icon: const Icon(Icons.calculate, size: 18), + label: const Text('Open mileage calculator'), + ), + ), + 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', + ), + ) + else + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'No route selected. Use the calculator to add a route.', + ), + ), + ], + trailing: FilterChip( + label: Text(_useManualMileage ? 'Manual' : 'Automatic'), + selected: _useManualMileage, + onSelected: (val) { + setState(() => _useManualMileage = val); + _saveDraft(); + }, + ), + minHeight: balancedHeight, + ); + + 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 + ? (_isEditing ? 'Saving...' : 'Submitting...') + : (_isEditing ? 'Save changes' : 'Submit entry'), + ), + ), + ], + ), + ); + }, + ), + ); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + final allow = await _handleExitIntent(); + if (allow && context.mounted) { + Navigator.of(context).maybePop(); + } + }, + child: Scaffold( + appBar: _isEditing + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + if (!await _handleExitIntent()) return; + if (!context.mounted) return; + Navigator.of(context).maybePop(); + }, + ), + title: const Text('Edit entry'), + ) + : null, + body: body, + ), + ); + } + + 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 children, { + Widget? trailing, + double? minHeight, + }) { + Widget card = 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, + ), + ), + ], + ), + ), + ); + + if (minHeight != null) { + card = ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: card, + ); + } + + return card; + } +} + +class _UpperCaseTextFormatter extends TextInputFormatter { + const _UpperCaseTextFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return newValue.copyWith( + text: newValue.text.toUpperCase(), + selection: newValue.selection, + ); + } +} diff --git a/lib/components/pages/new_entry/new_entry_picker_pages.dart b/lib/components/pages/new_entry/new_entry_picker_pages.dart new file mode 100644 index 0000000..88787c4 --- /dev/null +++ b/lib/components/pages/new_entry/new_entry_picker_pages.dart @@ -0,0 +1,21 @@ +part of 'new_entry.dart'; + +class _CalculatorPickerPage extends StatelessWidget { + const _CalculatorPickerPage({required this.onResult}); + final ValueChanged 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), + ); + } +} + diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart new file mode 100644 index 0000000..8ddb1fe --- /dev/null +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -0,0 +1,216 @@ +part of 'new_entry.dart'; + +extension _NewEntrySubmitLogic on _NewEntryPageState { + Future _validateRequiredFields() async { + final missing = []; + + if (_useManualMileage) { + if (_startController.text.trim().isEmpty) missing.add('From'); + if (_endController.text.trim().isEmpty) missing.add('To'); + final mileageText = _mileageController.text.trim(); + if (double.tryParse(mileageText) == null) { + missing.add('Mileage'); + } + } else { + if (_routeResult == null || _routeResult!.calculatedRoute.isEmpty) { + missing.add('Route'); + } + } + + if (_networkController.text.trim().isEmpty) { + missing.add('Network'); + } + + if (missing.isEmpty) return true; + if (!mounted) return false; + + final fieldList = missing.join(', '); + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Required field missing'), + content: Text( + missing.length == 1 + ? 'Please fill the following field: $fieldList.' + : 'Please fill the following fields: $fieldList.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + return false; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + if (!await _validateRequiredFields()) return; + 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(); + final snapshot = _buildSubmissionSnapshot( + routeStations: routeStations, + startVal: startVal, + endVal: endVal, + mileageVal: mileageVal, + tractionPayload: tractionPayload, + ); + if (_lastSubmittedSnapshot != null && + _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { + final confirmed = await _confirmDuplicateSubmission(); + if (!confirmed) return; + } + if (!mounted) return; + final api = context.read(); + final dataService = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + _setState(() => _submitting = true); + final isEditingExisting = _isEditing && widget.editLegId != null; + + try { + if (_useManualMileage) { + final body = { + if (isEditingExisting) "leg_id": widget.editLegId, + "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, + }; + if (isEditingExisting) { + await api.put('/update', body); + } else { + await api.post('/add/manual', body); + } + } else { + final body = { + if (isEditingExisting) "leg_id": widget.editLegId, + "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, + }; + if (isEditingExisting) { + await api.put('/update', body); + } else { + await api.post('/add', body); + } + } + if (!mounted) return; + dataService.refreshLegs(); + if (!mounted) return; + messenger?.showSnackBar( + SnackBar( + content: Text(isEditingExisting ? 'Entry updated' : 'Entry submitted'), + ), + ); + _lastSubmittedSnapshot = snapshot; + _activeDraftId = null; + } catch (e) { + if (!mounted) return; + messenger?.showSnackBar( + SnackBar(content: Text('Failed to submit: $e')), + ); + } finally { + if (mounted) _setState(() => _submitting = false); + } + } + + Map _buildSubmissionSnapshot({ + required List routeStations, + required String startVal, + required String endVal, + required double mileageVal, + required List> tractionPayload, + }) { + return { + "legId": widget.editLegId, + "useManualMileage": _useManualMileage, + "tripId": _selectedTripId, + "legDateTime": _legDateTime.toIso8601String(), + "start": startVal, + "end": endVal, + "routeStations": routeStations, + "mileage": mileageVal, + "network": _networkController.text.trim(), + "notes": _notesController.text.trim(), + "headcode": _headcodeController.text.trim(), + "locos": tractionPayload, + "routeResult": _routeResult == null + ? null + : { + "input_route": _routeResult!.inputRoute, + "calculated_route": _routeResult!.calculatedRoute, + "costs": _routeResult!.costs, + "distance": _routeResult!.distance, + }, + }; + } + + Future _confirmDuplicateSubmission() async { + if (!mounted) return false; + final result = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Duplicate entry?'), + content: const Text('Entry already added, are you sure?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Submit anyway'), + ), + ], + ), + ); + return result ?? false; + } + + Future _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; + _activeDraftId = null; + }); + if (clearDraft) { + await _clearDraft(); + } + } +} diff --git a/lib/components/pages/new_entry/new_entry_traction_logic.dart b/lib/components/pages/new_entry/new_entry_traction_logic.dart new file mode 100644 index 0000000..4321092 --- /dev/null +++ b/lib/components/pages/new_entry/new_entry_traction_logic.dart @@ -0,0 +1,147 @@ +part of 'new_entry.dart'; + +extension _NewEntryTractionLogic on _NewEntryPageState { + Future _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(); + }, + ), + ), + ); + } + + List<_TractionItem> _buildTractionFromApi( + List> locoData, + ) { + if (locoData.isEmpty) return [_TractionItem.marker()]; + final sorted = [...locoData] + ..sort((a, b) { + return _allocPos(b).compareTo(_allocPos(a)); + }); + final leading = sorted.where((e) => _allocPos(e) >= 0); + final trailing = sorted.where((e) => _allocPos(e) < 0); + return [ + ...leading.map(_mapLocoToTractionItem), + _TractionItem.marker(), + ...trailing.map(_mapLocoToTractionItem), + ]; + } + + int _allocPos(Map loco) => + (loco['alloc_pos'] as num?)?.toInt() ?? 0; + + _TractionItem _mapLocoToTractionItem(Map loco) { + final poweringRaw = loco['alloc_powering']; + final powering = poweringRaw == true || poweringRaw == 1; + return _TractionItem(loco: LocoSummary.fromJson(loco), powering: powering); + } + + List> _buildTractionPayload() { + final markerIndex = _tractionItems.indexWhere( + (element) => element.isMarker, + ); + final payload = >[]; + 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; + } + + List> _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> items) { + final restored = <_TractionItem>[]; + for (final item in items) { + final locoData = item['loco'] as Map?; + 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); + } +} diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index 544fce5..b490d80 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -1,728 +1,2 @@ -import 'dart:convert'; +export 'traction/traction.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mileograph_flutter/components/traction/traction_card.dart'; -import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/data_service.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class TractionPage extends StatefulWidget { - const TractionPage({ - super.key, - this.selectionMode = false, - this.onSelect, - this.selectedKeys = const {}, - }); - - final bool selectionMode; - final ValueChanged? onSelect; - final Set selectedKeys; - - @override - State createState() => _TractionPageState(); -} - -class _TractionPageState extends State { - static const String _prefsKey = 'traction_search_state_v1'; - final _classController = TextEditingController(); - final _classFocusNode = FocusNode(); - final _numberController = TextEditingController(); - final _nameController = TextEditingController(); - bool _mileageFirst = true; - bool _initialised = false; - bool _showAdvancedFilters = false; - String? _selectedClass; - late Set _selectedKeys; - - final Map _dynamicControllers = {}; - final Map _enumSelections = {}; - bool _restoredFromPrefs = false; - - @override - void initState() { - super.initState(); - _classController.addListener(_onClassTextChanged); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (!_initialised) { - _initialised = true; - _selectedKeys = {...widget.selectedKeys}; - WidgetsBinding.instance.addPostFrameCallback((_) { - _initialLoad(); - }); - } - } - - Future _initialLoad() async { - final data = context.read(); - await _restoreSearchState(); - data.fetchClassList(); - data.fetchEventFields(); - await _refreshTraction(); - } - - Future _restoreSearchState() async { - if (widget.selectionMode) return; - if (_restoredFromPrefs) return; - _restoredFromPrefs = true; - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_prefsKey); - if (raw == null || raw.trim().isEmpty) return; - final decoded = jsonDecode(raw); - if (decoded is! Map) return; - - final classText = decoded['classText']?.toString(); - final numberText = decoded['number']?.toString(); - final nameText = decoded['name']?.toString(); - final selectedClass = decoded['selectedClass']?.toString(); - final mileageFirst = decoded['mileageFirst']; - final showAdvanced = decoded['showAdvancedFilters']; - - if (classText != null) _classController.text = classText; - if (numberText != null) _numberController.text = numberText; - if (nameText != null) _nameController.text = nameText; - - final dynamicValues = {}; - final enumValues = {}; - final dynamicRaw = decoded['dynamic']; - if (dynamicRaw is Map) { - for (final entry in dynamicRaw.entries) { - final key = entry.key.toString(); - final val = entry.value?.toString() ?? ''; - dynamicValues[key] = val; - } - } - final enumRaw = decoded['enum']; - if (enumRaw is Map) { - for (final entry in enumRaw.entries) { - enumValues[entry.key.toString()] = entry.value?.toString(); - } - } - - for (final entry in dynamicValues.entries) { - _dynamicControllers.putIfAbsent( - entry.key, - () => TextEditingController(text: entry.value), - ); - _dynamicControllers[entry.key]?.text = entry.value; - } - for (final entry in enumValues.entries) { - _enumSelections[entry.key] = entry.value; - } - - if (!mounted) return; - setState(() { - _selectedClass = (selectedClass != null && selectedClass.trim().isNotEmpty) - ? selectedClass - : null; - if (mileageFirst is bool) _mileageFirst = mileageFirst; - if (showAdvanced is bool) _showAdvancedFilters = showAdvanced; - }); - } catch (_) { - // Ignore preference restore failures. - } - } - - Future _persistSearchState() async { - if (widget.selectionMode) return; - final payload = { - 'classText': _classController.text, - 'number': _numberController.text, - 'name': _nameController.text, - 'selectedClass': _selectedClass, - 'mileageFirst': _mileageFirst, - 'showAdvancedFilters': _showAdvancedFilters, - 'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)), - 'enum': _enumSelections, - }; - - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefsKey, jsonEncode(payload)); - } catch (_) { - // Ignore persistence failures. - } - } - - @override - void dispose() { - _classController.removeListener(_onClassTextChanged); - _persistSearchState(); - _classController.dispose(); - _classFocusNode.dispose(); - _numberController.dispose(); - _nameController.dispose(); - for (final controller in _dynamicControllers.values) { - controller.dispose(); - } - super.dispose(); - } - - bool get _hasFilters { - final dynamicFieldsUsed = - _dynamicControllers.values.any( - (controller) => controller.text.trim().isNotEmpty, - ) || - _enumSelections.values.any( - (value) => (value ?? '').toString().trim().isNotEmpty, - ); - - return [ - _selectedClass, - _classController.text, - _numberController.text, - _nameController.text, - ].any((value) => (value ?? '').toString().trim().isNotEmpty) || - dynamicFieldsUsed; - } - - Future _refreshTraction({bool append = false}) async { - final data = context.read(); - final filters = {}; - final name = _nameController.text.trim(); - if (name.isNotEmpty) filters['name'] = name; - _dynamicControllers.forEach((key, controller) { - final value = controller.text.trim(); - if (value.isNotEmpty) filters[key] = value; - }); - _enumSelections.forEach((key, value) { - if (value != null && value.toString().trim().isNotEmpty) { - filters[key] = value; - } - }); - final hadOnly = !_hasFilters; - await data.fetchTraction( - hadOnly: hadOnly, - locoClass: _selectedClass ?? _classController.text.trim(), - locoNumber: _numberController.text.trim(), - offset: append ? data.traction.length : 0, - append: append, - filters: filters, - mileageFirst: _mileageFirst, - ); - await _persistSearchState(); - } - - void _clearFilters() { - for (final controller in [ - _classController, - _numberController, - _nameController, - ]) { - controller.clear(); - } - for (final controller in _dynamicControllers.values) { - controller.clear(); - } - _enumSelections.clear(); - setState(() { - _selectedClass = null; - _mileageFirst = true; - }); - _refreshTraction(); - } - - void _onClassTextChanged() { - if (_selectedClass != null && - _classController.text.trim() != (_selectedClass ?? '')) { - setState(() { - _selectedClass = null; - }); - } - } - - List _activeEventFields(List fields) { - return fields - .where( - (field) => ![ - 'class', - 'number', - 'name', - 'build date', - 'build_date', - ].contains(field.name.toLowerCase()), - ) - .toList(); - } - - void _ensureControllersForFields(List fields) { - for (final field in fields) { - if (field.enumValues != null) { - _enumSelections.putIfAbsent(field.name, () => null); - } else { - _dynamicControllers.putIfAbsent( - field.name, - () => TextEditingController(), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final data = context.watch(); - final traction = data.traction; - final classOptions = data.locoClasses; - final isMobile = MediaQuery.of(context).size.width < 700; - _ensureControllersForFields(data.eventFields); - final extraFields = _activeEventFields(data.eventFields); - - final listView = RefreshIndicator( - onRefresh: _refreshTraction, - child: ListView( - padding: const EdgeInsets.all(16), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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: [ - IconButton( - tooltip: 'Refresh', - onPressed: _refreshTraction, - icon: const Icon(Icons.refresh), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: () async { - final createdClass = await context.push( - '/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'), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Filters', - style: Theme.of(context).textTheme.titleMedium, - ), - TextButton( - onPressed: _clearFilters, - child: const Text('Clear'), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - SizedBox( - width: isMobile ? double.infinity : 240, - child: RawAutocomplete( - textEditingController: _classController, - focusNode: _classFocusNode, - optionsBuilder: (TextEditingValue textEditingValue) { - final query = textEditingValue.text.toLowerCase(); - if (query.isEmpty) { - return classOptions; - } - return classOptions.where( - (c) => c.toLowerCase().contains(query), - ); - }, - fieldViewBuilder: - ( - context, - controller, - focusNode, - onFieldSubmitted, - ) { - return TextField( - controller: controller, - focusNode: focusNode, - decoration: const InputDecoration( - labelText: 'Class', - border: OutlineInputBorder(), - ), - onSubmitted: (_) => _refreshTraction(), - ); - }, - optionsViewBuilder: (context, onSelected, options) { - final optionList = options.toList(); - if (optionList.isEmpty) { - return const SizedBox.shrink(); - } - final maxWidth = isMobile - ? MediaQuery.of(context).size.width - 64 - : 240.0; - return Align( - alignment: Alignment.topLeft, - child: Material( - elevation: 4, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxWidth, - maxHeight: 240, - ), - child: ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: optionList.length, - itemBuilder: (context, index) { - final option = optionList[index]; - return ListTile( - title: Text(option), - onTap: () => onSelected(option), - ); - }, - ), - ), - ), - ); - }, - onSelected: (String selection) { - setState(() { - _selectedClass = selection; - _classController.text = selection; - }); - _refreshTraction(); - }, - ), - ), - SizedBox( - width: isMobile ? double.infinity : 220, - child: TextField( - controller: _numberController, - decoration: const InputDecoration( - labelText: 'Number', - border: OutlineInputBorder(), - ), - onSubmitted: (_) => _refreshTraction(), - ), - ), - SizedBox( - width: isMobile ? double.infinity : 220, - child: TextField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Name', - border: OutlineInputBorder(), - ), - onSubmitted: (_) => _refreshTraction(), - ), - ), - FilterChip( - label: Text( - _mileageFirst ? 'Mileage first' : 'Number order', - ), - selected: _mileageFirst, - onSelected: (v) { - setState(() => _mileageFirst = v); - _refreshTraction(); - }, - ), - TextButton.icon( - onPressed: () => setState( - () => _showAdvancedFilters = !_showAdvancedFilters, - ), - icon: Icon( - _showAdvancedFilters - ? Icons.expand_less - : Icons.expand_more, - ), - label: Text( - _showAdvancedFilters - ? 'Hide filters' - : 'More filters', - ), - ), - ElevatedButton.icon( - onPressed: _refreshTraction, - icon: const Icon(Icons.search), - label: const Text('Search'), - ), - ], - ), - AnimatedCrossFade( - crossFadeState: _showAdvancedFilters - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 200), - firstChild: Padding( - padding: const EdgeInsets.only(top: 12.0), - child: data.isEventFieldsLoading - ? const Center( - child: Padding( - padding: EdgeInsets.all(8.0), - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ) - : extraFields.isEmpty - ? const Text('No extra filters available right now.') - : Wrap( - spacing: 12, - runSpacing: 12, - children: extraFields - .map( - (field) => _buildFilterInput( - context, - field, - isMobile, - ), - ) - .toList(), - ), - ), - secondChild: const SizedBox.shrink(), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - Stack( - children: [ - if (data.isTractionLoading && traction.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), - child: Center(child: CircularProgressIndicator()), - ) - else if (traction.isEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'No traction found', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - const Text('Try relaxing the filters or sync again.'), - ], - ), - ), - ) - else - Column( - children: [ - ...traction.map( - (loco) => TractionCard( - loco: loco, - selectionMode: widget.selectionMode, - isSelected: _isSelected(loco), - onShowInfo: () => showTractionDetails(context, loco), - onOpenTimeline: () => _openTimeline(loco), - onOpenLegs: () => _openLegs(loco), - onToggleSelect: - widget.selectionMode ? () => _toggleSelection(loco) : null, - ), - ), - if (data.tractionHasMore || data.isTractionLoading) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: OutlinedButton.icon( - onPressed: data.isTractionLoading - ? null - : () => _refreshTraction(append: true), - icon: data.isTractionLoading - ? const SizedBox( - height: 14, - width: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.expand_more), - label: Text( - data.isTractionLoading ? 'Loading...' : 'Load more', - ), - ), - ), - ], - ), - if (data.isTractionLoading) - Positioned.fill( - child: IgnorePointer( - child: Container( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), - child: const Center(child: CircularProgressIndicator()), - ), - ), - ), - ], - ), - ], - ), - ); - - if (widget.selectionMode) { - return Scaffold( - appBar: AppBar( - leadingWidth: 140, - leading: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: TextButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.arrow_back), - label: const Text('Back'), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - title: null, - ), - body: listView, - ); - } - - return listView; - } - - void _toggleSelection(LocoSummary loco) { - final keyVal = '${loco.locoClass}-${loco.number}'; - if (widget.onSelect != null) { - widget.onSelect!(loco); - } - setState(() { - if (_selectedKeys.contains(keyVal)) { - _selectedKeys.remove(keyVal); - } else { - _selectedKeys.add(keyVal); - } - }); - } - - bool _isSelected(LocoSummary loco) { - final keyVal = '${loco.locoClass}-${loco.number}'; - return _selectedKeys.contains(keyVal); - } - - Future _openTimeline(LocoSummary loco) async { - final label = '${loco.locoClass} ${loco.number}'.trim(); - await context.push( - '/traction/${loco.id}/timeline', - extra: {'label': label}, - ); - if (!mounted) return; - await _refreshTraction(); - } - - Future _openLegs(LocoSummary loco) async { - final label = '${loco.locoClass} ${loco.number}'.trim(); - await context.push( - '/traction/${loco.id}/legs', - extra: {'label': label}, - ); - } - - Widget _buildFilterInput( - BuildContext context, - EventField field, - bool isMobile, - ) { - final width = isMobile ? double.infinity : 220.0; - if (field.enumValues != null && field.enumValues!.isNotEmpty) { - final options = field.enumValues! - .map((e) => e.toString()) - .toSet() - .toList(); - final currentValue = _enumSelections[field.name]; - final safeValue = options.contains(currentValue) ? currentValue : null; - return SizedBox( - width: width, - child: DropdownButtonFormField( - value: safeValue, - decoration: InputDecoration( - labelText: field.display, - border: const OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem(value: null, child: Text('Any')), - ...options.map( - (value) => DropdownMenuItem(value: value, child: Text(value)), - ), - ], - onChanged: (val) { - setState(() { - _enumSelections[field.name] = val; - }); - _refreshTraction(); - }, - ), - ); - } - - final controller = - _dynamicControllers[field.name] ?? TextEditingController(); - _dynamicControllers[field.name] = controller; - TextInputType? inputType; - if (field.type != null) { - final type = field.type!.toLowerCase(); - if (type.contains('int') || - type.contains('num') || - type.contains('double')) { - inputType = const TextInputType.numberWithOptions(decimal: true); - } - } - - return SizedBox( - width: width, - child: TextField( - controller: controller, - keyboardType: inputType, - decoration: InputDecoration( - labelText: field.display, - border: const OutlineInputBorder(), - ), - onSubmitted: (_) => _refreshTraction(), - ), - ); - } -} diff --git a/lib/components/pages/traction/traction.dart b/lib/components/pages/traction/traction.dart new file mode 100644 index 0000000..61d7a68 --- /dev/null +++ b/lib/components/pages/traction/traction.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/components/traction/traction_card.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'traction_page.dart'; +part 'traction_persistence.dart'; + +const String _kTractionSearchPrefsKey = 'traction_search_state_v1'; diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart new file mode 100644 index 0000000..f0dc10e --- /dev/null +++ b/lib/components/pages/traction/traction_page.dart @@ -0,0 +1,641 @@ +part of 'traction.dart'; + +class TractionPage extends StatefulWidget { + const TractionPage({ + super.key, + this.selectionMode = false, + this.onSelect, + this.selectedKeys = const {}, + }); + + final bool selectionMode; + final ValueChanged? onSelect; + final Set selectedKeys; + + @override + State createState() => _TractionPageState(); +} + +class _TractionPageState extends State { + final _classController = TextEditingController(); + final _classFocusNode = FocusNode(); + final _numberController = TextEditingController(); + final _nameController = TextEditingController(); + bool _mileageFirst = true; + bool _initialised = false; + bool _showAdvancedFilters = false; + String? _selectedClass; + late Set _selectedKeys; + + final Map _dynamicControllers = {}; + final Map _enumSelections = {}; + bool _restoredFromPrefs = false; + + @override + void initState() { + super.initState(); + _classController.addListener(_onClassTextChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialised) { + _initialised = true; + _selectedKeys = {...widget.selectedKeys}; + WidgetsBinding.instance.addPostFrameCallback((_) { + _initialLoad(); + }); + } + } + + Future _initialLoad() async { + final data = context.read(); + await _restoreSearchState(); + data.fetchClassList(); + data.fetchEventFields(); + await _refreshTraction(); + } + + @override + void dispose() { + _classController.removeListener(_onClassTextChanged); + _persistSearchState(); + _classController.dispose(); + _classFocusNode.dispose(); + _numberController.dispose(); + _nameController.dispose(); + for (final controller in _dynamicControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + void _setState(VoidCallback fn) { + if (!mounted) return; + // ignore: invalid_use_of_protected_member + setState(fn); + } + + bool get _hasFilters { + final dynamicFieldsUsed = + _dynamicControllers.values.any( + (controller) => controller.text.trim().isNotEmpty, + ) || + _enumSelections.values.any( + (value) => (value ?? '').toString().trim().isNotEmpty, + ); + + return [ + _selectedClass, + _classController.text, + _numberController.text, + _nameController.text, + ].any((value) => (value ?? '').toString().trim().isNotEmpty) || + dynamicFieldsUsed; + } + + Future _refreshTraction({bool append = false}) async { + final data = context.read(); + final filters = {}; + final name = _nameController.text.trim(); + if (name.isNotEmpty) filters['name'] = name; + _dynamicControllers.forEach((key, controller) { + final value = controller.text.trim(); + if (value.isNotEmpty) filters[key] = value; + }); + _enumSelections.forEach((key, value) { + if (value != null && value.toString().trim().isNotEmpty) { + filters[key] = value; + } + }); + final hadOnly = !_hasFilters; + await data.fetchTraction( + hadOnly: hadOnly, + locoClass: _selectedClass ?? _classController.text.trim(), + locoNumber: _numberController.text.trim(), + offset: append ? data.traction.length : 0, + append: append, + filters: filters, + mileageFirst: _mileageFirst, + ); + await _persistSearchState(); + } + + void _clearFilters() { + for (final controller in [ + _classController, + _numberController, + _nameController, + ]) { + controller.clear(); + } + for (final controller in _dynamicControllers.values) { + controller.clear(); + } + _enumSelections.clear(); + setState(() { + _selectedClass = null; + _mileageFirst = true; + }); + _refreshTraction(); + } + + void _onClassTextChanged() { + if (_selectedClass != null && + _classController.text.trim() != (_selectedClass ?? '')) { + setState(() { + _selectedClass = null; + }); + } + } + + List _activeEventFields(List fields) { + return fields + .where( + (field) => ![ + 'class', + 'number', + 'name', + 'build date', + 'build_date', + ].contains(field.name.toLowerCase()), + ) + .toList(); + } + + void _ensureControllersForFields(List fields) { + for (final field in fields) { + if (field.enumValues != null) { + _enumSelections.putIfAbsent(field.name, () => null); + } else { + _dynamicControllers.putIfAbsent( + field.name, + () => TextEditingController(), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final data = context.watch(); + final traction = data.traction; + final classOptions = data.locoClasses; + final isMobile = MediaQuery.of(context).size.width < 700; + _ensureControllersForFields(data.eventFields); + final extraFields = _activeEventFields(data.eventFields); + + final listView = RefreshIndicator( + onRefresh: _refreshTraction, + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + IconButton( + tooltip: 'Refresh', + onPressed: _refreshTraction, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () async { + final createdClass = await context.push( + '/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'), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Filters', + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton( + onPressed: _clearFilters, + child: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: isMobile ? double.infinity : 240, + child: RawAutocomplete( + textEditingController: _classController, + focusNode: _classFocusNode, + optionsBuilder: (TextEditingValue textEditingValue) { + final query = textEditingValue.text.toLowerCase(); + if (query.isEmpty) { + return classOptions; + } + return classOptions.where( + (c) => c.toLowerCase().contains(query), + ); + }, + fieldViewBuilder: + ( + context, + controller, + focusNode, + onFieldSubmitted, + ) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration( + labelText: 'Class', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + final optionList = options.toList(); + if (optionList.isEmpty) { + return const SizedBox.shrink(); + } + final maxWidth = isMobile + ? MediaQuery.of(context).size.width - 64 + : 240.0; + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: 240, + ), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: optionList.length, + itemBuilder: (context, index) { + final option = optionList[index]; + return ListTile( + title: Text(option), + onTap: () => onSelected(option), + ); + }, + ), + ), + ), + ); + }, + onSelected: (String selection) { + setState(() { + _selectedClass = selection; + _classController.text = selection; + }); + _refreshTraction(); + }, + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _numberController, + decoration: const InputDecoration( + labelText: 'Number', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + FilterChip( + label: Text( + _mileageFirst ? 'Mileage first' : 'Number order', + ), + selected: _mileageFirst, + onSelected: (v) { + setState(() => _mileageFirst = v); + _refreshTraction(); + }, + ), + TextButton.icon( + onPressed: () => setState( + () => _showAdvancedFilters = !_showAdvancedFilters, + ), + icon: Icon( + _showAdvancedFilters + ? Icons.expand_less + : Icons.expand_more, + ), + label: Text( + _showAdvancedFilters + ? 'Hide filters' + : 'More filters', + ), + ), + ElevatedButton.icon( + onPressed: _refreshTraction, + icon: const Icon(Icons.search), + label: const Text('Search'), + ), + ], + ), + AnimatedCrossFade( + crossFadeState: _showAdvancedFilters + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: data.isEventFieldsLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ) + : extraFields.isEmpty + ? const Text('No extra filters available right now.') + : Wrap( + spacing: 12, + runSpacing: 12, + children: extraFields + .map( + (field) => _buildFilterInput( + context, + field, + isMobile, + ), + ) + .toList(), + ), + ), + secondChild: const SizedBox.shrink(), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Stack( + children: [ + if (data.isTractionLoading && traction.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: Center(child: CircularProgressIndicator()), + ) + else if (traction.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No traction found', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + const Text('Try relaxing the filters or sync again.'), + ], + ), + ), + ) + else + Column( + children: [ + ...traction.map( + (loco) => TractionCard( + loco: loco, + selectionMode: widget.selectionMode, + isSelected: _isSelected(loco), + onShowInfo: () => showTractionDetails(context, loco), + onOpenTimeline: () => _openTimeline(loco), + onOpenLegs: () => _openLegs(loco), + onToggleSelect: + widget.selectionMode ? () => _toggleSelection(loco) : null, + ), + ), + if (data.tractionHasMore || data.isTractionLoading) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: OutlinedButton.icon( + onPressed: data.isTractionLoading + ? null + : () => _refreshTraction(append: true), + icon: data.isTractionLoading + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text( + data.isTractionLoading ? 'Loading...' : 'Load more', + ), + ), + ), + ], + ), + if (data.isTractionLoading) + Positioned.fill( + child: IgnorePointer( + child: Container( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + child: const Center(child: CircularProgressIndicator()), + ), + ), + ), + ], + ), + ], + ), + ); + + if (widget.selectionMode) { + return Scaffold( + appBar: AppBar( + leadingWidth: 140, + leading: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: TextButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + label: const Text('Back'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + title: null, + ), + body: listView, + ); + } + + return listView; + } + + void _toggleSelection(LocoSummary loco) { + final keyVal = '${loco.locoClass}-${loco.number}'; + if (widget.onSelect != null) { + widget.onSelect!(loco); + } + setState(() { + if (_selectedKeys.contains(keyVal)) { + _selectedKeys.remove(keyVal); + } else { + _selectedKeys.add(keyVal); + } + }); + } + + bool _isSelected(LocoSummary loco) { + final keyVal = '${loco.locoClass}-${loco.number}'; + return _selectedKeys.contains(keyVal); + } + + Future _openTimeline(LocoSummary loco) async { + final label = '${loco.locoClass} ${loco.number}'.trim(); + await context.push( + '/traction/${loco.id}/timeline', + extra: {'label': label}, + ); + if (!mounted) return; + await _refreshTraction(); + } + + Future _openLegs(LocoSummary loco) async { + final label = '${loco.locoClass} ${loco.number}'.trim(); + await context.push( + '/traction/${loco.id}/legs', + extra: {'label': label}, + ); + } + + Widget _buildFilterInput( + BuildContext context, + EventField field, + bool isMobile, + ) { + final width = isMobile ? double.infinity : 220.0; + if (field.enumValues != null && field.enumValues!.isNotEmpty) { + final options = field.enumValues! + .map((e) => e.toString()) + .toSet() + .toList(); + final currentValue = _enumSelections[field.name]; + final safeValue = options.contains(currentValue) ? currentValue : null; + return SizedBox( + width: width, + child: DropdownButtonFormField( + value: safeValue, + decoration: InputDecoration( + labelText: field.display, + border: const OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem(value: null, child: Text('Any')), + ...options.map( + (value) => DropdownMenuItem(value: value, child: Text(value)), + ), + ], + onChanged: (val) { + setState(() { + _enumSelections[field.name] = val; + }); + _refreshTraction(); + }, + ), + ); + } + + final controller = + _dynamicControllers[field.name] ?? TextEditingController(); + _dynamicControllers[field.name] = controller; + TextInputType? inputType; + if (field.type != null) { + final type = field.type!.toLowerCase(); + if (type.contains('int') || + type.contains('num') || + type.contains('double')) { + inputType = const TextInputType.numberWithOptions(decimal: true); + } + } + + return SizedBox( + width: width, + child: TextField( + controller: controller, + keyboardType: inputType, + decoration: InputDecoration( + labelText: field.display, + border: const OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ); + } +} diff --git a/lib/components/pages/traction/traction_persistence.dart b/lib/components/pages/traction/traction_persistence.dart new file mode 100644 index 0000000..41952ae --- /dev/null +++ b/lib/components/pages/traction/traction_persistence.dart @@ -0,0 +1,88 @@ +part of 'traction.dart'; + +extension _TractionPersistence on _TractionPageState { + Future _restoreSearchState() async { + if (widget.selectionMode) return; + if (_restoredFromPrefs) return; + _restoredFromPrefs = true; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kTractionSearchPrefsKey); + if (raw == null || raw.trim().isEmpty) return; + final decoded = jsonDecode(raw); + if (decoded is! Map) return; + + final classText = decoded['classText']?.toString(); + final numberText = decoded['number']?.toString(); + final nameText = decoded['name']?.toString(); + final selectedClass = decoded['selectedClass']?.toString(); + final mileageFirst = decoded['mileageFirst']; + final showAdvanced = decoded['showAdvancedFilters']; + + if (classText != null) _classController.text = classText; + if (numberText != null) _numberController.text = numberText; + if (nameText != null) _nameController.text = nameText; + + final dynamicValues = {}; + final enumValues = {}; + final dynamicRaw = decoded['dynamic']; + if (dynamicRaw is Map) { + for (final entry in dynamicRaw.entries) { + final key = entry.key.toString(); + final val = entry.value?.toString() ?? ''; + dynamicValues[key] = val; + } + } + final enumRaw = decoded['enum']; + if (enumRaw is Map) { + for (final entry in enumRaw.entries) { + enumValues[entry.key.toString()] = entry.value?.toString(); + } + } + + for (final entry in dynamicValues.entries) { + _dynamicControllers.putIfAbsent( + entry.key, + () => TextEditingController(text: entry.value), + ); + _dynamicControllers[entry.key]?.text = entry.value; + } + for (final entry in enumValues.entries) { + _enumSelections[entry.key] = entry.value; + } + + if (!mounted) return; + _setState(() { + _selectedClass = + (selectedClass != null && selectedClass.trim().isNotEmpty) + ? selectedClass + : null; + if (mileageFirst is bool) _mileageFirst = mileageFirst; + if (showAdvanced is bool) _showAdvancedFilters = showAdvanced; + }); + } catch (_) { + // Ignore preference restore failures. + } + } + + Future _persistSearchState() async { + if (widget.selectionMode) return; + final payload = { + 'classText': _classController.text, + 'number': _numberController.text, + 'name': _nameController.text, + 'selectedClass': _selectedClass, + 'mileageFirst': _mileageFirst, + 'showAdvancedFilters': _showAdvancedFilters, + 'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)), + 'enum': _enumSelections, + }; + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kTractionSearchPrefsKey, jsonEncode(payload)); + } catch (_) { + // Ignore persistence failures. + } + } +} diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index f0b37e1..b34cc5c 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -36,48 +36,60 @@ class _TripsPageState extends State { return RefreshIndicator( onRefresh: _refreshTrips, - child: ListView( + child: ListView.builder( padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Journeys', - style: Theme.of(context).textTheme.labelMedium, - ), - const SizedBox(height: 2), - Text( - 'Trips', - style: Theme.of(context).textTheme.headlineSmall, - ), - ], - ), - Row( - children: [ - IconButton( - onPressed: _refreshTrips, - icon: const Icon(Icons.refresh), - tooltip: 'Refresh trips', - ), - ], - ), - ], - ), - const SizedBox(height: 12), - if (showLoading) - const Center( + itemCount: () { + if (showLoading) return 2; + if (tripDetails.isEmpty && tripSummaries.isEmpty) return 2; + if (tripDetails.isEmpty) return 1 + tripSummaries.length; + return 1 + tripDetails.length; + }(), + itemBuilder: (context, index) { + if (index == 0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Journeys', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 2), + Text( + 'Trips', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + IconButton( + onPressed: _refreshTrips, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh trips', + ), + ], + ), + const SizedBox(height: 12), + ], + ); + } + + if (showLoading) { + return const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: CircularProgressIndicator(), ), - ) - else if (tripDetails.isEmpty && tripSummaries.isEmpty) - Card( + ); + } + + if (tripDetails.isEmpty && tripSummaries.isEmpty) { + return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -86,8 +98,8 @@ class _TripsPageState extends State { Text( 'No trips yet', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 8), const Text( @@ -96,29 +108,22 @@ class _TripsPageState extends State { ], ), ), - ) - else if (tripDetails.isEmpty) - Column( - children: tripSummaries - .map( - (trip) => Card( - child: ListTile( - title: Text(trip.tripName), - subtitle: Text( - '${trip.tripMileage.toStringAsFixed(1)} mi', - ), - ), - ), - ) - .toList(), - ) - else - Column( - children: tripDetails - .map((trip) => _buildTripCard(context, trip, isMobile)) - .toList(), - ), - ], + ); + } + + if (tripDetails.isEmpty) { + final trip = tripSummaries[index - 1]; + return Card( + child: ListTile( + title: Text(trip.tripName), + subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), + ), + ); + } + + final trip = tripDetails[index - 1]; + return _buildTripCard(context, trip, isMobile); + }, ), ); } diff --git a/lib/main.dart b/lib/main.dart index cbc1ac0..da8dddb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,356 +1,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/calculator_details.dart'; -import 'package:mileograph_flutter/components/pages/loco_legs.dart'; -import 'package:mileograph_flutter/components/pages/loco_timeline.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'; - -import 'package:mileograph_flutter/components/pages/legs.dart'; -import 'package:mileograph_flutter/services/api_service.dart'; -import 'package:mileograph_flutter/services/authservice.dart'; -import 'package:mileograph_flutter/services/data_service.dart'; -import 'package:mileograph_flutter/services/navigation_guard.dart'; - -import 'components/login/login.dart'; -import 'components/pages/dashboard.dart'; - -import 'package:go_router/go_router.dart'; - -late ApiService api; +import 'package:mileograph_flutter/app.dart'; void main() { - runApp( - MultiProvider( - providers: [ - Provider( - create: (_) { - api = ApiService(baseUrl: 'https://mileograph.co.uk/api/v1'); - return api; - }, - ), - ChangeNotifierProxyProvider( - create: (context) => AuthService(api: context.read()), - update: (_, api, previous) { - return previous ?? AuthService(api: api); - }, - ), - ProxyProvider( - update: (_, auth, previous) { - api.setTokenProvider(() => auth.token); - api.setUnauthorizedHandler(() => auth.handleTokenExpired()); - }, - ), - ChangeNotifierProxyProvider( - create: (context) => DataService(api: context.read()), - update: (_, api, previous) => previous ?? DataService(api: api), - ), - ], - child: MyApp(), - ), - ); + runApp(const App()); } -class MyApp extends StatelessWidget { - MyApp({super.key}); - - final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red); - final ColorScheme defaultDark = ColorScheme.fromSeed( - seedColor: Colors.red, - brightness: Brightness.dark, - ); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - final GoRouter router = GoRouter( - refreshListenable: context - .read(), // `AuthService` extends `ChangeNotifier` - redirect: (context, state) { - final auth = Provider.of(context, listen: false); - final loggedIn = auth.isLoggedIn; - final loggingIn = state.uri.toString() == '/login'; - - // Redirect to login if not logged in and trying to access protected pages - if (!loggedIn && !loggingIn) return '/login'; - - // Redirect to home if already logged in and trying to go to login - if (loggedIn && loggingIn) return '/'; - - // No redirection - return null; - }, - routes: [ - ShellRoute( - builder: (context, state, child) { - return MyHomePage(child: child); - }, - routes: [ - GoRoute(path: '/', builder: (context, state) => const Dashboard()), - GoRoute( - path: '/calculator', - builder: (context, state) => CalculatorPage(), - ), - GoRoute( - path: '/calculator/details', - builder: (context, state) => CalculatorDetailsPage( - result: state.extra, - ), - ), - GoRoute(path: '/legs', builder: (context, state) => LegsPage()), - GoRoute(path: '/traction', builder: (context, state) => TractionPage()), - GoRoute( - path: '/traction/:id/timeline', - builder: (_, state) { - final idParam = state.pathParameters['id']; - final locoId = int.tryParse(idParam ?? '') ?? 0; - final extra = state.extra; - String label = state.uri.queryParameters['label'] ?? ''; - if (extra is Map && extra['label'] is String) { - label = extra['label'] as String; - } else if (extra is String && extra.isNotEmpty) { - label = extra; - } - if (label.trim().isEmpty) { - label = 'Loco $locoId'; - } - return LocoTimelinePage( - locoId: locoId, - locoLabel: label, - ); - }, - ), - GoRoute( - path: '/traction/:id/legs', - builder: (_, state) { - final idParam = state.pathParameters['id']; - final locoId = int.tryParse(idParam ?? '') ?? 0; - final extra = state.extra; - String label = state.uri.queryParameters['label'] ?? ''; - if (extra is Map && extra['label'] is String) { - label = extra['label'] as String; - } else if (extra is String && extra.isNotEmpty) { - label = extra; - } - if (label.trim().isEmpty) { - label = 'Loco $locoId'; - } - return LocoLegsPage( - locoId: locoId, - locoLabel: label, - ); - }, - ), - GoRoute( - path: '/traction/new', - builder: (context, state) => const NewTractionPage(), - ), - GoRoute(path: '/trips', builder: (context, state) => TripsPage()), - GoRoute(path: '/add', builder: (context, state) => NewEntryPage()), - GoRoute( - path: '/legs/edit/:id', - builder: (_, state) { - final idParam = state.pathParameters['id']; - final legId = idParam == null ? null : int.tryParse(idParam); - return NewEntryPage(editLegId: legId); - }, - ), - ], - ), - GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), - ], - ); - - return DynamicColorBuilder( - builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - return MaterialApp.router( - title: 'Flutter Demo', - routerConfig: router, - theme: ThemeData( - useMaterial3: true, - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - //fullPage - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: lightDynamic ?? defaultLight, - ), - darkTheme: ThemeData( - useMaterial3: true, - colorScheme: darkDynamic ?? defaultDark, - ), - themeMode: ThemeMode.system, - ); - }, - ); - } -} - -class MyHomePage extends StatefulWidget { - final Widget child; - const MyHomePage({super.key, required this.child}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - final List contentPages = [ - "/", - "/calculator", - "/legs", - "/traction", - "/trips", - "/add", - ]; - - int _getIndexFromLocation(String location) { - int newIndex = contentPages.indexWhere((path) { - if (location == path) return true; - if (path == '/') return location == '/'; - return location.startsWith('$path/'); - }); - if (newIndex < 0) { - return 0; - } - return newIndex; - } - - Future _onItemTapped(int index, int currentIndex) async { - if (index < 0 || index >= contentPages.length || index == currentIndex) { - return; - } - await NavigationGuard.attemptNavigation(() async { - if (!mounted) return; - context.go(contentPages[index]); - }); - } - - bool loggedIn = false; - bool _fetched = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - if (!_fetched) { - _fetched = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - Future(() async { - if (!mounted) return; - final data = context.read(); - final auth = context.read(); - 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(); - } - if (data.onThisDay.isEmpty) { - data.fetchOnThisDay(); - } - if (data.tripDetails.isEmpty) { - data.fetchTripDetails(); - } - }); - }); - } - } - - @override - Widget build(BuildContext context) { - Widget currentPage; - final location = GoRouterState.of(context).uri.toString(); - final pageIndex = _getIndexFromLocation(location); - final data = context.watch(); - final auth = context.read(); - - if (data.homepageStats != null || !data.isHomepageLoading) { - currentPage = widget.child; - } else { - currentPage = Center(child: CircularProgressIndicator()); - } - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text.rich( - TextSpan( - children: [ - TextSpan(text: "Mile"), - TextSpan( - text: "O", - style: TextStyle(color: Colors.red), - ), - TextSpan(text: "graph"), - ], - style: TextStyle( - decoration: TextDecoration.none, - color: Colors.white, - fontFamily: "Tomatoes", - ), - ), - ), - actions: [ - IconButton(onPressed: null, icon: Icon(Icons.account_circle)), - IconButton(onPressed: auth.logout, icon: Icon(Icons.logout)), - ], - ), - bottomNavigationBar: NavigationBar( - selectedIndex: pageIndex, - onDestinationSelected: (int index) { - _onItemTapped(index, pageIndex); - }, - destinations: [ - NavigationDestination(icon: Icon(Icons.home), label: "Home"), - NavigationDestination(icon: Icon(Icons.route), label: "Calculator"), - NavigationDestination(icon: Icon(Icons.list), label: "Entries"), - NavigationDestination(icon: Icon(Icons.train), label: "Traction"), - NavigationDestination(icon: Icon(Icons.book), label: "Trips"), - NavigationDestination(icon: Icon(Icons.add), label: "Add"), - ], - ), - body: currentPage, - ); - } -} diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 563d1e2..81bc567 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -1,6 +1,31 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +int _asInt(dynamic value, [int fallback = 0]) { + if (value is int) return value; + if (value is num) return value.toInt(); + final parsed = int.tryParse(value?.toString() ?? ''); + return parsed ?? fallback; +} + +double _asDouble(dynamic value, [double fallback = 0]) { + if (value is double) return value; + if (value is num) return value.toDouble(); + final parsed = double.tryParse(value?.toString() ?? ''); + return parsed ?? fallback; +} + +String _asString(dynamic value, [String fallback = '']) { + final str = value?.toString(); + return (str == null) ? fallback : str; +} + +DateTime _asDateTime(dynamic value, [DateTime? fallback]) { + if (value is DateTime) return value; + final parsed = DateTime.tryParse(value?.toString() ?? ''); + return parsed ?? (fallback ?? DateTime.fromMillisecondsSinceEpoch(0)); +} + class DestinationObject { const DestinationObject( this.label, @@ -92,8 +117,8 @@ class YearlyMileage { YearlyMileage({this.year, required this.mileage}); factory YearlyMileage.fromJson(Map json) => YearlyMileage( - year: json['year'], - mileage: (json['mileage'] as num).toDouble(), + year: json['year'] is int ? json['year'] as int : int.tryParse('${json['year']}'), + mileage: _asDouble(json['mileage']), ); } @@ -341,10 +366,10 @@ class LeaderboardEntry { factory LeaderboardEntry.fromJson(Map json) => LeaderboardEntry( - userId: json['user_id'], - username: json['username'], - userFullName: json['user_full_name'], - mileage: (json['mileage'] as num).toDouble(), + userId: _asString(json['user_id']), + username: _asString(json['username']), + userFullName: _asString(json['user_full_name']), + mileage: _asDouble(json['mileage']), ); } @@ -360,9 +385,9 @@ class TripSummary { }); factory TripSummary.fromJson(Map json) => TripSummary( - tripId: json['trip_id'], - tripName: json['trip_name'], - tripMileage: (json['trip_mileage'] as num).toDouble(), + tripId: _asInt(json['trip_id']), + tripName: _asString(json['trip_name']), + tripMileage: _asDouble(json['trip_mileage']), ); } @@ -391,21 +416,22 @@ class Leg { }); factory Leg.fromJson(Map json) => Leg( - id: json['leg_id'], - tripId: json['leg_trip'] ?? 0, - start: json['leg_start'], - end: json['leg_end'], - beginTime: DateTime.parse(json['leg_begin_time']), - timezone: (json['leg_timezone'] as num).toInt(), - network: json['leg_network'] ?? "", - route: json['leg_route'], - mileage: (json['leg_mileage'] as num).toDouble(), - notes: json['leg_notes'] ?? "", - headcode: json['leg_headcode'] ?? "", - driving: json['leg_driving'], - user: json['leg_user'], - locos: (json['locos'] as List) - .map((e) => Loco.fromJson(e as Map)) + id: _asInt(json['leg_id']), + tripId: _asInt(json['leg_trip']), + start: _asString(json['leg_start']), + end: _asString(json['leg_end']), + beginTime: _asDateTime(json['leg_begin_time']), + timezone: _asInt(json['leg_timezone']), + network: _asString(json['leg_network']), + route: _asString(json['leg_route']), + mileage: _asDouble(json['leg_mileage']), + notes: _asString(json['leg_notes']), + headcode: _asString(json['leg_headcode']), + driving: _asInt(json['leg_driving']), + user: _asString(json['leg_user']), + locos: (json['locos'] is List ? (json['locos'] as List) : const []) + .whereType() + .map((e) => Loco.fromJson(Map.from(e))) .toList(), ); } @@ -458,10 +484,10 @@ class Station { }); factory Station.fromJson(Map json) => Station( - id: json['id'], - name: json['name'], - network: json['network'], - country: json['country'], + id: _asInt(json['id']), + name: _asString(json['name']), + network: _asString(json['network']), + country: _asString(json['country']), ); } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 09035d5..9fd805b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -6,10 +6,16 @@ typedef UnauthorizedHandler = Future Function(); class ApiService { final String baseUrl; + final http.Client _client; + final Duration timeout; TokenProvider? _getToken; UnauthorizedHandler? _onUnauthorized; - ApiService({required this.baseUrl}); + ApiService({ + required this.baseUrl, + http.Client? client, + this.timeout = const Duration(seconds: 30), + }) : _client = client ?? http.Client(); void setTokenProvider(TokenProvider provider) { _getToken = provider; @@ -19,6 +25,10 @@ class ApiService { _onUnauthorized = handler; } + void dispose() { + _client.close(); + } + Map _buildHeaders(Map? extra) { final token = _getToken?.call(); final headers = {'accept': 'application/json', ...?extra}; @@ -29,10 +39,12 @@ class ApiService { } Future get(String endpoint, {Map? headers}) async { - final response = await http.get( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(headers), - ); + final response = await _client + .get( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(headers), + ) + .timeout(timeout); return _processResponse(response); } @@ -42,23 +54,27 @@ class ApiService { Map? headers, }) async { final hasBody = data != null; - final response = await http.post( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), - body: hasBody ? jsonEncode(data) : null, - ); + final response = await _client + .post( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), + body: hasBody ? jsonEncode(data) : null, + ) + .timeout(timeout); return _processResponse(response); } Future postForm(String endpoint, Map data) async { - final response = await http.post( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders({ - 'Content-Type': 'application/x-www-form-urlencoded', - 'accept': 'application/json', - }), - body: data, // http package handles form-encoding for Map - ); + final response = await _client + .post( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders({ + 'Content-Type': 'application/x-www-form-urlencoded', + 'accept': 'application/json', + }), + body: data, // http package handles form-encoding for Map + ) + .timeout(timeout); return _processResponse(response); } @@ -68,11 +84,13 @@ class ApiService { Map? headers, }) async { final hasBody = data != null; - final response = await http.put( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), - body: hasBody ? jsonEncode(data) : null, - ); + final response = await _client + .put( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), + body: hasBody ? jsonEncode(data) : null, + ) + .timeout(timeout); return _processResponse(response); } @@ -80,10 +98,12 @@ class ApiService { String endpoint, { Map? headers, }) async { - final response = await http.delete( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(headers), - ); + final response = await _client + .delete( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(headers), + ) + .timeout(timeout); return _processResponse(response); } @@ -92,7 +112,7 @@ class ApiService { } Future _processResponse(http.Response res) async { - final body = res.body.isNotEmpty ? jsonDecode(res.body) : null; + final body = _decodeBody(res); if (res.statusCode >= 200 && res.statusCode < 300) { return body; } @@ -103,4 +123,23 @@ class ApiService { throw Exception('API error ${res.statusCode}: $body'); } + + dynamic _decodeBody(http.Response res) { + if (res.body.isEmpty) return null; + + final contentType = res.headers['content-type'] ?? ''; + final shouldTryJson = contentType.contains('application/json') || + contentType.contains('+json') || + res.body.trimLeft().startsWith('{') || + res.body.trimLeft().startsWith('['); + + if (!shouldTryJson) return res.body; + + try { + return jsonDecode(res.body); + } catch (_) { + // Avoid turning a server-side error body into a client-side crash. + return res.body; + } + } } diff --git a/lib/services/authservice.dart b/lib/services/authservice.dart index b99cd57..dd31f5c 100644 --- a/lib/services/authservice.dart +++ b/lib/services/authservice.dart @@ -9,7 +9,10 @@ class AuthService extends ChangeNotifier { final TokenStorageService _tokenStorage = TokenStorageService(); - AuthService({required this.api}); + AuthService({required this.api}) { + api.setTokenProvider(() => token); + api.setUnauthorizedHandler(handleTokenExpired); + } AuthenticatedUserData? _user; diff --git a/lib/services/data_service.dart b/lib/services/data_service.dart index 2d1fd7a..5d95d7d 100644 --- a/lib/services/data_service.dart +++ b/lib/services/data_service.dart @@ -1,557 +1,2 @@ -import 'dart:async'; -import 'dart:convert'; +export 'data_service/data_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/api_service.dart'; // assumes you've moved HomepageStats + submodels to a separate file - -class _LegFetchOptions { - final int limit; - final String sortBy; - final int sortDirection; - final String? dateRangeStart; - final String? dateRangeEnd; - - const _LegFetchOptions({ - this.limit = 100, - this.sortBy = 'date', - this.sortDirection = 0, - this.dateRangeStart, - this.dateRangeEnd, - }); -} - -class DataService extends ChangeNotifier { - final ApiService api; - - DataService({required this.api}); - - _LegFetchOptions _lastLegsFetch = const _LegFetchOptions(); - - // Homepage Data - HomepageStats? _homepageStats; - HomepageStats? get homepageStats => _homepageStats; - - // Legs Data - List _legs = []; - List get legs => _legs; - List _onThisDay = []; - List get onThisDay => _onThisDay; - bool _isLegsLoading = false; - bool get isLegsLoading => _isLegsLoading; - bool _legsHasMore = false; - bool get legsHasMore => _legsHasMore; - - // Traction Data - List _traction = []; - List get traction => _traction; - bool _isTractionLoading = false; - bool get isTractionLoading => _isTractionLoading; - bool _tractionHasMore = false; - bool get tractionHasMore => _tractionHasMore; - final Map> _locoTimelines = {}; - final Map _isLocoTimelineLoading = {}; - List timelineForLoco(int locoId) => - _locoTimelines[locoId] ?? []; - bool isLocoTimelineLoading(int locoId) => - _isLocoTimelineLoading[locoId] ?? false; - - // Trips - List _trips = []; - List get trips => _trips; - List _tripDetails = []; - List get tripDetails => _tripDetails; - bool _isTripDetailsLoading = false; - bool get isTripDetailsLoading => _isTripDetailsLoading; - List _locoClasses = []; - List get locoClasses => _locoClasses; - List _tripList = []; - List get tripList => _tripList; - List _eventFields = []; - List get eventFields => _eventFields; - bool _isEventFieldsLoading = false; - bool get isEventFieldsLoading => _isEventFieldsLoading; - - // Station Data - List? _cachedStations; - DateTime? _stationsFetchedAt; - - List stations = [""]; - - bool _isHomepageLoading = false; - bool get isHomepageLoading => _isHomepageLoading; - bool _isOnThisDayLoading = false; - bool get isOnThisDayLoading => _isOnThisDayLoading; - - static const List _fallbackEventFields = [ - EventField(name: 'operator', display: 'Operator'), - EventField(name: 'status', display: 'Status'), - EventField(name: 'evn', display: 'EVN'), - EventField(name: 'owner', display: 'Owner'), - EventField(name: 'location', display: 'Location'), - EventField(name: 'livery', display: 'Livery'), - EventField(name: 'domain', display: 'Domain'), - EventField(name: 'type', display: 'Type'), - ]; - - void _notifyAsync() { - // Always defer to the next frame to avoid setState during build. - SchedulerBinding.instance.addPostFrameCallback((_) { - notifyListeners(); - }); - } - - Future fetchHomepageStats() async { - _isHomepageLoading = true; - - try { - final json = await api.get('/stats/homepage'); - _homepageStats = HomepageStats.fromJson(json); - _trips = _homepageStats?.trips ?? []; - } catch (e) { - debugPrint('Failed to fetch homepage stats: $e'); - _homepageStats = null; - _trips = []; - } finally { - _isHomepageLoading = false; - _notifyAsync(); - } - } - - Future fetchLegs({ - int offset = 0, - int limit = 100, - String sortBy = 'date', - int sortDirection = 0, - String? dateRangeStart, - String? dateRangeEnd, - bool append = false, - }) async { - _isLegsLoading = true; - if (!append) { - _lastLegsFetch = _LegFetchOptions( - limit: limit, - sortBy: sortBy, - sortDirection: sortDirection, - dateRangeStart: dateRangeStart, - dateRangeEnd: dateRangeEnd, - ); - } - final buffer = StringBuffer( - '?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit', - ); - if (dateRangeStart != null && dateRangeStart.isNotEmpty) { - buffer.write('&date_range_start=$dateRangeStart'); - } - if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) { - buffer.write('&date_range_end=$dateRangeEnd'); - } - try { - final json = await api.get('/user/legs${buffer.toString()}'); - - if (json is List) { - final newLegs = json.map((e) => Leg.fromJson(e)).toList(); - _legs = append ? [..._legs, ...newLegs] : newLegs; - _legsHasMore = newLegs.length >= limit; - } else { - throw Exception('Unexpected legs response: $json'); - } - } catch (e) { - debugPrint('Failed to fetch legs: $e'); - if (!append) _legs = []; - _legsHasMore = false; - } finally { - _isLegsLoading = false; - _notifyAsync(); - } - } - - Future refreshLegs() { - return fetchLegs( - limit: _lastLegsFetch.limit, - sortBy: _lastLegsFetch.sortBy, - sortDirection: _lastLegsFetch.sortDirection, - dateRangeStart: _lastLegsFetch.dateRangeStart, - dateRangeEnd: _lastLegsFetch.dateRangeEnd, - ); - } - - Future> fetchLegsForLoco( - int locoId, { - bool includeNonPowering = false, - }) async { - if (locoId <= 0) return []; - final params = - includeNonPowering ? '?include_non_powering=true' : ''; - try { - final json = await api.get('/legs/$locoId$params'); - dynamic list = json; - if (json is Map) { - for (final key in ['legs', 'data', 'results']) { - if (json[key] is List) { - list = json[key]; - break; - } - } - } - if (list is List) { - return list - .whereType() - .map((e) => Leg.fromJson(Map.from(e))) - .toList(); - } - debugPrint('Unexpected loco legs response: $json'); - return []; - } catch (e) { - debugPrint('Failed to fetch loco legs for $locoId: $e'); - return []; - } - } - - Future fetchHadTraction({int offset = 0, int limit = 100}) async { - await fetchTraction( - hadOnly: true, - offset: offset, - limit: limit, - append: offset > 0, - ); - } - - Future fetchTraction({ - bool hadOnly = false, - int offset = 0, - int limit = 50, - String? locoClass, - String? locoNumber, - bool mileageFirst = true, - bool append = false, - Map? filters, - }) async { - _isTractionLoading = true; - - try { - final params = StringBuffer('?limit=$limit&offset=$offset'); - if (hadOnly) params.write('&had_only=true'); - if (!mileageFirst) params.write('&mileage_first=false'); - - final payload = {}; - if (locoClass != null && locoClass.isNotEmpty) { - payload['class'] = locoClass; - } - if (locoNumber != null && locoNumber.isNotEmpty) { - payload['number'] = locoNumber; - } - if (filters != null) { - filters.forEach((key, value) { - if (value == null) return; - if (value is String && value.trim().isEmpty) return; - payload[key] = value; - }); - } - - final json = await api.post( - '/locos/search/v2${params.toString()}', - payload.isEmpty ? null : payload, - ); - - if (json is List) { - final newItems = json.map((e) => LocoSummary.fromJson(e)).toList(); - _traction = append ? [..._traction, ...newItems] : newItems; - _tractionHasMore = newItems.length >= limit - 1; - } else { - throw Exception('Unexpected traction response: $json'); - } - } catch (e) { - debugPrint('Failed to fetch traction: $e'); - if (!append) { - _traction = []; - } - _tractionHasMore = false; - } finally { - _isTractionLoading = false; - _notifyAsync(); - } - } - - Future> fetchLocoTimeline(int locoId) async { - _isLocoTimelineLoading[locoId] = true; - _notifyAsync(); - try { - final json = await api.get('/loco/get-timeline/$locoId'); - final timeline = LocoAttrVersion.fromGroupedJson(json); - _locoTimelines[locoId] = timeline; - return timeline; - } catch (e) { - debugPrint('Failed to fetch loco timeline for $locoId: $e'); - _locoTimelines[locoId] = []; - return []; - } finally { - _isLocoTimelineLoading[locoId] = false; - _notifyAsync(); - } - } - - Future createLoco(Map 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 fetchOnThisDay({DateTime? date}) async { - _isOnThisDayLoading = true; - final target = date ?? DateTime.now(); - final formatted = - "${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}"; - try { - final json = await api.get('/legs/on-this-day?date=$formatted'); - if (json is List) { - _onThisDay = json.map((e) => Leg.fromJson(e)).toList(); - } else { - _onThisDay = []; - } - } catch (e) { - debugPrint('Failed to fetch on-this-day legs: $e'); - _onThisDay = []; - } finally { - _isOnThisDayLoading = false; - _notifyAsync(); - } - } - - Future fetchTripDetails() async { - _isTripDetailsLoading = true; - try { - final json = await api.get('/trips/legs-and-stats'); - if (json is List) { - final tripMap = json.map((e) => TripDetail.fromJson(e)).toList(); - _tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id)); - } else { - _tripDetails = []; - } - } catch (e) { - debugPrint('Failed to fetch trip_map: $e'); - _tripDetails = []; - } finally { - _isTripDetailsLoading = false; - _notifyAsync(); - } - } - - Future> fetchTripLocoStats(int tripId) async { - try { - final json = await api.get('/trips/stats/$tripId'); - return _parseTripLocoStats(json); - } catch (e) { - debugPrint('Failed to fetch trip loco stats: $e'); - return []; - } - } - - List _parseTripLocoStats(dynamic json) { - List? list; - if (json is List) { - list = json.expand((e) => e is List ? e : [e]).toList(); - } else if (json is Map) { - for (final key in ['locos', 'stats', 'data', 'trip_locos']) { - final candidate = json[key]; - if (candidate is List) { - list = candidate.expand((e) => e is List ? e : [e]).toList(); - break; - } - } - } - if (list == null) return []; - return list - .whereType>() - .map((e) => TripLocoStat.fromJson(e)) - .toList(); - } - - Future fetchEventFields({bool force = false}) async { - if (_eventFields.isNotEmpty && !force) return; - _isEventFieldsLoading = true; - _notifyAsync(); - try { - final json = await api.get('/event/fields'); - List fields = _parseEventFields(json); - if (fields.isEmpty) { - fields = _fallbackEventFields; - } - _eventFields = fields; - } catch (e) { - debugPrint('Failed to fetch event fields: $e'); - _eventFields = _fallbackEventFields; - } finally { - _isEventFieldsLoading = false; - _notifyAsync(); - } - } - - List _parseEventFields(dynamic json) { - if (json is List) { - return json - .whereType>() - .map(EventField.fromJson) - .toList(); - } - if (json is Map) { - if (json['fields'] is List) { - return (json['fields'] as List) - .whereType>() - .map(EventField.fromJson) - .toList(); - } - // If map of name -> definition - return json.entries - .where((entry) => entry.value is Map) - .map((entry) { - final map = Map.from(entry.value); - map['name'] = entry.key; - return EventField.fromJson(map); - }) - .toList(); - } - return []; - } - - Future fetchTrips() async { - try { - final json = await api.get('/trips/mileage'); - Iterable? raw; - if (json is List) { - raw = json; - } else if (json is Map) { - for (final key in ['trips', 'trip_data', 'data']) { - final value = json[key]; - if (value is List) { - raw = value; - break; - } - } - } - if (raw != null) { - final tripMap = raw - .whereType>() - .map((e) => TripSummary.fromJson(e)) - .toList(); - - _tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId)); - } else { - debugPrint('Unexpected trip list response: $json'); - _tripList = []; - } - } catch (e) { - debugPrint('Failed to fetch trip list: $e'); - _tripList = []; - } finally { - _notifyAsync(); - } - } - - Future> fetchClassList() async { - if (_locoClasses.isNotEmpty) return _locoClasses; - try { - final json = await api.get('/loco/classlist'); - if (json is List) { - _locoClasses = json.map((e) => e.toString()).toList(); - _notifyAsync(); - } - } catch (e) { - debugPrint('Failed to fetch class list: $e'); - } - return _locoClasses; - } - - Future createLocoEvent({ - required int locoId, - required String eventDate, - required Map values, - required String details, - String eventType = 'other', - }) async { - try { - await api.put( - '/event/new', - { - 'loco_id': locoId, - 'loco_event_type': eventType, - 'loco_event_date': eventDate, - 'loco_event_value': jsonEncode(values), - 'loco_event_details': details, - }, - ); - } catch (e) { - debugPrint('Failed to create loco event: $e'); - rethrow; - } - } - - Future deleteTimelineBlock({ - required int blockId, - }) async { - try { - await api.delete('/event/delete/$blockId'); - } catch (e) { - debugPrint('Failed to delete timeline block $blockId: $e'); - rethrow; - } - } - - void clear() { - _homepageStats = null; - _legs = []; - _onThisDay = []; - _trips = []; - _tripDetails = []; - _eventFields = []; - _locoTimelines.clear(); - _isLocoTimelineLoading.clear(); - _notifyAsync(); - } - - double getMileageForCurrentYear() { - final currentYear = DateTime.now().year; - return getMileageForYear(currentYear) ?? 0; - } - - double? getMileageForYear(int year) { - return _homepageStats?.yearlyMileage - .firstWhere( - (entry) => entry.year == year, - orElse: () => YearlyMileage(year: null, mileage: 0), - ) - .mileage ?? - 0; - } - - Future> fetchStations() async { - final now = DateTime.now(); - - // If cache exists and is less than 30 minutes old, return it - if (_cachedStations != null && - _stationsFetchedAt != null && - now.difference(_stationsFetchedAt!) < Duration(minutes: 30)) { - return _cachedStations!; - } - - final response = await api.get('/location'); - final parsed = (response as List).map((e) => Station.fromJson(e)).toList(); - - _cachedStations = parsed; - _stationsFetchedAt = now; - - return parsed; - } -} diff --git a/lib/services/data_service/data_service.dart b/lib/services/data_service/data_service.dart new file mode 100644 index 0000000..b843319 --- /dev/null +++ b/lib/services/data_service/data_service.dart @@ -0,0 +1,12 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; + +part 'data_service_core.dart'; +part 'data_service_traction.dart'; +part 'data_service_trips.dart'; + diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart new file mode 100644 index 0000000..7e50ed1 --- /dev/null +++ b/lib/services/data_service/data_service_core.dart @@ -0,0 +1,370 @@ +part of 'data_service.dart'; + +class _LegFetchOptions { + final int limit; + final String sortBy; + final int sortDirection; + final String? dateRangeStart; + final String? dateRangeEnd; + + const _LegFetchOptions({ + this.limit = 100, + this.sortBy = 'date', + this.sortDirection = 0, + this.dateRangeStart, + this.dateRangeEnd, + }); +} + +class DataService extends ChangeNotifier { + final ApiService api; + + DataService({required this.api}); + + _LegFetchOptions _lastLegsFetch = const _LegFetchOptions(); + + // Homepage Data + HomepageStats? _homepageStats; + HomepageStats? get homepageStats => _homepageStats; + + // Legs Data + List _legs = []; + List get legs => _legs; + List _onThisDay = []; + List get onThisDay => _onThisDay; + bool _isLegsLoading = false; + bool get isLegsLoading => _isLegsLoading; + bool _legsHasMore = false; + bool get legsHasMore => _legsHasMore; + + // Traction Data + List _traction = []; + List get traction => _traction; + bool _isTractionLoading = false; + bool get isTractionLoading => _isTractionLoading; + bool _tractionHasMore = false; + bool get tractionHasMore => _tractionHasMore; + final Map> _locoTimelines = {}; + final Map _isLocoTimelineLoading = {}; + List timelineForLoco(int locoId) => + _locoTimelines[locoId] ?? []; + bool isLocoTimelineLoading(int locoId) => + _isLocoTimelineLoading[locoId] ?? false; + + // Trips + List _trips = []; + List get trips => _trips; + List _tripDetails = []; + List get tripDetails => _tripDetails; + bool _isTripDetailsLoading = false; + bool get isTripDetailsLoading => _isTripDetailsLoading; + List _locoClasses = []; + List get locoClasses => _locoClasses; + List _tripList = []; + List get tripList => _tripList; + List _eventFields = []; + List get eventFields => _eventFields; + bool _isEventFieldsLoading = false; + bool get isEventFieldsLoading => _isEventFieldsLoading; + + // Station Data + List? _cachedStations; + DateTime? _stationsFetchedAt; + Future>? _stationsInFlight; + + List stations = [""]; + + bool _isHomepageLoading = false; + bool get isHomepageLoading => _isHomepageLoading; + bool _isOnThisDayLoading = false; + bool get isOnThisDayLoading => _isOnThisDayLoading; + + static const List _fallbackEventFields = [ + EventField(name: 'operator', display: 'Operator'), + EventField(name: 'status', display: 'Status'), + EventField(name: 'evn', display: 'EVN'), + EventField(name: 'owner', display: 'Owner'), + EventField(name: 'location', display: 'Location'), + EventField(name: 'livery', display: 'Livery'), + EventField(name: 'domain', display: 'Domain'), + EventField(name: 'type', display: 'Type'), + ]; + + void _notifyAsync() { + // Always defer to the next frame to avoid setState during build. + SchedulerBinding.instance.addPostFrameCallback((_) { + notifyListeners(); + }); + } + + Future fetchHomepageStats() async { + _isHomepageLoading = true; + + try { + final json = await api.get('/stats/homepage'); + _homepageStats = HomepageStats.fromJson(json); + _trips = _homepageStats?.trips ?? []; + } catch (e) { + debugPrint('Failed to fetch homepage stats: $e'); + _homepageStats = null; + _trips = []; + } finally { + _isHomepageLoading = false; + _notifyAsync(); + } + } + + Future fetchLegs({ + int offset = 0, + int limit = 100, + String sortBy = 'date', + int sortDirection = 0, + String? dateRangeStart, + String? dateRangeEnd, + bool append = false, + }) async { + _isLegsLoading = true; + if (!append) { + _lastLegsFetch = _LegFetchOptions( + limit: limit, + sortBy: sortBy, + sortDirection: sortDirection, + dateRangeStart: dateRangeStart, + dateRangeEnd: dateRangeEnd, + ); + } + final buffer = StringBuffer( + '?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit', + ); + if (dateRangeStart != null && dateRangeStart.isNotEmpty) { + buffer.write('&date_range_start=$dateRangeStart'); + } + if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) { + buffer.write('&date_range_end=$dateRangeEnd'); + } + try { + final json = await api.get('/user/legs${buffer.toString()}'); + + if (json is List) { + final newLegs = json.map((e) => Leg.fromJson(e)).toList(); + _legs = append ? [..._legs, ...newLegs] : newLegs; + _legsHasMore = newLegs.length >= limit; + } else { + throw Exception('Unexpected legs response: $json'); + } + } catch (e) { + debugPrint('Failed to fetch legs: $e'); + if (!append) _legs = []; + _legsHasMore = false; + } finally { + _isLegsLoading = false; + _notifyAsync(); + } + } + + Future refreshLegs() { + return fetchLegs( + limit: _lastLegsFetch.limit, + sortBy: _lastLegsFetch.sortBy, + sortDirection: _lastLegsFetch.sortDirection, + dateRangeStart: _lastLegsFetch.dateRangeStart, + dateRangeEnd: _lastLegsFetch.dateRangeEnd, + ); + } + + Future> fetchLegsForLoco( + int locoId, { + bool includeNonPowering = false, + }) async { + if (locoId <= 0) return []; + final params = + includeNonPowering ? '?include_non_powering=true' : ''; + try { + final json = await api.get('/legs/$locoId$params'); + dynamic list = json; + if (json is Map) { + for (final key in ['legs', 'data', 'results']) { + if (json[key] is List) { + list = json[key]; + break; + } + } + } + if (list is List) { + return list + .whereType() + .map((e) => Leg.fromJson(Map.from(e))) + .toList(); + } + debugPrint('Unexpected loco legs response: $json'); + return []; + } catch (e) { + debugPrint('Failed to fetch loco legs for $locoId: $e'); + return []; + } + } + + Future fetchOnThisDay({DateTime? date}) async { + _isOnThisDayLoading = true; + final target = date ?? DateTime.now(); + final formatted = + "${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}"; + try { + final json = await api.get('/legs/on-this-day?date=$formatted'); + if (json is List) { + _onThisDay = json.map((e) => Leg.fromJson(e)).toList(); + } else { + _onThisDay = []; + } + } catch (e) { + debugPrint('Failed to fetch on-this-day legs: $e'); + _onThisDay = []; + } finally { + _isOnThisDayLoading = false; + _notifyAsync(); + } + } + + Future fetchEventFields({bool force = false}) async { + if (_eventFields.isNotEmpty && !force) return; + _isEventFieldsLoading = true; + _notifyAsync(); + try { + final json = await api.get('/event/fields'); + List fields = _parseEventFields(json); + if (fields.isEmpty) { + fields = _fallbackEventFields; + } + _eventFields = fields; + } catch (e) { + debugPrint('Failed to fetch event fields: $e'); + _eventFields = _fallbackEventFields; + } finally { + _isEventFieldsLoading = false; + _notifyAsync(); + } + } + + List _parseEventFields(dynamic json) { + if (json is List) { + return json + .whereType>() + .map(EventField.fromJson) + .toList(); + } + if (json is Map) { + if (json['fields'] is List) { + return (json['fields'] as List) + .whereType>() + .map(EventField.fromJson) + .toList(); + } + // If map of name -> definition + return json.entries + .where((entry) => entry.value is Map) + .map((entry) { + final map = Map.from(entry.value); + map['name'] = entry.key; + return EventField.fromJson(map); + }) + .toList(); + } + return []; + } + + Future createLocoEvent({ + required int locoId, + required String eventDate, + required Map values, + required String details, + String eventType = 'other', + }) async { + try { + await api.put( + '/event/new', + { + 'loco_id': locoId, + 'loco_event_type': eventType, + 'loco_event_date': eventDate, + 'loco_event_value': jsonEncode(values), + 'loco_event_details': details, + }, + ); + } catch (e) { + debugPrint('Failed to create loco event: $e'); + rethrow; + } + } + + Future deleteTimelineBlock({ + required int blockId, + }) async { + try { + await api.delete('/event/delete/$blockId'); + } catch (e) { + debugPrint('Failed to delete timeline block $blockId: $e'); + rethrow; + } + } + + void clear() { + _homepageStats = null; + _legs = []; + _onThisDay = []; + _trips = []; + _tripDetails = []; + _eventFields = []; + _locoTimelines.clear(); + _isLocoTimelineLoading.clear(); + _notifyAsync(); + } + + double getMileageForCurrentYear() { + final currentYear = DateTime.now().year; + return getMileageForYear(currentYear) ?? 0; + } + + double? getMileageForYear(int year) { + return _homepageStats?.yearlyMileage + .firstWhere( + (entry) => entry.year == year, + orElse: () => YearlyMileage(year: null, mileage: 0), + ) + .mileage ?? + 0; + } + + Future> fetchStations() async { + final now = DateTime.now(); + + // If cache exists and is less than 30 minutes old, return it + if (_cachedStations != null && + _stationsFetchedAt != null && + now.difference(_stationsFetchedAt!) < Duration(minutes: 30)) { + return _cachedStations!; + } + + if (_stationsInFlight != null) return _stationsInFlight!; + + _stationsInFlight = () async { + try { + final response = await api.get('/location'); + if (response is! List) return const []; + final parsed = response + .whereType() + .map((e) => Station.fromJson(Map.from(e))) + .toList(); + _cachedStations = parsed; + _stationsFetchedAt = now; + return parsed; + } catch (e) { + debugPrint('Failed to fetch stations: $e'); + return const []; + } finally { + _stationsInFlight = null; + } + }(); + + return _stationsInFlight!; + } +} diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart new file mode 100644 index 0000000..187c978 --- /dev/null +++ b/lib/services/data_service/data_service_traction.dart @@ -0,0 +1,118 @@ +part of 'data_service.dart'; + +extension DataServiceTraction on DataService { + Future fetchHadTraction({int offset = 0, int limit = 100}) async { + await fetchTraction( + hadOnly: true, + offset: offset, + limit: limit, + append: offset > 0, + ); + } + + Future fetchTraction({ + bool hadOnly = false, + int offset = 0, + int limit = 50, + String? locoClass, + String? locoNumber, + bool mileageFirst = true, + bool append = false, + Map? filters, + }) async { + _isTractionLoading = true; + + try { + final params = StringBuffer('?limit=$limit&offset=$offset'); + if (hadOnly) params.write('&had_only=true'); + if (!mileageFirst) params.write('&mileage_first=false'); + + final payload = {}; + if (locoClass != null && locoClass.isNotEmpty) { + payload['class'] = locoClass; + } + if (locoNumber != null && locoNumber.isNotEmpty) { + payload['number'] = locoNumber; + } + if (filters != null) { + filters.forEach((key, value) { + if (value == null) return; + if (value is String && value.trim().isEmpty) return; + payload[key] = value; + }); + } + + final json = await api.post( + '/locos/search/v2${params.toString()}', + payload.isEmpty ? null : payload, + ); + + if (json is List) { + final newItems = json.map((e) => LocoSummary.fromJson(e)).toList(); + _traction = append ? [..._traction, ...newItems] : newItems; + _tractionHasMore = newItems.length >= limit - 1; + } else { + throw Exception('Unexpected traction response: $json'); + } + } catch (e) { + debugPrint('Failed to fetch traction: $e'); + if (!append) { + _traction = []; + } + _tractionHasMore = false; + } finally { + _isTractionLoading = false; + _notifyAsync(); + } + } + + Future> fetchLocoTimeline(int locoId) async { + _isLocoTimelineLoading[locoId] = true; + _notifyAsync(); + try { + final json = await api.get('/loco/get-timeline/$locoId'); + final timeline = LocoAttrVersion.fromGroupedJson(json); + _locoTimelines[locoId] = timeline; + return timeline; + } catch (e) { + debugPrint('Failed to fetch loco timeline for $locoId: $e'); + _locoTimelines[locoId] = []; + return []; + } finally { + _isLocoTimelineLoading[locoId] = false; + _notifyAsync(); + } + } + + Future createLoco(Map 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> fetchClassList() async { + if (_locoClasses.isNotEmpty) return _locoClasses; + try { + final json = await api.get('/loco/classlist'); + if (json is List) { + _locoClasses = json.map((e) => e.toString()).toList(); + _notifyAsync(); + } + } catch (e) { + debugPrint('Failed to fetch class list: $e'); + } + return _locoClasses; + } +} + diff --git a/lib/services/data_service/data_service_trips.dart b/lib/services/data_service/data_service_trips.dart new file mode 100644 index 0000000..676c157 --- /dev/null +++ b/lib/services/data_service/data_service_trips.dart @@ -0,0 +1,87 @@ +part of 'data_service.dart'; + +extension DataServiceTrips on DataService { + Future fetchTripDetails() async { + _isTripDetailsLoading = true; + try { + final json = await api.get('/trips/legs-and-stats'); + if (json is List) { + final tripMap = json.map((e) => TripDetail.fromJson(e)).toList(); + _tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id)); + } else { + _tripDetails = []; + } + } catch (e) { + debugPrint('Failed to fetch trip_map: $e'); + _tripDetails = []; + } finally { + _isTripDetailsLoading = false; + _notifyAsync(); + } + } + + Future> fetchTripLocoStats(int tripId) async { + try { + final json = await api.get('/trips/stats/$tripId'); + return _parseTripLocoStats(json); + } catch (e) { + debugPrint('Failed to fetch trip loco stats: $e'); + return []; + } + } + + List _parseTripLocoStats(dynamic json) { + List? list; + if (json is List) { + list = json.expand((e) => e is List ? e : [e]).toList(); + } else if (json is Map) { + for (final key in ['locos', 'stats', 'data', 'trip_locos']) { + final candidate = json[key]; + if (candidate is List) { + list = candidate.expand((e) => e is List ? e : [e]).toList(); + break; + } + } + } + if (list == null) return []; + return list + .whereType>() + .map((e) => TripLocoStat.fromJson(e)) + .toList(); + } + + Future fetchTrips() async { + try { + final json = await api.get('/trips/mileage'); + Iterable? raw; + if (json is List) { + raw = json; + } else if (json is Map) { + for (final key in ['trips', 'trip_data', 'data']) { + final value = json[key]; + if (value is List) { + raw = value; + break; + } + } + } + if (raw != null) { + final tripMap = raw + .whereType>() + .map((e) => TripSummary.fromJson(e)) + .toList(); + + _tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId)); + } else { + debugPrint('Unexpected trip list response: $json'); + _tripList = []; + } + } catch (e) { + debugPrint('Failed to fetch trip list: $e'); + _tripList = []; + } finally { + _notifyAsync(); + } + } +} + diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart new file mode 100644 index 0000000..34e3d1c --- /dev/null +++ b/lib/ui/app_shell.dart @@ -0,0 +1,273 @@ +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/components/login/login.dart'; +import 'package:mileograph_flutter/components/pages/calculator.dart'; +import 'package:mileograph_flutter/components/pages/calculator_details.dart'; +import 'package:mileograph_flutter/components/pages/dashboard.dart'; +import 'package:mileograph_flutter/components/pages/legs.dart'; +import 'package:mileograph_flutter/components/pages/loco_legs.dart'; +import 'package:mileograph_flutter/components/pages/loco_timeline.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:mileograph_flutter/services/authservice.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/navigation_guard.dart'; +import 'package:provider/provider.dart'; + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final GoRouter _router; + bool _routerInitialized = false; + + final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red); + final ColorScheme defaultDark = ColorScheme.fromSeed( + seedColor: Colors.red, + brightness: Brightness.dark, + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_routerInitialized) return; + _routerInitialized = true; + final auth = context.read(); + _router = GoRouter( + refreshListenable: auth, + redirect: (context, state) { + final loggedIn = auth.isLoggedIn; + final loggingIn = state.uri.toString() == '/login'; + + if (!loggedIn && !loggingIn) return '/login'; + if (loggedIn && loggingIn) return '/'; + return null; + }, + routes: [ + ShellRoute( + builder: (context, state, child) => MyHomePage(child: child), + routes: [ + GoRoute(path: '/', builder: (context, state) => const Dashboard()), + GoRoute( + path: '/calculator', + builder: (context, state) => CalculatorPage(), + ), + GoRoute( + path: '/calculator/details', + builder: (context, state) => + CalculatorDetailsPage(result: state.extra), + ), + GoRoute(path: '/legs', builder: (context, state) => LegsPage()), + GoRoute( + path: '/traction', + builder: (context, state) => TractionPage(), + ), + GoRoute( + path: '/traction/:id/timeline', + builder: (_, state) { + final idParam = state.pathParameters['id']; + final locoId = int.tryParse(idParam ?? '') ?? 0; + final extra = state.extra; + String label = state.uri.queryParameters['label'] ?? ''; + if (extra is Map && extra['label'] is String) { + label = extra['label'] as String; + } else if (extra is String && extra.isNotEmpty) { + label = extra; + } + if (label.trim().isEmpty) label = 'Loco $locoId'; + return LocoTimelinePage(locoId: locoId, locoLabel: label); + }, + ), + GoRoute( + path: '/traction/:id/legs', + builder: (_, state) { + final idParam = state.pathParameters['id']; + final locoId = int.tryParse(idParam ?? '') ?? 0; + final extra = state.extra; + String label = state.uri.queryParameters['label'] ?? ''; + if (extra is Map && extra['label'] is String) { + label = extra['label'] as String; + } else if (extra is String && extra.isNotEmpty) { + label = extra; + } + if (label.trim().isEmpty) label = 'Loco $locoId'; + return LocoLegsPage(locoId: locoId, locoLabel: label); + }, + ), + GoRoute( + path: '/traction/new', + builder: (context, state) => const NewTractionPage(), + ), + GoRoute(path: '/trips', builder: (context, state) => TripsPage()), + GoRoute(path: '/add', builder: (context, state) => NewEntryPage()), + GoRoute( + path: '/legs/edit/:id', + builder: (_, state) { + final idParam = state.pathParameters['id']; + final legId = idParam == null ? null : int.tryParse(idParam); + return NewEntryPage(editLegId: legId); + }, + ), + ], + ), + GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), + ], + ); + } + + @override + Widget build(BuildContext context) { + return DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { + return MaterialApp.router( + title: 'Mileograph', + routerConfig: _router, + theme: ThemeData( + useMaterial3: true, + colorScheme: lightDynamic ?? defaultLight, + ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: darkDynamic ?? defaultDark, + ), + themeMode: ThemeMode.system, + ); + }, + ); + } +} + +class MyHomePage extends StatefulWidget { + final Widget child; + const MyHomePage({super.key, required this.child}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final List contentPages = [ + "/", + "/calculator", + "/legs", + "/traction", + "/trips", + "/add", + ]; + + int _getIndexFromLocation(String location) { + int newIndex = contentPages.indexWhere((path) { + if (location == path) return true; + if (path == '/') return location == '/'; + return location.startsWith('$path/'); + }); + if (newIndex < 0) { + return 0; + } + return newIndex; + } + + Future _onItemTapped(int index, int currentIndex) async { + if (index < 0 || index >= contentPages.length || index == currentIndex) { + return; + } + await NavigationGuard.attemptNavigation(() async { + if (!mounted) return; + context.go(contentPages[index]); + }); + } + + bool _fetched = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_fetched) return; + _fetched = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + Future(() async { + if (!mounted) return; + final data = context.read(); + final auth = context.read(); + 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(); + } + if (data.onThisDay.isEmpty) { + data.fetchOnThisDay(); + } + if (data.tripDetails.isEmpty) { + data.fetchTripDetails(); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + final location = GoRouterState.of(context).uri.toString(); + final pageIndex = _getIndexFromLocation(location); + final homepageReady = context.select( + (data) => data.homepageStats != null || !data.isHomepageLoading, + ); + final auth = context.read(); + + final currentPage = homepageReady + ? widget.child + : const Center(child: CircularProgressIndicator()); + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text.rich( + TextSpan( + children: const [ + TextSpan(text: "Mile"), + TextSpan(text: "O", style: TextStyle(color: Colors.red)), + TextSpan(text: "graph"), + ], + style: const TextStyle( + decoration: TextDecoration.none, + color: Colors.white, + fontFamily: "Tomatoes", + ), + ), + ), + actions: [ + const IconButton(onPressed: null, icon: Icon(Icons.account_circle)), + IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: pageIndex, + onDestinationSelected: (int index) => _onItemTapped(index, pageIndex), + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: "Home"), + NavigationDestination(icon: Icon(Icons.route), label: "Calculator"), + NavigationDestination(icon: Icon(Icons.list), label: "Entries"), + NavigationDestination(icon: Icon(Icons.train), label: "Traction"), + NavigationDestination(icon: Icon(Icons.book), label: "Trips"), + NavigationDestination(icon: Icon(Icons.add), label: "Add"), + ], + ), + body: currentPage, + ); + } +} + diff --git a/test/widget_test.dart b/test/widget_test.dart index 0b175c3..c71b2bc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,18 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:mileograph_flutter/main.dart'; +import 'package:mileograph_flutter/app.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); + TestWidgetsFlutterBinding.ensureInitialized(); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + testWidgets('Shows login UI when logged out', (WidgetTester tester) async { + await tester.pumpWidget(const App()); + await tester.pump(const Duration(milliseconds: 100)); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Login'), findsWidgets); }); }