From 987e4036e94d219dab03b9d8e13a5ed35bf9004a Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Mon, 23 Feb 2026 17:37:31 +0000 Subject: [PATCH] add redate functionality to set the earliest date of entries --- lib/components/pages/loco_timeline.dart | 259 ++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 260 insertions(+), 1 deletion(-) diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 3dabbd7..3d32de6 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -13,6 +13,8 @@ import 'package:shared_preferences/shared_preferences.dart'; part 'loco_timeline/timeline_grid.dart'; part 'loco_timeline/event_editor.dart'; +enum _TimelineMenuAction { redate } + class LocoTimelinePage extends StatefulWidget { const LocoTimelinePage({ super.key, @@ -537,6 +539,239 @@ class _LocoTimelinePageState extends State { return _draftEvents.every(_draftIsValid); } + Future _handleTimelineMenuAction( + _TimelineMenuAction action, + List timeline, + ) async { + switch (action) { + case _TimelineMenuAction.redate: + await _startMassRedate(timeline); + break; + } + } + + Future _startMassRedate(List timeline) async { + final messenger = ScaffoldMessenger.of(context); + final eventDate = await _showRedateDateDialog(); + if (!mounted || eventDate == null) return; + + final earliestByAttr = _earliestEntriesByAttr(timeline); + if (earliestByAttr.isEmpty) { + messenger.showSnackBar( + const SnackBar(content: Text('No timeline values available to redate.')), + ); + return; + } + + final result = await _showMassRedateProgressDialog( + eventDate: eventDate, + entries: earliestByAttr, + ); + if (!mounted || result == null) return; + + await _load(); + if (!mounted) return; + final successCount = result.total - result.failures; + final message = result.failures == 0 + ? 'Redated $successCount attributes.' + : 'Redated $successCount of ${result.total} attributes (${result.failures} failed).'; + messenger.showSnackBar(SnackBar(content: Text(message))); + } + + List _earliestEntriesByAttr(List timeline) { + final byAttr = {}; + for (final entry in timeline) { + if (entry.isPending) continue; + final key = entry.attrCode.trim().toLowerCase(); + if (key.isEmpty) continue; + final current = byAttr[key]; + if (current == null || LocoAttrVersion.compareByStart(entry, current) < 0) { + byAttr[key] = entry; + } + } + final entries = byAttr.values.toList() + ..sort((a, b) => a.attrCode.compareTo(b.attrCode)); + return entries; + } + + Future _showRedateDateDialog() async { + final controller = TextEditingController( + text: DateFormat('yyyy-MM-dd').format(DateTime.now()), + ); + String? validationError; + final result = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + Future pickDate() async { + final initial = DateTime.tryParse(controller.text.trim()) ?? + DateTime.now(); + final picked = await showDatePicker( + context: dialogContext, + initialDate: initial, + firstDate: DateTime(1800), + lastDate: DateTime(2500), + ); + if (picked == null) return; + final nextValue = DateFormat('yyyy-MM-dd').format(picked); + setDialogState(() { + controller.text = nextValue; + validationError = null; + }); + } + + return AlertDialog( + title: const Text('Redate timeline attributes'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter the target date. The earliest value of each attribute will be submitted at this date.', + ), + const SizedBox(height: 12), + TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'Date', + hintText: 'YYYY-MM-DD', + errorText: validationError, + suffixIcon: IconButton( + tooltip: 'Pick date', + icon: const Icon(Icons.calendar_month), + onPressed: pickDate, + ), + ), + onSubmitted: (_) { + final value = controller.text.trim(); + if (!_isValidDateString(value)) { + setDialogState(() { + validationError = 'Use format YYYY-MM-DD.'; + }); + return; + } + Navigator.of(dialogContext).pop(value); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final value = controller.text.trim(); + if (!_isValidDateString(value)) { + setDialogState(() { + validationError = 'Use format YYYY-MM-DD.'; + }); + return; + } + Navigator.of(dialogContext).pop(value); + }, + child: const Text('Run'), + ), + ], + ); + }, + ); + }, + ); + controller.dispose(); + return result; + } + + Future<_MassRedateResult?> _showMassRedateProgressDialog({ + required String eventDate, + required List entries, + }) async { + final data = context.read(); + var started = false; + var completed = 0; + var failures = 0; + var currentAttrLabel = ''; + + return showDialog<_MassRedateResult>( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + Future run() async { + for (final entry in entries) { + if (!mounted) return; + setDialogState(() { + currentAttrLabel = _formatAttrLabel(entry.attrCode); + }); + + final value = _valueForEntry(entry); + final isBlank = value is String && value.trim().isEmpty; + if (value == null || isBlank) { + failures += 1; + setDialogState(() { + completed += 1; + }); + continue; + } + + try { + await data.createLocoEvent( + locoId: widget.locoId, + eventDate: eventDate, + values: {entry.attrCode: value}, + details: '', + ); + } catch (_) { + failures += 1; + } + if (!mounted || !dialogContext.mounted) return; + setDialogState(() { + completed += 1; + }); + } + + if (!mounted || !dialogContext.mounted) return; + Navigator.of(dialogContext).pop( + _MassRedateResult(total: entries.length, failures: failures), + ); + } + + if (!started) { + started = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + run(); + }); + } + + final total = entries.length; + final progress = total == 0 ? 0.0 : completed / total; + return AlertDialog( + title: const Text('Redating attributes'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator(value: progress), + const SizedBox(height: 12), + Text('$completed / $total complete'), + const SizedBox(height: 8), + Text( + currentAttrLabel.isEmpty + ? 'Preparing requests...' + : 'Current: $currentAttrLabel', + ), + ], + ), + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { final data = context.watch(); @@ -554,6 +789,20 @@ class _LocoTimelinePageState extends State { onPressed: () => Navigator.of(context).maybePop(), ), title: Text('Timeline ยท ${widget.locoLabel}'), + actions: [ + PopupMenuButton<_TimelineMenuAction>( + tooltip: 'Timeline actions', + icon: const Icon(Icons.more_vert), + onSelected: (action) => + _handleTimelineMenuAction(action, visibleTimeline), + itemBuilder: (context) => const [ + PopupMenuItem<_TimelineMenuAction>( + value: _TimelineMenuAction.redate, + child: Text('Redate'), + ), + ], + ), + ], ), body: RefreshIndicator( onRefresh: _load, @@ -684,3 +933,13 @@ class _LocoTimelinePageState extends State { ); } } + +class _MassRedateResult { + const _MassRedateResult({ + required this.total, + required this.failures, + }); + + final int total; + final int failures; +} diff --git a/pubspec.yaml b/pubspec.yaml index e304f2e..8e70262 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.8.2+19 +version: 0.8.3+20 environment: sdk: ^3.8.1