diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index b590f33..dc38af9 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/dataService.dart'; import 'package:provider/provider.dart'; @@ -279,6 +280,11 @@ class _LegsPageState extends State { trailing: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + IconButton( + tooltip: 'Edit entry', + icon: const Icon(Icons.edit), + onPressed: () => context.push('/legs/edit/${leg.id}'), + ), Text( '${leg.mileage.toStringAsFixed(1)} mi', style: diff --git a/lib/components/pages/new_entry.dart b/lib/components/pages/new_entry.dart index 913716c..3d642a3 100644 --- a/lib/components/pages/new_entry.dart +++ b/lib/components/pages/new_entry.dart @@ -1,6 +1,7 @@ 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'; @@ -9,11 +10,14 @@ import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/dataService.dart'; +import 'package: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}); + const NewEntryPage({super.key, this.editLegId}); + + final int? editLegId; @override State createState() => _NewEntryPageState(); @@ -21,6 +25,7 @@ class NewEntryPage extends StatefulWidget { class _NewEntryPageState extends State { static const _draftPrefsKey = 'new_entry_draft'; + static const _draftListPrefsKey = 'new_entry_drafts_list'; final _formKey = GlobalKey(); DateTime _selectedDate = DateTime.now(); @@ -37,41 +42,39 @@ class _NewEntryPageState extends State { 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(); - for (final controller in [ - _startController, - _endController, - _headcodeController, - _notesController, - _mileageController, - _networkController, - ]) { - controller.addListener(_saveDraft); - } + 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(); - _loadDraft(); + if (_draftPersistenceEnabled) { + _loadDraft(); + } + if (_isEditing && widget.editLegId != null) { + _loadLegForEdit(widget.editLegId!); + } }); } @override void dispose() { - for (final controller in [ - _startController, - _endController, - _headcodeController, - _notesController, - _mileageController, - _networkController, - ]) { - controller.removeListener(_saveDraft); - } + NavigationGuard.unregister(_handleExitIntent); _startController.dispose(); _endController.dispose(); _headcodeController.dispose(); @@ -251,6 +254,152 @@ class _NewEntryPageState extends State { } } + 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, @@ -283,6 +432,97 @@ class _NewEntryPageState extends State { 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 = _normalizeDraftSnapshot( + _buildDraftSnapshot( + id: _activeDraftId ?? 'temp', + includeTimestamp: false, + ), + ); + final baseline = _normalizeDraftSnapshot(_loadedDraftSnapshot!); + return !_snapshotEquality.equals(baseline, current); + } + + Map _normalizeDraftSnapshot(Map snapshot) { + final normalized = Map.from(snapshot); + normalized.remove('saved_at'); + return normalized; + } + + 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 = []; @@ -330,8 +570,6 @@ class _NewEntryPageState extends State { Future _submit() async { if (!_formKey.currentState!.validate()) return; if (!await _validateRequiredFields()) return; - setState(() => _submitting = true); - final api = context.read(); final routeStations = _routeResult?.calculatedRoute ?? []; final startVal = _useManualMileage ? _startController.text.trim() @@ -343,42 +581,71 @@ class _NewEntryPageState extends State { ? 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; + } + setState(() => _submitting = true); + final api = context.read(); + final isEditingExisting = _isEditing && widget.editLegId != null; - if (_useManualMileage) { - final body = { - "leg_trip": _selectedTripId, - "leg_start": startVal, - "leg_end": endVal, - "leg_begin_time": _legDateTime.toIso8601String(), - "leg_network": _networkController.text.trim(), - "leg_distance": mileageVal, - "isKilometers": false, - "leg_notes": _notesController.text.trim(), - "leg_headcode": _headcodeController.text.trim(), - "locos": tractionPayload, - }; - await api.post('/add/manual', body); - } else { - final body = { - "leg_trip": _selectedTripId, - "leg_begin_time": _legDateTime.toIso8601String(), - "leg_route": routeStations, - "leg_notes": _notesController.text.trim(), - "leg_headcode": _headcodeController.text.trim(), - "leg_network": _networkController.text.trim(), - "locos": tractionPayload, - }; - await api.post('/add', body); - } - if (mounted) { - context.read().refreshLegs(); - } 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; + context.read().refreshLegs(); if (!mounted) return; ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('Entry submitted'))); - _resetFormState(clearDraft: true); + ).showSnackBar( + SnackBar( + content: Text(isEditingExisting ? 'Entry updated' : 'Entry submitted'), + ), + ); + _lastSubmittedSnapshot = snapshot; + _activeDraftId = null; } catch (e) { if (!mounted) return; ScaffoldMessenger.of( @@ -389,6 +656,59 @@ class _NewEntryPageState extends State { } } + 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(); @@ -408,16 +728,15 @@ class _NewEntryPageState extends State { ..add(_TractionItem.marker()); _selectedTripId = null; _submitting = false; + _activeDraftId = null; }); if (clearDraft) { await _clearDraft(); - } else { - _saveDraft(); } } Future _saveDraft() async { - if (_restoringDraft) return; + if (_restoringDraft || !_draftPersistenceEnabled) return; final prefs = await SharedPreferences.getInstance(); final draft = { "date": _selectedDate.toIso8601String(), @@ -444,59 +763,220 @@ class _NewEntryPageState extends State { } Future _clearDraft() async { + if (!_draftPersistenceEnabled) return; final prefs = await SharedPreferences.getInstance(); await prefs.remove(_draftPrefsKey); } - Future _loadDraft() async { + Future> _loadSavedDrafts() async { final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_draftPrefsKey); - if (raw == null) return; + final raw = prefs.getString(_draftListPrefsKey); + if (raw == null || raw.isEmpty) return []; try { - final data = jsonDecode(raw); - if (data is! Map) return; - _restoringDraft = true; - setState(() { - if (data['date'] is String) { - _selectedDate = DateTime.tryParse(data['date']) ?? _selectedDate; - } - if (data['time'] is Map) { - final time = data['time'] as Map; - final hour = time['hour'] as int?; - final minute = time['minute'] as int?; - if (hour != null && minute != null) { - _selectedTime = TimeOfDay(hour: hour, minute: minute); - } - } - _useManualMileage = data['useManualMileage'] ?? _useManualMileage; - _selectedTripId = data['selectedTripId']; - if (data['routeResult'] is Map) { - _routeResult = RouteResult.fromJson( - Map.from(data['routeResult']), - ); - _mileageController.text = _routeResult!.distance.toStringAsFixed(2); - } - if (data['tractionItems'] is List) { - _restoreTractionItems( - List>.from(data['tractionItems'].cast()), - ); - } - }); - _startController.text = data['start'] ?? ''; - _endController.text = data['end'] ?? ''; - _headcodeController.text = - data['headcode'] is String ? data['headcode'].toUpperCase() : ''; - _notesController.text = data['notes'] ?? ''; - _mileageController.text = data['mileage'] ?? ''; - _networkController.text = - data['network'] is String ? data['network'].toUpperCase() : ''; + 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 (_) { - // Ignore corrupt draft data - } finally { - _restoringDraft = false; + 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 = + _normalizeDraftSnapshot(_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 = _normalizeDraftSnapshot( + _buildDraftSnapshot( + id: baselineId, + includeTimestamp: false, + ), + ); + _restoringDraft = false; + } + + Future _loadDraft() async { + // legacy single draft no-op + } + List> _serializeTractionItems() { return _tractionItems .map( @@ -555,10 +1035,14 @@ class _NewEntryPageState extends State { @override Widget build(BuildContext context) { - final isMobile = MediaQuery.of(context).size.width < 700; - return Scaffold( - appBar: null, - body: Form( + 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) { @@ -569,6 +1053,32 @@ class _NewEntryPageState extends State { 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: [ @@ -721,22 +1231,6 @@ class _NewEntryPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Align( - alignment: Alignment.centerRight, - child: 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'), - ), - ), - const SizedBox(height: 8), detailPanel, const SizedBox(height: 16), twoCol @@ -765,13 +1259,37 @@ class _NewEntryPageState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), - label: Text(_submitting ? 'Submitting...' : 'Submit entry'), + label: Text( + _submitting + ? (_isEditing ? 'Saving...' : 'Submitting...') + : (_isEditing ? 'Save changes' : 'Submit entry'), + ), ), ], ), ); }, ), + ); + } + + return WillPopScope( + onWillPop: () => _handleExitIntent(), + child: Scaffold( + appBar: _isEditing + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + if (!await _handleExitIntent()) return; + if (!mounted) return; + Navigator.of(context).maybePop(); + }, + ), + title: const Text('Edit entry'), + ) + : null, + body: body, ), ); } @@ -919,6 +1437,191 @@ class _UpperCaseTextFormatter extends TextInputFormatter { } } +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 List<_StoredDraft> _drafts = widget.drafts; + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemCount: _drafts.length, + separatorBuilder: (_, __) => 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: (_) => AlertDialog( + title: const Text('Delete draft?'), + content: const Text('This draft will be removed permanently.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).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; diff --git a/lib/main.dart b/lib/main.dart index 1124a09..c47eea4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:mileograph_flutter/components/pages/legs.dart'; import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'components/login/login.dart'; import 'components/pages/dashboard.dart'; @@ -100,6 +101,14 @@ class MyApp extends StatelessWidget { ), GoRoute(path: '/trips', builder: (_, __) => TripsPage()), GoRoute(path: '/add', builder: (_, __) => 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: (_, __) => const LoginScreen()), @@ -180,12 +189,14 @@ class _MyHomePageState extends State { return newIndex; } - void _onItemTapped(int index, int currentIndex) { + Future _onItemTapped(int index, int currentIndex) async { if (index < 0 || index >= contentPages.length || index == currentIndex) { return; } - context.push(contentPages[index]); - _getIndexFromLocation(contentPages[index]); + await NavigationGuard.attemptNavigation(() async { + if (!mounted) return; + context.go(contentPages[index]); + }); } bool loggedIn = false; diff --git a/lib/services/navigation_guard.dart b/lib/services/navigation_guard.dart new file mode 100644 index 0000000..6f9c7de --- /dev/null +++ b/lib/services/navigation_guard.dart @@ -0,0 +1,40 @@ +typedef NavigationGuardCallback = Future Function(); + +class NavigationGuard { + static NavigationGuardCallback? _callback; + + static void register(NavigationGuardCallback callback) { + _callback = callback; + } + + static void unregister(NavigationGuardCallback callback) { + if (_callback == callback) { + _callback = null; + } + } + + static Future attemptNavigation( + Future Function() performNavigation, + ) async { + if (_promptActive) return; + final cb = _callback; + if (cb == null) { + await performNavigation(); + return; + } + _promptActive = true; + bool allow = false; + try { + allow = await cb(); + } catch (_) { + allow = false; + } finally { + _promptActive = false; + } + if (allow) { + await performNavigation(); + } + } + + static bool _promptActive = false; +} diff --git a/pubspec.yaml b/pubspec.yaml index 8fc9036..80ce1f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.1.3+1 +version: 0.1.4+1 environment: sdk: ^3.8.1