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/data_service.dart'; import 'package:provider/provider.dart'; part 'loco_timeline/timeline_grid.dart'; part 'loco_timeline/event_editor.dart'; class LocoTimelinePage extends StatefulWidget { const LocoTimelinePage({ super.key, required this.locoId, required this.locoLabel, }); final int locoId; final String locoLabel; @override State createState() => _LocoTimelinePageState(); } class _LocoTimelinePageState extends State { final List<_EventDraft> _draftEvents = []; bool _isSaving = false; bool _isDeleting = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } @override void dispose() { _disposeDrafts(_draftEvents); super.dispose(); } Future _load() { final data = context.read(); data.fetchEventFields(); return data.fetchLocoTimeline(widget.locoId); } 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 ?? entry.txnFrom; 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), ); setState(() { _draftEvents.add(draft); }); } Future _deleteEntry(LocoAttrVersion entry) async { if (_isDeleting) return; final blockId = entry.versionId; if (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 { await data.deleteTimelineBlock( blockId: blockId, ); await _load(); if (mounted) { messenger.showSnackBar( const SnackBar(content: Text('Timeline block deleted')), ); } } catch (e) { if (mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to delete timeline block: $e')), ); } } finally { if (mounted) { setState(() { _isDeleting = false; }); } } } 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 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] = val; } if (invalid.isNotEmpty) continue; if (values.isEmpty) { invalid.add('Add at least one value'); continue; } 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; }); } } } 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); } @override Widget build(BuildContext context) { final data = context.watch(); final timeline = data.timelineForLoco(widget.locoId); final isLoading = data.isLocoTimelineLoading(widget.locoId); return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).maybePop(), ), title: Text('Timeline · ${widget.locoLabel}'), ), body: RefreshIndicator( onRefresh: _load, child: LayoutBuilder( builder: (context, constraints) { if (isLoading && timeline.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (timeline.isEmpty) { 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: [ _TimelineGrid( entries: timeline, onEditEntry: (entry) => _prefillDraftFromEntry( entry, data.eventFields, ), onDeleteEntry: _deleteEntry, ), const SizedBox(height: 16), _EventEditor( eventFields: data.eventFields, drafts: _draftEvents, onAddEvent: _addDraftEvent, onChange: () => setState(() {}), onSave: _saveEvents, onRemoveDraft: _removeDraftAt, isSaving: _isSaving, canSave: _canSaveDrafts(), ), ], ); }, ), ), ); } }