diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index db65d30..1cbb00b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -29,8 +29,40 @@ jobs: run: | RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) 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 "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: runs-on: @@ -235,7 +267,7 @@ jobs: id: bundle run: | 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" diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index ff3a153..12a213e 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -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 { RouteResult? get result => _routeResult; String? _errorMessage; - bool _showDetails = false; - bool _fetched = false; @override @@ -188,13 +187,6 @@ class _RouteCalculatorState extends State { @override Widget build(BuildContext context) { final data = context.watch(); - 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 { 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( diff --git a/lib/components/pages/calculator_details.dart b/lib/components/pages/calculator_details.dart new file mode 100644 index 0000000..44a4378 --- /dev/null +++ b/lib/components/pages/calculator_details.dart @@ -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(), + ), + ); + } +} + diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 0346a92..9317b34 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -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 { final List<_EventDraft> _draftEvents = []; bool _isSaving = false; + bool _isDeleting = false; @override void initState() { @@ -51,6 +54,143 @@ class _LocoTimelinePageState extends State { }); } + 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 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 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 _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(); + final messenger = ScaffoldMessenger.of(context); + + final dateStr = _eventDateForEntry(entry); + final ok = await showDialog( + 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 { children: [ _TimelineGrid( entries: timeline, + onEditEntry: (entry) => _prefillDraftFromEntry( + entry, + data.eventFields, + ), + onDeleteEntry: _deleteEntry, ), const SizedBox(height: 16), _EventEditor( diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart index 0acb94c..ae71de2 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -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 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, ); } } diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index 5a11bc3..840a4d6 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -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 { + 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 { final Map _dynamicControllers = {}; final Map _enumSelections = {}; + bool _restoredFromPrefs = false; @override void initState() { @@ -48,17 +53,107 @@ class _TractionPageState extends State { _initialised = true; _selectedKeys = {...widget.selectedKeys}; WidgetsBinding.instance.addPostFrameCallback((_) { - final data = context.read(); - data.fetchClassList(); - data.fetchEventFields(); - _refreshTraction(); + _initialLoad(); }); } } + Future _initialLoad() async { + final data = context.read(); + await _restoreSearchState(); + data.fetchClassList(); + data.fetchEventFields(); + await _refreshTraction(); + } + + Future _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 = {}; + final enumValues = {}; + 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 _persistSearchState() async { + if (widget.selectionMode) return; + final payload = { + '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 { filters: filters, mileageFirst: _mileageFirst, ); + await _persistSearchState(); } void _clearFilters() { @@ -547,12 +643,14 @@ class _TractionPageState extends State { return _selectedKeys.contains(keyVal); } - void _openTimeline(LocoSummary loco) { + Future _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( diff --git a/lib/main.dart b/lib/main.dart index 3ba6a69..8502994 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.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/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart'; @@ -95,7 +96,9 @@ class MyApp extends StatelessWidget { ), GoRoute( path: '/calculator/details', - builder: (context, state) => CalculatorPage(), + builder: (context, state) => CalculatorDetailsPage( + result: state.extra, + ), ), GoRoute(path: '/legs', builder: (context, state) => LegsPage()), GoRoute(path: '/traction', builder: (context, state) => TractionPage()), diff --git a/lib/services/data_service.dart b/lib/services/data_service.dart index fc23536..080c2f8 100644 --- a/lib/services/data_service.dart +++ b/lib/services/data_service.dart @@ -466,6 +466,17 @@ class DataService extends ChangeNotifier { } } + Future 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() { _homepageStats = null; _legs = []; diff --git a/pubspec.yaml b/pubspec.yaml index 9345fc0..95fe133 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 0.2.1+1 +version: 0.2.2+1 environment: sdk: ^3.8.1