add timeline edit/delete
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m36s
Release / android-build (push) Successful in 17m20s
Release / release-dev (push) Successful in 37s
Release / release-master (push) Successful in 35s

This commit is contained in:
2025-12-17 12:17:41 +00:00
parent 80be797322
commit fa9773bcd1
9 changed files with 454 additions and 38 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
@@ -119,8 +120,6 @@ class _RouteCalculatorState extends State<RouteCalculator> {
RouteResult? get result => _routeResult;
String? _errorMessage;
bool _showDetails = false;
bool _fetched = false;
@override
@@ -188,13 +187,6 @@ class _RouteCalculatorState extends State<RouteCalculator> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
if (_showDetails && _routeResult != null) {
return RouteDetailsView(
route: _routeResult!.calculatedRoute,
costs: _routeResult!.costs,
onBack: () => setState(() => _showDetails = false),
);
}
return Column(
children: [
Expanded(
@@ -263,7 +255,11 @@ class _RouteCalculatorState extends State<RouteCalculator> {
else if (_routeResult != null) ...[
RouteSummaryWidget(
distance: _routeResult!.distance,
onDetailsPressed: () => setState(() => _showDetails = true),
onDetailsPressed: () {
final result = _routeResult;
if (result == null) return;
context.push('/calculator/details', extra: result);
},
),
if (widget.onApplyRoute != null)
Padding(

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/calculator/route_summary_widget.dart';
import 'package:mileograph_flutter/objects/objects.dart';
class CalculatorDetailsPage extends StatelessWidget {
const CalculatorDetailsPage({
super.key,
required this.result,
});
final Object? result;
@override
Widget build(BuildContext context) {
final parsed = result is RouteResult ? result as RouteResult : null;
if (parsed == null) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
),
const SizedBox(height: 12),
const Text(
'No route details available.',
),
],
),
);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: RouteDetailsView(
route: parsed.calculatedRoute,
costs: parsed.costs,
onBack: () => context.pop(),
),
);
}
}

View File

@@ -1,6 +1,8 @@
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';
@@ -26,6 +28,7 @@ class LocoTimelinePage extends StatefulWidget {
class _LocoTimelinePageState extends State<LocoTimelinePage> {
final List<_EventDraft> _draftEvents = [];
bool _isSaving = false;
bool _isDeleting = false;
@override
void initState() {
@@ -51,6 +54,143 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
});
}
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<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),
);
setState(() {
_draftEvents.add(draft);
});
}
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);
@@ -241,6 +381,11 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
children: [
_TimelineGrid(
entries: timeline,
onEditEntry: (entry) => _prefillDraftFromEntry(
entry,
data.eventFields,
),
onDeleteEntry: _deleteEntry,
),
const SizedBox(height: 16),
_EventEditor(

View File

@@ -5,9 +5,13 @@ final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
class _TimelineGrid extends StatefulWidget {
const _TimelineGrid({
required this.entries,
this.onEditEntry,
this.onDeleteEntry,
});
final List<LocoAttrVersion> entries;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
@override
State<_TimelineGrid> createState() => _TimelineGridState();
@@ -185,6 +189,8 @@ class _TimelineGridState extends State<_TimelineGrid> {
model: model,
scrollOffset: _scrollOffset,
viewportWidth: axisWidth,
onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry,
),
);
},
@@ -268,6 +274,8 @@ class _AttrRow extends StatelessWidget {
required this.model,
required this.scrollOffset,
required this.viewportWidth,
this.onEditEntry,
this.onDeleteEntry,
});
final double rowHeight;
@@ -275,6 +283,8 @@ class _AttrRow extends StatelessWidget {
final _TimelineModel model;
final double scrollOffset;
final double viewportWidth;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
@override
Widget build(BuildContext context) {
@@ -296,7 +306,11 @@ class _AttrRow extends StatelessWidget {
width: block.width,
top: 0,
bottom: 0,
child: _ValueBlockView(block: block),
child: _ValueBlockMenu(
block: block,
onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry,
),
),
if (activeBlock != null)
Positioned(
@@ -408,6 +422,80 @@ class _ValueBlockView extends StatelessWidget {
}
}
enum _TimelineBlockAction { edit, delete }
class _ValueBlockMenu extends StatelessWidget {
const _ValueBlockMenu({
required this.block,
this.onEditEntry,
this.onDeleteEntry,
});
final _ValueBlock block;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
bool get _hasActions => onEditEntry != null || onDeleteEntry != null;
@override
Widget build(BuildContext context) {
if (!_hasActions || block.entry == null) {
return _ValueBlockView(block: block);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) async {
final overlay = Overlay.of(context);
final renderBox = overlay.context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
if (defaultTargetPlatform == TargetPlatform.android) {
HapticFeedback.lightImpact();
}
final anchor = details.globalPosition + const Offset(0, -8);
final position = RelativeRect.fromRect(
Rect.fromLTWH(
anchor.dx,
anchor.dy,
1,
1,
),
Offset.zero & renderBox.size,
);
final action = await showMenu<_TimelineBlockAction>(
context: context,
position: position,
items: [
if (onEditEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.edit,
child: Text('Edit'),
),
if (onDeleteEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.delete,
child: Text('Delete'),
),
],
);
final entry = block.entry;
if (action == null || entry == null) return;
switch (action) {
case _TimelineBlockAction.edit:
onEditEntry?.call(entry);
break;
case _TimelineBlockAction.delete:
onDeleteEntry?.call(entry);
break;
}
},
child: _ValueBlockView(block: block),
);
}
}
String? _formatDate(DateTime? date) {
if (date == null) return null;
return _dateFormat.format(date);
@@ -481,23 +569,14 @@ class _TimelineModel {
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
final end = _safeEnd(start, rawEnd);
if (segments.isNotEmpty && segments.last.value == entry.valueLabel) {
final last = segments.removeLast();
segments.add(
last.copyWith(
end: end.isAfter(last.end) ? end : last.end,
),
);
} else {
segments.add(
_ValueSegment(
start: start,
end: end,
value: entry.valueLabel,
entry: entry,
),
);
}
segments.add(
_ValueSegment(
start: start,
end: end,
value: entry.valueLabel,
entry: entry,
),
);
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
@@ -557,6 +636,7 @@ class _TimelineModel {
left: left,
width: width,
cell: _RowCell.fromSegment(seg),
entry: seg.entry,
),
);
}
@@ -671,11 +751,13 @@ class _ValueBlock {
final double left;
final double width;
final _RowCell cell;
final LocoAttrVersion? entry;
const _ValueBlock({
required this.left,
required this.width,
required this.cell,
required this.entry,
});
double get right => left + width;
@@ -684,11 +766,13 @@ class _ValueBlock {
double? left,
double? width,
_RowCell? cell,
LocoAttrVersion? entry,
}) {
return _ValueBlock(
left: left ?? this.left,
width: width ?? this.width,
cell: cell ?? this.cell,
entry: entry ?? this.entry,
);
}
}

