Compare commits
2 Commits
0.8.1-dev.
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 987e4036e9 | |||
| ecfd63c223 |
@@ -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<LocoTimelinePage> {
|
||||
return _draftEvents.every(_draftIsValid);
|
||||
}
|
||||
|
||||
Future<void> _handleTimelineMenuAction(
|
||||
_TimelineMenuAction action,
|
||||
List<LocoAttrVersion> timeline,
|
||||
) async {
|
||||
switch (action) {
|
||||
case _TimelineMenuAction.redate:
|
||||
await _startMassRedate(timeline);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startMassRedate(List<LocoAttrVersion> 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<LocoAttrVersion> _earliestEntriesByAttr(List<LocoAttrVersion> timeline) {
|
||||
final byAttr = <String, LocoAttrVersion>{};
|
||||
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<String?> _showRedateDateDialog() async {
|
||||
final controller = TextEditingController(
|
||||
text: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
);
|
||||
String? validationError;
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
Future<void> 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<LocoAttrVersion> entries,
|
||||
}) async {
|
||||
final data = context.read<DataService>();
|
||||
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<void> 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<DataService>();
|
||||
@@ -554,6 +789,20 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
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<LocoTimelinePage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MassRedateResult {
|
||||
const _MassRedateResult({
|
||||
required this.total,
|
||||
required this.failures,
|
||||
});
|
||||
|
||||
final int total;
|
||||
final int failures;
|
||||
}
|
||||
|
||||
@@ -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.1+18
|
||||
version: 0.8.3+20
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
Reference in New Issue
Block a user