add ability for non admins to add new traction, pending approval. Various QoL updates
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Successful in 1m14s
Release / android-build (push) Successful in 5m33s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Successful in 1m14s
Release / android-build (push) Successful in 5m33s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
This commit is contained in:
@@ -23,6 +23,7 @@ class _EventEditor extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasDrafts = drafts.isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -37,12 +38,14 @@ class _EventEditor extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddEvent,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New event'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (!hasDrafts) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddEvent,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New event'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: (!canSave || isSaving) ? null : onSave,
|
||||
icon: isSaving
|
||||
@@ -147,6 +150,14 @@ class _EventEditor extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (hasDrafts) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddEvent,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New event'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,25 @@ part of 'package:mileograph_flutter/components/pages/loco_timeline.dart';
|
||||
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
|
||||
enum _PendingModerationAction { approve, reject }
|
||||
|
||||
class _TimelineGrid extends StatefulWidget {
|
||||
const _TimelineGrid({
|
||||
required this.entries,
|
||||
this.onEditEntry,
|
||||
this.onDeleteEntry,
|
||||
this.onModeratePending,
|
||||
this.pendingActionsBusy = false,
|
||||
});
|
||||
|
||||
final List<LocoAttrVersion> entries;
|
||||
final void Function(LocoAttrVersion entry)? onEditEntry;
|
||||
final void Function(LocoAttrVersion entry)? onDeleteEntry;
|
||||
final Future<void> Function(
|
||||
LocoAttrVersion entry,
|
||||
_PendingModerationAction action,
|
||||
)? onModeratePending;
|
||||
final bool pendingActionsBusy;
|
||||
|
||||
@override
|
||||
State<_TimelineGrid> createState() => _TimelineGridState();
|
||||
@@ -191,6 +200,8 @@ class _TimelineGridState extends State<_TimelineGrid> {
|
||||
viewportWidth: axisWidth,
|
||||
onEditEntry: widget.onEditEntry,
|
||||
onDeleteEntry: widget.onDeleteEntry,
|
||||
onModeratePending: widget.onModeratePending,
|
||||
pendingActionsBusy: widget.pendingActionsBusy,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -276,6 +287,8 @@ class _AttrRow extends StatelessWidget {
|
||||
required this.viewportWidth,
|
||||
this.onEditEntry,
|
||||
this.onDeleteEntry,
|
||||
this.onModeratePending,
|
||||
this.pendingActionsBusy = false,
|
||||
});
|
||||
|
||||
final double rowHeight;
|
||||
@@ -285,6 +298,11 @@ class _AttrRow extends StatelessWidget {
|
||||
final double viewportWidth;
|
||||
final void Function(LocoAttrVersion entry)? onEditEntry;
|
||||
final void Function(LocoAttrVersion entry)? onDeleteEntry;
|
||||
final Future<void> Function(
|
||||
LocoAttrVersion entry,
|
||||
_PendingModerationAction action,
|
||||
)? onModeratePending;
|
||||
final bool pendingActionsBusy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -310,6 +328,8 @@ class _AttrRow extends StatelessWidget {
|
||||
block: block,
|
||||
onEditEntry: onEditEntry,
|
||||
onDeleteEntry: onDeleteEntry,
|
||||
onModeratePending: onModeratePending,
|
||||
pendingActionsBusy: pendingActionsBusy,
|
||||
),
|
||||
),
|
||||
if (activeBlock != null)
|
||||
@@ -326,6 +346,7 @@ class _AttrRow extends StatelessWidget {
|
||||
width: stickyWidth,
|
||||
),
|
||||
clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
|
||||
pendingActionsBusy: pendingActionsBusy,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -347,15 +368,17 @@ class _ValueBlockView extends StatelessWidget {
|
||||
const _ValueBlockView({
|
||||
required this.block,
|
||||
this.clipLeftEdge = false,
|
||||
this.pendingActionsBusy = false,
|
||||
});
|
||||
|
||||
final _ValueBlock block;
|
||||
final bool clipLeftEdge;
|
||||
final bool pendingActionsBusy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = block.cell.color.withValues(alpha: 0.9);
|
||||
final color = block.cell.color;
|
||||
final textColor = ThemeData.estimateBrightnessForColor(color) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
@@ -386,32 +409,63 @@ class _ValueBlockView extends StatelessWidget {
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 1, minHeight: 1),
|
||||
child: Column(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
block.cell.value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
) ??
|
||||
TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
block.cell.rangeLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: textColor.withValues(alpha: 0.9),
|
||||
) ??
|
||||
TextStyle(color: textColor.withValues(alpha: 0.9)),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (block.cell.isPending)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: pendingActionsBusy
|
||||
? CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation(textColor),
|
||||
)
|
||||
: Icon(
|
||||
Icons.pending,
|
||||
size: 16,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
block.cell.value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
) ??
|
||||
TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
block.cell.rangeLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: textColor.withValues(alpha: 0.9),
|
||||
) ??
|
||||
TextStyle(
|
||||
color: textColor.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -422,26 +476,45 @@ class _ValueBlockView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _TimelineBlockAction { edit, delete }
|
||||
enum _TimelineBlockAction { edit, delete, approve, reject }
|
||||
|
||||
class _ValueBlockMenu extends StatelessWidget {
|
||||
const _ValueBlockMenu({
|
||||
required this.block,
|
||||
this.onEditEntry,
|
||||
this.onDeleteEntry,
|
||||
this.onModeratePending,
|
||||
this.pendingActionsBusy = false,
|
||||
});
|
||||
|
||||
final _ValueBlock block;
|
||||
final void Function(LocoAttrVersion entry)? onEditEntry;
|
||||
final void Function(LocoAttrVersion entry)? onDeleteEntry;
|
||||
final Future<void> Function(
|
||||
LocoAttrVersion entry,
|
||||
_PendingModerationAction action,
|
||||
)? onModeratePending;
|
||||
final bool pendingActionsBusy;
|
||||
|
||||
bool get _hasActions => onEditEntry != null || onDeleteEntry != null;
|
||||
bool get _hasActions {
|
||||
final canModerate = block.entry?.isPending == true &&
|
||||
block.entry?.canModeratePending == true &&
|
||||
onModeratePending != null;
|
||||
final canEdit = onEditEntry != null && block.entry?.isPending != true;
|
||||
return onDeleteEntry != null || canModerate || canEdit;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_hasActions || block.entry == null) {
|
||||
return _ValueBlockView(block: block);
|
||||
return _ValueBlockView(
|
||||
block: block,
|
||||
);
|
||||
}
|
||||
final canModerate = block.entry?.isPending == true &&
|
||||
block.entry?.canModeratePending == true &&
|
||||
onModeratePending != null;
|
||||
final canEdit = onEditEntry != null && block.entry?.isPending != true;
|
||||
|
||||
Future<void> showContextMenuAt(Offset globalPosition) async {
|
||||
final overlay = Overlay.of(context);
|
||||
@@ -459,11 +532,23 @@ class _ValueBlockMenu extends StatelessWidget {
|
||||
context: context,
|
||||
position: position,
|
||||
items: [
|
||||
if (onEditEntry != null)
|
||||
if (canEdit)
|
||||
const PopupMenuItem(
|
||||
value: _TimelineBlockAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (canModerate)
|
||||
PopupMenuItem(
|
||||
value: _TimelineBlockAction.approve,
|
||||
enabled: !pendingActionsBusy,
|
||||
child: const Text('Approve pending'),
|
||||
),
|
||||
if (canModerate)
|
||||
PopupMenuItem(
|
||||
value: _TimelineBlockAction.reject,
|
||||
enabled: !pendingActionsBusy,
|
||||
child: const Text('Reject pending'),
|
||||
),
|
||||
if (onDeleteEntry != null)
|
||||
const PopupMenuItem(
|
||||
value: _TimelineBlockAction.delete,
|
||||
@@ -481,11 +566,17 @@ class _ValueBlockMenu extends StatelessWidget {
|
||||
case _TimelineBlockAction.delete:
|
||||
onDeleteEntry?.call(entry);
|
||||
break;
|
||||
case _TimelineBlockAction.approve:
|
||||
onModeratePending?.call(entry, _PendingModerationAction.approve);
|
||||
break;
|
||||
case _TimelineBlockAction.reject:
|
||||
onModeratePending?.call(entry, _PendingModerationAction.reject);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onLongPressStart: (details) async {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
HapticFeedback.lightImpact();
|
||||
@@ -495,7 +586,10 @@ class _ValueBlockMenu extends StatelessWidget {
|
||||
onSecondaryTapDown: (details) async {
|
||||
await showContextMenuAt(details.globalPosition);
|
||||
},
|
||||
child: _ValueBlockView(block: block),
|
||||
child: _ValueBlockView(
|
||||
block: block,
|
||||
pendingActionsBusy: pendingActionsBusy,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -518,7 +612,28 @@ String _formatAttrLabel(String code) {
|
||||
|
||||
DateTime? _parseDateString(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return DateTime.tryParse(value);
|
||||
final direct = DateTime.tryParse(value);
|
||||
if (direct != null) return direct;
|
||||
final maskedMatch =
|
||||
RegExp(r'^(\\d{4})-(\\d{2}|xx|XX)-(\\d{2}|xx|XX)\$').firstMatch(value);
|
||||
if (maskedMatch != null) {
|
||||
final year = int.tryParse(maskedMatch.group(1) ?? '');
|
||||
if (year == null) return null;
|
||||
String normalize(String part, int fallback) {
|
||||
final lower = part.toLowerCase();
|
||||
if (lower == 'xx') return fallback.toString().padLeft(2, '0');
|
||||
return part;
|
||||
}
|
||||
|
||||
final month = int.tryParse(normalize(maskedMatch.group(2) ?? '01', 1)) ?? 1;
|
||||
final day = int.tryParse(normalize(maskedMatch.group(3) ?? '01', 1)) ?? 1;
|
||||
try {
|
||||
return DateTime(year, month.clamp(1, 12), day.clamp(1, 31));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _effectiveStart(LocoAttrVersion entry) {
|
||||
@@ -550,8 +665,11 @@ class _TimelineModel {
|
||||
});
|
||||
|
||||
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
|
||||
final effectiveEntries = entries
|
||||
.where((e) => _effectiveStart(e) != null)
|
||||
.toList();
|
||||
final grouped = <String, List<LocoAttrVersion>>{};
|
||||
for (final entry in entries) {
|
||||
for (final entry in effectiveEntries) {
|
||||
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
|
||||
}
|
||||
final now = DateTime.now();
|
||||
@@ -774,11 +892,13 @@ class _RowCell {
|
||||
final String value;
|
||||
final String rangeLabel;
|
||||
final Color color;
|
||||
final bool isPending;
|
||||
|
||||
const _RowCell({
|
||||
required this.value,
|
||||
required this.rangeLabel,
|
||||
required this.color,
|
||||
this.isPending = false,
|
||||
});
|
||||
|
||||
factory _RowCell.fromSegment(_ValueSegment seg) {
|
||||
@@ -787,6 +907,7 @@ class _RowCell {
|
||||
value: '',
|
||||
rangeLabel: '',
|
||||
color: Colors.transparent,
|
||||
isPending: false,
|
||||
);
|
||||
}
|
||||
final entry = seg.entry;
|
||||
@@ -802,6 +923,7 @@ class _RowCell {
|
||||
value: seg.value,
|
||||
rangeLabel: displayStart,
|
||||
color: _colorForValue(seg.value),
|
||||
isPending: entry?.isPending ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user