Files
mileograph_flutter/lib/components/pages/loco_timeline.dart
Pete Gregory 44d79e7c28
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m3s
Release / android-build (push) Successful in 19m21s
Release / release-master (push) Successful in 40s
Release / release-dev (push) Successful in 42s
Improve entries page and latest changes panel, units on events and timeline
2025-12-23 17:41:21 +00:00

435 lines
13 KiB
Dart

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<LocoTimelinePage> createState() => _LocoTimelinePageState();
}
class _LocoTimelinePageState extends State<LocoTimelinePage> {
final List<_EventDraft> _draftEvents = [];
bool _isSaving = false;
bool _isDeleting = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _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<void> _load() {
final data = context.read<DataService>();
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;
if (from == null) return null;
return DateFormat('yyyy-MM-dd').format(from);
}
EventField? _fieldForAttr(String attrCode, List<EventField> 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<EventField> 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<void> _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<DataService>();
final messenger = ScaffoldMessenger.of(context);
final dateStr = _eventDateForEntry(entry);
final ok = await showDialog<bool>(
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<void> _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<DataService>();
setState(() {
_isSaving = true;
});
try {
final invalid = <String>[];
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 = <String, dynamic>{};
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 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<DataService>();
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(),
),
],
);
},
),
),
);
}
}