import 'dart:math' as math; import 'package:flutter/material.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; @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()); }); } 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, ), const SizedBox(height: 16), _EventEditor( eventFields: data.eventFields, drafts: _draftEvents, onAddEvent: _addDraftEvent, onChange: () => setState(() {}), onSave: _saveEvents, onRemoveDraft: _removeDraftAt, isSaving: _isSaving, canSave: _canSaveDrafts(), ), ], ); }, ), ), ); } }