add timeline edit/delete
All checks were successful
All checks were successful
This commit is contained in:
@@ -29,8 +29,40 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
||||||
BASE_VERSION=${RAW_VERSION%%+*}
|
BASE_VERSION=${RAW_VERSION%%+*}
|
||||||
|
TAG="v${BASE_VERSION}"
|
||||||
|
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
||||||
|
TAG="v${BASE_VERSION}-dev"
|
||||||
|
fi
|
||||||
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "release_tag=v${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Fail if release already exists
|
||||||
|
env:
|
||||||
|
TAG: ${{ steps.meta.outputs.release_tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
|
fi
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y curl ca-certificates
|
||||||
|
fi
|
||||||
|
|
||||||
|
URL="${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}"
|
||||||
|
CODE="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" "$URL" || true)"
|
||||||
|
|
||||||
|
if [ "$CODE" = "200" ]; then
|
||||||
|
echo "Release already exists for tag ${TAG}; refusing to re-release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$CODE" != "404" ]; then
|
||||||
|
echo "Unexpected response checking existing release (${CODE}) at ${URL}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
android-build:
|
android-build:
|
||||||
runs-on:
|
runs-on:
|
||||||
@@ -235,7 +267,7 @@ jobs:
|
|||||||
id: bundle
|
id: bundle
|
||||||
run: |
|
run: |
|
||||||
BASE="${{ needs.meta.outputs.base_version }}"
|
BASE="${{ needs.meta.outputs.base_version }}"
|
||||||
TAG="v${BASE}-dev"
|
TAG="${{ needs.meta.outputs.release_tag }}"
|
||||||
|
|
||||||
mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk"
|
mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/api_service.dart';
|
import 'package:mileograph_flutter/services/api_service.dart';
|
||||||
@@ -119,8 +120,6 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
RouteResult? get result => _routeResult;
|
RouteResult? get result => _routeResult;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
bool _showDetails = false;
|
|
||||||
|
|
||||||
bool _fetched = false;
|
bool _fetched = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -188,13 +187,6 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
if (_showDetails && _routeResult != null) {
|
|
||||||
return RouteDetailsView(
|
|
||||||
route: _routeResult!.calculatedRoute,
|
|
||||||
costs: _routeResult!.costs,
|
|
||||||
onBack: () => setState(() => _showDetails = false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -263,7 +255,11 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
else if (_routeResult != null) ...[
|
else if (_routeResult != null) ...[
|
||||||
RouteSummaryWidget(
|
RouteSummaryWidget(
|
||||||
distance: _routeResult!.distance,
|
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)
|
if (widget.onApplyRoute != null)
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
47
lib/components/pages/calculator_details.dart
Normal file
47
lib/components/pages/calculator_details.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
@@ -26,6 +28,7 @@ class LocoTimelinePage extends StatefulWidget {
|
|||||||
class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||||
final List<_EventDraft> _draftEvents = [];
|
final List<_EventDraft> _draftEvents = [];
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
bool _isDeleting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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) {
|
void _removeDraftAt(int index) {
|
||||||
if (index < 0 || index >= _draftEvents.length) return;
|
if (index < 0 || index >= _draftEvents.length) return;
|
||||||
final draft = _draftEvents.removeAt(index);
|
final draft = _draftEvents.removeAt(index);
|
||||||
@@ -241,6 +381,11 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
children: [
|
children: [
|
||||||
_TimelineGrid(
|
_TimelineGrid(
|
||||||
entries: timeline,
|
entries: timeline,
|
||||||
|
onEditEntry: (entry) => _prefillDraftFromEntry(
|
||||||
|
entry,
|
||||||
|
data.eventFields,
|
||||||
|
),
|
||||||
|
onDeleteEntry: _deleteEntry,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_EventEditor(
|
_EventEditor(
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
|
|||||||
class _TimelineGrid extends StatefulWidget {
|
class _TimelineGrid extends StatefulWidget {
|
||||||
const _TimelineGrid({
|
const _TimelineGrid({
|
||||||
required this.entries,
|
required this.entries,
|
||||||
|
this.onEditEntry,
|
||||||
|
this.onDeleteEntry,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<LocoAttrVersion> entries;
|
final List<LocoAttrVersion> entries;
|
||||||
|
final void Function(LocoAttrVersion entry)? onEditEntry;
|
||||||
|
final void Function(LocoAttrVersion entry)? onDeleteEntry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_TimelineGrid> createState() => _TimelineGridState();
|
State<_TimelineGrid> createState() => _TimelineGridState();
|
||||||
@@ -185,6 +189,8 @@ class _TimelineGridState extends State<_TimelineGrid> {
|
|||||||
model: model,
|
model: model,
|
||||||
scrollOffset: _scrollOffset,
|
scrollOffset: _scrollOffset,
|
||||||
viewportWidth: axisWidth,
|
viewportWidth: axisWidth,
|
||||||
|
onEditEntry: widget.onEditEntry,
|
||||||
|
onDeleteEntry: widget.onDeleteEntry,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -268,6 +274,8 @@ class _AttrRow extends StatelessWidget {
|
|||||||
required this.model,
|
required this.model,
|
||||||
required this.scrollOffset,
|
required this.scrollOffset,
|
||||||
required this.viewportWidth,
|
required this.viewportWidth,
|
||||||
|
this.onEditEntry,
|
||||||
|
this.onDeleteEntry,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double rowHeight;
|
final double rowHeight;
|
||||||
@@ -275,6 +283,8 @@ class _AttrRow extends StatelessWidget {
|
|||||||
final _TimelineModel model;
|
final _TimelineModel model;
|
||||||
final double scrollOffset;
|
final double scrollOffset;
|
||||||
final double viewportWidth;
|
final double viewportWidth;
|
||||||
|
final void Function(LocoAttrVersion entry)? onEditEntry;
|
||||||
|
final void Function(LocoAttrVersion entry)? onDeleteEntry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -296,7 +306,11 @@ class _AttrRow extends StatelessWidget {
|
|||||||
width: block.width,
|
width: block.width,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: _ValueBlockView(block: block),
|
child: _ValueBlockMenu(
|
||||||
|
block: block,
|
||||||
|
onEditEntry: onEditEntry,
|
||||||
|
onDeleteEntry: onDeleteEntry,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (activeBlock != null)
|
if (activeBlock != null)
|
||||||
Positioned(
|
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) {
|
String? _formatDate(DateTime? date) {
|
||||||
if (date == null) return null;
|
if (date == null) return null;
|
||||||
return _dateFormat.format(date);
|
return _dateFormat.format(date);
|
||||||
@@ -481,14 +569,6 @@ class _TimelineModel {
|
|||||||
: null;
|
: null;
|
||||||
final rawEnd = entry.validTo ?? nextStart ?? now;
|
final rawEnd = entry.validTo ?? nextStart ?? now;
|
||||||
final end = _safeEnd(start, rawEnd);
|
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(
|
segments.add(
|
||||||
_ValueSegment(
|
_ValueSegment(
|
||||||
start: start,
|
start: start,
|
||||||
@@ -497,7 +577,6 @@ class _TimelineModel {
|
|||||||
entry: entry,
|
entry: entry,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
minStart = minStart == null || start.isBefore(minStart!)
|
minStart = minStart == null || start.isBefore(minStart!)
|
||||||
? start
|
? start
|
||||||
: minStart;
|
: minStart;
|
||||||
@@ -557,6 +636,7 @@ class _TimelineModel {
|
|||||||
left: left,
|
left: left,
|
||||||
width: width,
|
width: width,
|
||||||
cell: _RowCell.fromSegment(seg),
|
cell: _RowCell.fromSegment(seg),
|
||||||
|
entry: seg.entry,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -671,11 +751,13 @@ class _ValueBlock {
|
|||||||
final double left;
|
final double left;
|
||||||
final double width;
|
final double width;
|
||||||
final _RowCell cell;
|
final _RowCell cell;
|
||||||
|
final LocoAttrVersion? entry;
|
||||||
|
|
||||||
const _ValueBlock({
|
const _ValueBlock({
|
||||||
required this.left,
|
required this.left,
|
||||||
required this.width,
|
required this.width,
|
||||||
required this.cell,
|
required this.cell,
|
||||||
|
required this.entry,
|
||||||
});
|
});
|
||||||
|
|
||||||
double get right => left + width;
|
double get right => left + width;
|
||||||
@@ -684,11 +766,13 @@ class _ValueBlock {
|
|||||||
double? left,
|
double? left,
|
||||||
double? width,
|
double? width,
|
||||||
_RowCell? cell,
|
_RowCell? cell,
|
||||||
|
LocoAttrVersion? entry,
|
||||||
}) {
|
}) {
|
||||||
return _ValueBlock(
|
return _ValueBlock(
|
||||||
left: left ?? this.left,
|
left: left ?? this.left,
|
||||||
width: width ?? this.width,
|
width: width ?? this.width,
|
||||||
cell: cell ?? this.cell,
|
cell: cell ?? this.cell,
|
||||||
|
entry: entry ?? this.entry,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mileograph_flutter/components/traction/traction_card.dart';
|
import 'package:mileograph_flutter/components/traction/traction_card.dart';
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class TractionPage extends StatefulWidget {
|
class TractionPage extends StatefulWidget {
|
||||||
const TractionPage({
|
const TractionPage({
|
||||||
@@ -22,6 +25,7 @@ class TractionPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TractionPageState extends State<TractionPage> {
|
class _TractionPageState extends State<TractionPage> {
|
||||||
|
static const String _prefsKey = 'traction_search_state_v1';
|
||||||
final _classController = TextEditingController();
|
final _classController = TextEditingController();
|
||||||
final _classFocusNode = FocusNode();
|
final _classFocusNode = FocusNode();
|
||||||
final _numberController = TextEditingController();
|
final _numberController = TextEditingController();
|
||||||
@@ -34,6 +38,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
|
|
||||||
final Map<String, TextEditingController> _dynamicControllers = {};
|
final Map<String, TextEditingController> _dynamicControllers = {};
|
||||||
final Map<String, String?> _enumSelections = {};
|
final Map<String, String?> _enumSelections = {};
|
||||||
|
bool _restoredFromPrefs = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -48,17 +53,107 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
_initialised = true;
|
_initialised = true;
|
||||||
_selectedKeys = {...widget.selectedKeys};
|
_selectedKeys = {...widget.selectedKeys};
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_initialLoad();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initialLoad() async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
|
await _restoreSearchState();
|
||||||
data.fetchClassList();
|
data.fetchClassList();
|
||||||
data.fetchEventFields();
|
data.fetchEventFields();
|
||||||
_refreshTraction();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_classController.removeListener(_onClassTextChanged);
|
_classController.removeListener(_onClassTextChanged);
|
||||||
|
_persistSearchState();
|
||||||
_classController.dispose();
|
_classController.dispose();
|
||||||
_classFocusNode.dispose();
|
_classFocusNode.dispose();
|
||||||
_numberController.dispose();
|
_numberController.dispose();
|
||||||
@@ -111,6 +206,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
filters: filters,
|
filters: filters,
|
||||||
mileageFirst: _mileageFirst,
|
mileageFirst: _mileageFirst,
|
||||||
);
|
);
|
||||||
|
await _persistSearchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearFilters() {
|
void _clearFilters() {
|
||||||
@@ -547,12 +643,14 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
return _selectedKeys.contains(keyVal);
|
return _selectedKeys.contains(keyVal);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openTimeline(LocoSummary loco) {
|
Future<void> _openTimeline(LocoSummary loco) async {
|
||||||
final label = '${loco.locoClass} ${loco.number}'.trim();
|
final label = '${loco.locoClass} ${loco.number}'.trim();
|
||||||
context.push(
|
await context.push(
|
||||||
'/traction/${loco.id}/timeline',
|
'/traction/${loco.id}/timeline',
|
||||||
extra: {'label': label},
|
extra: {'label': label},
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _refreshTraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterInput(
|
Widget _buildFilterInput(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/calculator_details.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
|
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
||||||
@@ -95,7 +96,9 @@ class MyApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/calculator/details',
|
path: '/calculator/details',
|
||||||
builder: (context, state) => CalculatorPage(),
|
builder: (context, state) => CalculatorDetailsPage(
|
||||||
|
result: state.extra,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/legs', builder: (context, state) => LegsPage()),
|
GoRoute(path: '/legs', builder: (context, state) => LegsPage()),
|
||||||
GoRoute(path: '/traction', builder: (context, state) => TractionPage()),
|
GoRoute(path: '/traction', builder: (context, state) => TractionPage()),
|
||||||
|
|||||||
@@ -466,6 +466,17 @@ class DataService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteTimelineBlock({
|
||||||
|
required int blockId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await api.delete('/event/delete/$blockId');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to delete timeline block $blockId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
_homepageStats = null;
|
_homepageStats = null;
|
||||||
_legs = [];
|
_legs = [];
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.2.1+1
|
version: 0.2.2+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user