import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'loco_timeline/timeline_grid.dart'; part 'loco_timeline/event_editor.dart'; enum _TimelineMenuAction { redate } class LocoTimelinePage extends StatefulWidget { const LocoTimelinePage({ super.key, required this.locoId, required this.locoLabel, this.forceShowPending = false, }); final int locoId; final String locoLabel; final bool forceShowPending; @override State createState() => _LocoTimelinePageState(); } class _LocoTimelinePageState extends State { static const String _prefsKeyShowPending = 'timeline_show_pending'; final List<_EventDraft> _draftEvents = []; bool _isSaving = false; bool _isDeleting = false; final Set _moderatingEventIds = {}; final Set _expandedPendingAttrs = {}; bool _showPending = true; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (widget.forceShowPending) { setState(() { _showPending = true; }); } else { await _restorePendingVisibility(); } if (!mounted) return; await _load(); }); } dynamic _normalizeFieldValue(_FieldEntry field) { final name = field.field.name.toLowerCase(); final val = field.value; if (name == 'max_speed') { final numVal = val is num ? val.toDouble() : double.tryParse('$val'); if (numVal == null) return val; final unit = (field.unit ?? 'kph').toLowerCase(); if (unit == 'mph') { return numVal * 1.60934; } return numVal; } return val; } @override void dispose() { _disposeDrafts(_draftEvents); super.dispose(); } Future _load() { final data = context.read(); final auth = context.read(); data.fetchEventFields(); return data.fetchLocoTimeline( widget.locoId, includeAllPending: auth.isElevated && _showPending, ); } Future _restorePendingVisibility() async { final auth = context.read(); if (!auth.isElevated) return; try { final prefs = await SharedPreferences.getInstance(); final saved = prefs.getBool(_prefsKeyShowPending); if (saved == null) return; if (!mounted) return; setState(() { _showPending = saved; }); } catch (_) { // Ignore preference restore failures. } } Future _persistPendingVisibility(bool value) async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefsKeyShowPending, value); } catch (_) { // Ignore persistence failures. } } void _addDraftEvent() { setState(() { _draftEvents.add(_EventDraft()); }); } String? _eventDateForEntry(LocoAttrVersion entry) { final masked = entry.maskedValidFrom?.trim(); if (masked != null && masked.isNotEmpty) return masked; final from = entry.validFrom; if (from == null) return null; return DateFormat('yyyy-MM-dd').format(from); } EventField? _fieldForAttr(String attrCode, List fields) { final normalized = attrCode.trim().toLowerCase(); for (final field in fields) { if (field.name.trim().toLowerCase() == normalized) return field; } return null; } dynamic _valueForEntry(LocoAttrVersion entry) { if (entry.valueInt != null) return entry.valueInt; if (entry.valueBool != null) return entry.valueBool; if (entry.valueEnum != null && entry.valueEnum!.isNotEmpty) { return entry.valueEnum; } if (entry.valueStr != null && entry.valueStr!.isNotEmpty) { return entry.valueStr; } if (entry.valueDate != null) { return DateFormat('yyyy-MM-dd').format(entry.valueDate!); } if (entry.valueNorm != null && entry.valueNorm.toString().isNotEmpty) { return entry.valueNorm; } final label = entry.valueLabel; return label == '—' ? '' : label; } void _prefillDraftFromEntry(LocoAttrVersion entry, List fields) { final dateStr = _eventDateForEntry(entry); if (dateStr == null || dateStr.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cannot edit: timeline block date unknown.')), ); return; } final field = _fieldForAttr(entry.attrCode, fields); if (field == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Cannot edit: no event field found for ${_formatAttrLabel(entry.attrCode)}.', ), ), ); return; } final draft = _EventDraft(); draft.dateController.text = dateStr; draft.detailsController.text = ''; draft.details = ''; draft.fields.add( _FieldEntry(field: field) ..value = _valueForEntry(entry) ..unit = _guessUnit(field, entry.valueLabel), ); setState(() { _draftEvents.add(draft); }); } String? _guessUnit(EventField field, String valueLabel) { final name = field.name.toLowerCase(); if (name == 'max_speed') { final val = valueLabel.toLowerCase(); if (val.contains('mph')) return 'mph'; return 'kph'; } return _defaultUnitForField(field); } Future _deleteEntry(LocoAttrVersion entry) async { if (_isDeleting) return; final isPending = entry.isPending; final blockId = entry.versionId; final pendingEventId = entry.sourceEventId; if (isPending && pendingEventId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Cannot delete: pending timeline block has no event ID.'), ), ); return; } if (!isPending && blockId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cannot delete: timeline block has no ID.')), ); return; } final data = context.read(); final messenger = ScaffoldMessenger.of(context); final dateStr = _eventDateForEntry(entry); final ok = await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Delete timeline block?'), content: Text( dateStr == null || dateStr.isEmpty ? 'This will delete the selected block for ${_formatAttrLabel(entry.attrCode)}.' : 'This will delete the block for ${_formatAttrLabel(entry.attrCode)} starting at $dateStr.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete'), ), ], ); }, ); if (ok != true) return; if (!mounted) return; setState(() { _isDeleting = true; }); try { if (isPending && pendingEventId != null) { await data.deletePendingEvent(eventId: pendingEventId); } else if (blockId != null) { await data.deleteTimelineBlock( blockId: blockId, ); } await _load(); if (mounted) { messenger.showSnackBar( SnackBar( content: Text( isPending ? 'Pending timeline block deleted' : 'Timeline block deleted', ), ), ); } } catch (e) { if (mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to delete timeline block: $e')), ); } } finally { if (mounted) { setState(() { _isDeleting = false; }); } } } Future _moderatePendingEntry( LocoAttrVersion entry, _PendingModerationAction action, ) async { final eventId = entry.sourceEventId; if (eventId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Cannot moderate: pending timeline block has no event ID.'), ), ); return; } if (_moderatingEventIds.contains(eventId)) return; final data = context.read(); final approve = action == _PendingModerationAction.approve; final messenger = ScaffoldMessenger.of(context); final verb = approve ? 'approve' : 'reject'; final ok = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('${approve ? 'Approve' : 'Reject'} pending event?'), content: Text( 'Are you sure you want to $verb this pending timeline block?', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(approve ? 'Approve' : 'Reject'), ), ], ), ); if (ok != true || !mounted) return; setState(() { _moderatingEventIds.add(eventId); }); try { if (approve) { await data.approvePendingEvent(eventId: eventId); } else { await data.rejectPendingEvent(eventId: eventId); } await _load(); if (mounted) { messenger.showSnackBar( SnackBar( content: Text( 'Pending timeline block ${approve ? 'approved' : 'rejected'}.', ), ), ); } } catch (e) { if (mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to $verb pending timeline block: $e')), ); } } finally { if (mounted) { setState(() { _moderatingEventIds.remove(eventId); }); } } } void _removeDraftAt(int index) { if (index < 0 || index >= _draftEvents.length) return; final draft = _draftEvents.removeAt(index); _disposeDraft(draft); setState(() {}); } void _disposeDraft(_EventDraft draft) { draft.dateController.dispose(); draft.detailsController.dispose(); } void _disposeDrafts(List<_EventDraft> drafts) { for (final draft in drafts) { _disposeDraft(draft); } } Future _saveEvents() async { if (_isSaving) return; if (!_canSaveDrafts()) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please fix validation issues before saving.')), ); return; } final data = context.read(); setState(() { _isSaving = true; }); try { final existingPending = await data.fetchUserPendingEvents(widget.locoId); final clearedEventIds = {}; final invalid = []; for (final draft in _draftEvents) { final dateStr = draft.dateController.text.trim(); if (!_isValidDateString(dateStr)) { invalid.add('Date is invalid (${dateStr.isEmpty ? 'empty' : dateStr})'); continue; } if (draft.fields.isEmpty) { invalid.add('Add at least one field for each event'); continue; } final values = {}; for (final field in draft.fields) { final val = field.value; final isBlankString = val is String && val.trim().isEmpty; if (val == null || isBlankString) { invalid.add('Field ${field.field.display} is empty'); break; } values[field.field.name] = _normalizeFieldValue(field); } if (invalid.isNotEmpty) continue; if (values.isEmpty) { invalid.add('Add at least one value'); continue; } await _clearDuplicatePending( existingPending, clearedEventIds, values.keys, dateStr, data, ); await data.createLocoEvent( locoId: widget.locoId, eventDate: dateStr, values: values, details: draft.details, ); } if (invalid.isNotEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(invalid.first)), ); } return; } _disposeDrafts(_draftEvents); _draftEvents.clear(); await _load(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Events saved')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Failed to save events: $e')), ); } } finally { if (mounted) { setState(() { _isSaving = false; }); } } } Future _clearDuplicatePending( List existingPending, Set clearedEventIds, Iterable attrs, String dateStr, DataService data, ) async { final trimmedDate = dateStr.trim().toLowerCase(); final attrSet = attrs.map((e) => e.toLowerCase()).toSet(); for (final pending in existingPending) { final attrMatch = attrSet.contains(pending.attrCode.toLowerCase()); if (!attrMatch) continue; final matchesDate = _dateMatchesPending(trimmedDate, pending); if (!matchesDate) continue; final eventId = pending.sourceEventId; if (eventId == null || clearedEventIds.contains(eventId)) continue; await data.deletePendingEvent(eventId: eventId); clearedEventIds.add(eventId); } } bool _dateMatchesPending(String draftDateLower, LocoAttrVersion pending) { final masked = pending.maskedValidFrom?.trim().toLowerCase(); if (masked != null && masked.isNotEmpty && masked == draftDateLower) { return true; } final draftDate = DateTime.tryParse(draftDateLower); final pendingDate = pending.validFrom; if (draftDate != null && pendingDate != null) { return draftDate.year == pendingDate.year && draftDate.month == pendingDate.month && draftDate.day == pendingDate.day; } return false; } bool _isValidDateString(String input) { final trimmed = input.trim(); final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$'); if (!regex.hasMatch(trimmed)) return false; final parts = trimmed.split('-'); final monthPart = parts[1]; final dayPart = parts[2]; final monthUnknown = monthPart.toLowerCase() == 'xx'; final dayUnknown = dayPart.toLowerCase() == 'xx'; if (monthUnknown && !dayUnknown) return false; if (!monthUnknown) { final month = int.tryParse(monthPart); if (month == null || month < 1 || month > 12) return false; } if (!dayUnknown) { final day = int.tryParse(dayPart); if (day == null || day < 1 || day > 31) return false; } return true; } bool _draftIsValid(_EventDraft draft) { final dateStr = draft.dateController.text.trim(); if (!_isValidDateString(dateStr)) return false; if (draft.fields.isEmpty) return false; for (final field in draft.fields) { final val = field.value; if (val == null) return false; if (val is String && val.trim().isEmpty) return false; } return true; } bool _canSaveDrafts() { if (_draftEvents.isEmpty) return false; return _draftEvents.every(_draftIsValid); } Future _handleTimelineMenuAction( _TimelineMenuAction action, List timeline, ) async { switch (action) { case _TimelineMenuAction.redate: await _startMassRedate(timeline); break; } } Future _startMassRedate(List timeline) async { final messenger = ScaffoldMessenger.of(context); final eventDate = await _showRedateDateDialog(); if (!mounted || eventDate == null) return; final earliestByAttr = _earliestEntriesByAttr(timeline); if (earliestByAttr.isEmpty) { messenger.showSnackBar( const SnackBar(content: Text('No timeline values available to redate.')), ); return; } final result = await _showMassRedateProgressDialog( eventDate: eventDate, entries: earliestByAttr, ); if (!mounted || result == null) return; await _load(); if (!mounted) return; final successCount = result.total - result.failures; final message = result.failures == 0 ? 'Redated $successCount attributes.' : 'Redated $successCount of ${result.total} attributes (${result.failures} failed).'; messenger.showSnackBar(SnackBar(content: Text(message))); } List _earliestEntriesByAttr(List timeline) { final byAttr = {}; for (final entry in timeline) { if (entry.isPending) continue; final key = entry.attrCode.trim().toLowerCase(); if (key.isEmpty) continue; final current = byAttr[key]; if (current == null || LocoAttrVersion.compareByStart(entry, current) < 0) { byAttr[key] = entry; } } final entries = byAttr.values.toList() ..sort((a, b) => a.attrCode.compareTo(b.attrCode)); return entries; } Future _showRedateDateDialog() async { final controller = TextEditingController( text: DateFormat('yyyy-MM-dd').format(DateTime.now()), ); String? validationError; final result = await showDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (dialogContext, setDialogState) { Future pickDate() async { final initial = DateTime.tryParse(controller.text.trim()) ?? DateTime.now(); final picked = await showDatePicker( context: dialogContext, initialDate: initial, firstDate: DateTime(1800), lastDate: DateTime(2500), ); if (picked == null) return; final nextValue = DateFormat('yyyy-MM-dd').format(picked); setDialogState(() { controller.text = nextValue; validationError = null; }); } return AlertDialog( title: const Text('Redate timeline attributes'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Enter the target date. The earliest value of each attribute will be submitted at this date.', ), const SizedBox(height: 12), TextField( controller: controller, decoration: InputDecoration( labelText: 'Date', hintText: 'YYYY-MM-DD', errorText: validationError, suffixIcon: IconButton( tooltip: 'Pick date', icon: const Icon(Icons.calendar_month), onPressed: pickDate, ), ), onSubmitted: (_) { final value = controller.text.trim(); if (!_isValidDateString(value)) { setDialogState(() { validationError = 'Use format YYYY-MM-DD.'; }); return; } Navigator.of(dialogContext).pop(value); }, ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final value = controller.text.trim(); if (!_isValidDateString(value)) { setDialogState(() { validationError = 'Use format YYYY-MM-DD.'; }); return; } Navigator.of(dialogContext).pop(value); }, child: const Text('Run'), ), ], ); }, ); }, ); controller.dispose(); return result; } Future<_MassRedateResult?> _showMassRedateProgressDialog({ required String eventDate, required List entries, }) async { final data = context.read(); var started = false; var completed = 0; var failures = 0; var currentAttrLabel = ''; return showDialog<_MassRedateResult>( context: context, barrierDismissible: false, builder: (dialogContext) { return StatefulBuilder( builder: (dialogContext, setDialogState) { Future run() async { for (final entry in entries) { if (!mounted) return; setDialogState(() { currentAttrLabel = _formatAttrLabel(entry.attrCode); }); final value = _valueForEntry(entry); final isBlank = value is String && value.trim().isEmpty; if (value == null || isBlank) { failures += 1; setDialogState(() { completed += 1; }); continue; } try { await data.createLocoEvent( locoId: widget.locoId, eventDate: eventDate, values: {entry.attrCode: value}, details: '', ); } catch (_) { failures += 1; } if (!mounted || !dialogContext.mounted) return; setDialogState(() { completed += 1; }); } if (!mounted || !dialogContext.mounted) return; Navigator.of(dialogContext).pop( _MassRedateResult(total: entries.length, failures: failures), ); } if (!started) { started = true; WidgetsBinding.instance.addPostFrameCallback((_) { run(); }); } final total = entries.length; final progress = total == 0 ? 0.0 : completed / total; return AlertDialog( title: const Text('Redating attributes'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ LinearProgressIndicator(value: progress), const SizedBox(height: 12), Text('$completed / $total complete'), const SizedBox(height: 8), Text( currentAttrLabel.isEmpty ? 'Preparing requests...' : 'Current: $currentAttrLabel', ), ], ), ); }, ); }, ); } @override Widget build(BuildContext context) { final data = context.watch(); final timeline = data.timelineForLoco(widget.locoId); final isElevated = context.select((auth) => auth.isElevated); final isLoading = data.isLocoTimelineLoading(widget.locoId); final visibleTimeline = (!isElevated || _showPending) ? timeline : timeline.where((entry) => !entry.isPending).toList(); return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).maybePop(), ), title: Text('Timeline · ${widget.locoLabel}'), actions: [ PopupMenuButton<_TimelineMenuAction>( tooltip: 'Timeline actions', icon: const Icon(Icons.more_vert), onSelected: (action) => _handleTimelineMenuAction(action, visibleTimeline), itemBuilder: (context) => const [ PopupMenuItem<_TimelineMenuAction>( value: _TimelineMenuAction.redate, child: Text('Redate'), ), ], ), ], ), body: RefreshIndicator( onRefresh: _load, child: LayoutBuilder( builder: (context, constraints) { if (isLoading && timeline.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (visibleTimeline.isEmpty) { if (timeline.isNotEmpty && isElevated && !_showPending) { return Padding( padding: const EdgeInsets.all(16.0), child: Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Pending entries hidden', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), const Text( 'Enable "Show pending entries" to view pending timeline blocks.', ), ], ), ), ), ); } return Padding( padding: const EdgeInsets.all(16.0), child: Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'No timeline data yet', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), const Text( 'This locomotive does not have any attribute history to show right now.', ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _load, icon: const Icon(Icons.refresh), label: const Text('Try again'), ), ], ), ), ), ); } return ListView( padding: const EdgeInsets.all(16), children: [ if (isElevated) Row( children: [ Expanded( child: SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: const Text('Show pending entries'), value: _showPending, onChanged: (value) async { setState(() { _showPending = value; }); await _persistPendingVisibility(value); if (mounted) { await _load(); } }, ), ), IconButton( tooltip: 'Refresh timeline', onPressed: _load, icon: const Icon(Icons.refresh), ), ], ), _TimelineGrid( entries: visibleTimeline, onEditEntry: (entry) => _prefillDraftFromEntry( entry, data.eventFields, ), onDeleteEntry: _deleteEntry, onModeratePending: _moderatePendingEntry, pendingActionEventIds: _moderatingEventIds, expandedPendingAttrs: _expandedPendingAttrs, onTogglePendingAttr: (attrCode) { setState(() { if (!_expandedPendingAttrs.add(attrCode)) { _expandedPendingAttrs.remove(attrCode); } }); }, ), const SizedBox(height: 16), _EventEditor( eventFields: data.eventFields, drafts: _draftEvents, onAddEvent: _addDraftEvent, onChange: () => setState(() {}), onSave: _saveEvents, onRemoveDraft: _removeDraftAt, isSaving: _isSaving, canSave: _canSaveDrafts(), ), ], ); }, ), ), ); } } class _MassRedateResult { const _MassRedateResult({ required this.total, required this.failures, }); final int total; final int failures; }