View File

@@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class TractionPage extends StatefulWidget {
const TractionPage({
@@ -22,6 +25,7 @@ class TractionPage extends StatefulWidget {
}
class _TractionPageState extends State<TractionPage> {
static const String _prefsKey = 'traction_search_state_v1';
final _classController = TextEditingController();
final _classFocusNode = FocusNode();
final _numberController = TextEditingController();
@@ -34,6 +38,7 @@ class _TractionPageState extends State<TractionPage> {
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
bool _restoredFromPrefs = false;
@override
void initState() {
@@ -48,17 +53,107 @@ class _TractionPageState extends State<TractionPage> {
_initialised = true;
_selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) {
final data = context.read<DataService>();
data.fetchClassList();
data.fetchEventFields();
_refreshTraction();
_initialLoad();
});
}
}
Future<void> _initialLoad() async {
final data = context.read<DataService>();
await _restoreSearchState();
data.fetchClassList();
data.fetchEventFields();
await _refreshTraction();
}
Future<void> _restoreSearchState() async {
if (widget.selectionMode) return;
if (_restoredFromPrefs) return;
_restoredFromPrefs = true;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_prefsKey);
if (raw == null || raw.trim().isEmpty) return;
final decoded = jsonDecode(raw);
if (decoded is! Map) return;
final classText = decoded['classText']?.toString();
final numberText = decoded['number']?.toString();
final nameText = decoded['name']?.toString();
final selectedClass = decoded['selectedClass']?.toString();
final mileageFirst = decoded['mileageFirst'];
final showAdvanced = decoded['showAdvancedFilters'];
if (classText != null) _classController.text = classText;
if (numberText != null) _numberController.text = numberText;
if (nameText != null) _nameController.text = nameText;
final dynamicValues = <String, String>{};
final enumValues = <String, String?>{};
final dynamicRaw = decoded['dynamic'];
if (dynamicRaw is Map) {
for (final entry in dynamicRaw.entries) {
final key = entry.key.toString();
final val = entry.value?.toString() ?? '';
dynamicValues[key] = val;
}
}
final enumRaw = decoded['enum'];
if (enumRaw is Map) {
for (final entry in enumRaw.entries) {
enumValues[entry.key.toString()] = entry.value?.toString();
}
}
for (final entry in dynamicValues.entries) {
_dynamicControllers.putIfAbsent(
entry.key,
() => TextEditingController(text: entry.value),
);
_dynamicControllers[entry.key]?.text = entry.value;
}
for (final entry in enumValues.entries) {
_enumSelections[entry.key] = entry.value;
}
if (!mounted) return;
setState(() {
_selectedClass = (selectedClass != null && selectedClass.trim().isNotEmpty)
? selectedClass
: null;
if (mileageFirst is bool) _mileageFirst = mileageFirst;
if (showAdvanced is bool) _showAdvancedFilters = showAdvanced;
});
} catch (_) {
// Ignore preference restore failures.
}
}
Future<void> _persistSearchState() async {
if (widget.selectionMode) return;
final payload = <String, dynamic>{
'classText': _classController.text,
'number': _numberController.text,
'name': _nameController.text,
'selectedClass': _selectedClass,
'mileageFirst': _mileageFirst,
'showAdvancedFilters': _showAdvancedFilters,
'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)),
'enum': _enumSelections,
};
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, jsonEncode(payload));
} catch (_) {
// Ignore persistence failures.
}
}
@override
void dispose() {
_classController.removeListener(_onClassTextChanged);
_persistSearchState();
_classController.dispose();
_classFocusNode.dispose();
_numberController.dispose();
@@ -111,6 +206,7 @@ class _TractionPageState extends State<TractionPage> {
filters: filters,
mileageFirst: _mileageFirst,
);
await _persistSearchState();
}
void _clearFilters() {
@@ -547,12 +643,14 @@ class _TractionPageState extends State<TractionPage> {
return _selectedKeys.contains(keyVal);
}
void _openTimeline(LocoSummary loco) {
Future<void> _openTimeline(LocoSummary loco) async {
final label = '${loco.locoClass} ${loco.number}'.trim();
context.push(
await context.push(
'/traction/${loco.id}/timeline',
extra: {'label': label},
);
if (!mounted) return;
await _refreshTraction();
}
Widget _buildFilterInput(