264 lines
7.7 KiB
Dart
264 lines
7.7 KiB
Dart
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<LocoTimelinePage> createState() => _LocoTimelinePageState();
|
|
}
|
|
|
|
class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|
final List<_EventDraft> _draftEvents = [];
|
|
bool _isSaving = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
|
}
|
|
|
|
@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());
|
|
});
|
|
}
|
|
|
|
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] = 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<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,
|
|
),
|
|
const SizedBox(height: 16),
|
|
_EventEditor(
|
|
eventFields: data.eventFields,
|
|
drafts: _draftEvents,
|
|
onAddEvent: _addDraftEvent,
|
|
onChange: () => setState(() {}),
|
|
onSave: _saveEvents,
|
|
onRemoveDraft: _removeDraftAt,
|
|
isSaving: _isSaving,
|
|
canSave: _canSaveDrafts(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|