diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 39b3ad2..1562235 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -34,6 +34,7 @@ class _LocoTimelinePageState extends State { bool _isSaving = false; bool _isDeleting = false; final Set _moderatingEventIds = {}; + final Set _expandedPendingAttrs = {}; bool _showPending = true; @override @@ -613,16 +614,30 @@ class _LocoTimelinePageState extends State { padding: const EdgeInsets.all(16), children: [ if (isElevated) - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Show pending entries'), - value: _showPending, - onChanged: (value) { - setState(() { - _showPending = value; - }); - _persistPendingVisibility(value); - }, + Row( + children: [ + Expanded( + child: SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Show pending entries'), + value: _showPending, + onChanged: (value) async { + setState(() { + _showPending = value; + }); + await _persistPendingVisibility(value); + if (mounted) { + await _load(); + } + }, + ), + ), + IconButton( + tooltip: 'Refresh timeline', + onPressed: _load, + icon: const Icon(Icons.refresh), + ), + ], ), _TimelineGrid( entries: visibleTimeline, @@ -633,6 +648,14 @@ class _LocoTimelinePageState extends State { onDeleteEntry: _deleteEntry, onModeratePending: _moderatePendingEntry, pendingActionEventIds: _moderatingEventIds, + expandedPendingAttrs: _expandedPendingAttrs, + onTogglePendingAttr: (attrCode) { + setState(() { + if (!_expandedPendingAttrs.add(attrCode)) { + _expandedPendingAttrs.remove(attrCode); + } + }); + }, ), 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 b785748..044ad46 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -11,6 +11,8 @@ class _TimelineGrid extends StatefulWidget { this.onDeleteEntry, this.onModeratePending, this.pendingActionEventIds = const {}, + this.expandedPendingAttrs = const {}, + this.onTogglePendingAttr, }); final List entries; @@ -21,6 +23,8 @@ class _TimelineGrid extends StatefulWidget { _PendingModerationAction action, )? onModeratePending; final Set pendingActionEventIds; + final Set expandedPendingAttrs; + final void Function(String attrCode)? onTogglePendingAttr; @override State<_TimelineGrid> createState() => _TimelineGridState(); @@ -86,12 +90,15 @@ class _TimelineGridState extends State<_TimelineGrid> { 'build_day', }.contains(code); }).toList(); - final model = _TimelineModel.fromEntries(filteredEntries); + final model = _TimelineModel.fromEntries( + filteredEntries, + expandedAttrCodes: widget.expandedPendingAttrs, + ); final axisSegments = model.axisSegments; const labelWidth = 110.0; const rowHeight = 52.0; const double axisHeight = 48; - final rows = model.attrRows.entries.toList(); + final rows = model.rows; final totalRowsHeight = rows.length * rowHeight; final axisWidth = math.max(model.axisTotalWidth, 120.0); final double viewHeight = totalRowsHeight + axisHeight + 8; @@ -131,7 +138,12 @@ class _TimelineGridState extends State<_TimelineGrid> { itemExtent: rowHeight, itemCount: rows.length, itemBuilder: (_, index) { - final label = _formatAttrLabel(rows[index].key); + final row = rows[index]; + final label = row.isPrimary + ? _formatAttrLabel(row.attrCode) + : (row.pendingUser?.trim().isNotEmpty == true + ? row.pendingUser!.trim() + : 'Unknown'); return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric( @@ -148,12 +160,49 @@ class _TimelineGridState extends State<_TimelineGrid> { ), ), ), - child: Text( - label, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith(fontWeight: FontWeight.w700), + child: Row( + children: [ + if (!row.isPrimary) ...[ + Icon( + Icons.subdirectory_arrow_right, + size: 16, + color: Theme.of(context).hintColor, + ), + const SizedBox(width: 6), + ], + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + fontWeight: row.isPrimary + ? FontWeight.w700 + : FontWeight.w600, + ), + ), + ), + if (row.showExpandToggle) + IconButton( + onPressed: widget.onTogglePendingAttr == null + ? null + : () => widget.onTogglePendingAttr?.call( + row.attrCode, + ), + icon: Icon( + row.isExpanded + ? Icons.expand_less + : Icons.expand_more, + ), + tooltip: row.isExpanded + ? 'Collapse pending rows' + : 'Expand pending rows', + visualDensity: VisualDensity.compact, + ), + ], ), ); }, @@ -188,7 +237,7 @@ class _TimelineGridState extends State<_TimelineGrid> { itemExtent: rowHeight, itemCount: rows.length, itemBuilder: (_, index) { - final blocks = rows[index].value; + final blocks = rows[index].blocks; return Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), @@ -657,79 +706,252 @@ DateTime _safeEnd(DateTime start, DateTime? end) { return end; } +int _startKey(DateTime date) => date.year * 10000 + date.month * 100 + date.day; + +bool _isOverlappingStart(LocoAttrVersion entry, Set approvedStartKeys) { + final start = _effectiveStart(entry); + if (start == null) return false; + return approvedStartKeys.contains(_startKey(start)); +} + +List<_ValueSegment> _segmentsForEntries( + List items, + DateTime now, +) { + if (items.isEmpty) return const []; + final sorted = [...items]; + sorted.sort( + (a, b) => (_effectiveStart(a) ?? now) + .compareTo(_effectiveStart(b) ?? now), + ); + final segments = <_ValueSegment>[]; + for (int i = 0; i < sorted.length; i++) { + final entry = sorted[i]; + final start = _effectiveStart(entry) ?? now; + final nextStart = i < sorted.length - 1 + ? _effectiveStart(sorted[i + 1]) + : null; + final rawEnd = entry.validTo ?? nextStart ?? now; + final end = _safeEnd(start, rawEnd); + segments.add( + _ValueSegment( + start: start, + end: end, + value: _formatValueWithUnits(entry), + entry: entry, + ), + ); + } + return segments; +} + +List _buildBoundaries( + List<_ValueSegment> segments, + DateTime now, +) { + DateTime? minStart; + DateTime? maxEnd; + final boundaryDates = {}; + for (final seg in segments) { + boundaryDates.add(seg.start); + boundaryDates.add(seg.end); + minStart = minStart == null || seg.start.isBefore(minStart!) + ? seg.start + : minStart; + maxEnd = maxEnd == null || seg.end.isAfter(maxEnd!) ? seg.end : maxEnd; + } + minStart ??= now.subtract(const Duration(days: 1)); + final effectiveMaxEnd = maxEnd ?? now; + boundaryDates.add(effectiveMaxEnd); + var boundaries = boundaryDates.toList()..sort(); + if (boundaries.length < 2) { + boundaries = [minStart!, effectiveMaxEnd]; + } + return boundaries; +} + +class _TimelineRowSpec { + final String id; + final String attrCode; + final List<_ValueSegment> segments; + final bool isPrimary; + final bool showExpandToggle; + final bool isExpanded; + final String? userLabel; + + const _TimelineRowSpec._({ + required this.id, + required this.attrCode, + required this.segments, + required this.isPrimary, + required this.showExpandToggle, + required this.isExpanded, + this.userLabel, + }); + + factory _TimelineRowSpec.primary({ + required String attrCode, + required List<_ValueSegment> segments, + required bool showExpandToggle, + required bool isExpanded, + }) { + return _TimelineRowSpec._( + id: attrCode, + attrCode: attrCode, + segments: segments, + isPrimary: true, + showExpandToggle: showExpandToggle, + isExpanded: isExpanded, + ); + } + + factory _TimelineRowSpec.pending({ + required String attrCode, + required String userLabel, + required List<_ValueSegment> segments, + }) { + return _TimelineRowSpec._( + id: '$attrCode::$userLabel', + attrCode: attrCode, + segments: segments, + isPrimary: false, + showExpandToggle: false, + isExpanded: false, + userLabel: userLabel, + ); + } +} + +class _TimelineRowData { + final String id; + final String attrCode; + final List<_ValueBlock> blocks; + final bool isPrimary; + final bool showExpandToggle; + final bool isExpanded; + final String? pendingUser; + + const _TimelineRowData({ + required this.id, + required this.attrCode, + required this.blocks, + required this.isPrimary, + required this.showExpandToggle, + required this.isExpanded, + this.pendingUser, + }); +} + class _TimelineModel { final List<_AxisSegment> axisSegments; - final Map> attrRows; + final List<_TimelineRowData> rows; final String endLabel; final List boundaries; final double axisTotalWidth; _TimelineModel({ required this.axisSegments, - required this.attrRows, + required this.rows, required this.endLabel, required this.boundaries, required this.axisTotalWidth, }); - factory _TimelineModel.fromEntries(List entries) { + factory _TimelineModel.fromEntries( + List entries, { + Set expandedAttrCodes = const {}, + }) { final effectiveEntries = entries .where((e) => _effectiveStart(e) != null) .toList(); final grouped = >{}; + final attrOrder = []; for (final entry in effectiveEntries) { - grouped.putIfAbsent(entry.attrCode, () => []).add(entry); + final key = entry.attrCode; + if (!grouped.containsKey(key)) { + attrOrder.add(key); + } + grouped.putIfAbsent(key, () => []).add(entry); } final now = DateTime.now(); - DateTime? minStart; - DateTime? maxEnd; - final attrSegments = >{}; + final allSegments = <_ValueSegment>[]; + final rowSpecs = <_TimelineRowSpec>[]; + for (final attr in attrOrder) { + final items = grouped[attr] ?? const []; + final approved = items.where((e) => !e.isPending).toList(); + final pending = items.where((e) => e.isPending).toList(); + final approvedSegments = _segmentsForEntries(approved, now); - grouped.forEach((attr, items) { - items.sort( - (a, b) => (_effectiveStart(a) ?? now) - .compareTo(_effectiveStart(b) ?? now), + final approvedStartKeys = {}; + for (final entry in approved) { + final start = _effectiveStart(entry); + if (start == null) continue; + approvedStartKeys.add(_startKey(start)); + } + + final pendingByUser = >{}; + final overlapByUser = >{}; + for (final entry in pending) { + final user = (entry.suggestedBy ?? '').trim().isEmpty + ? 'Unknown' + : entry.suggestedBy!.trim(); + pendingByUser.putIfAbsent(user, () => []).add(entry); + final start = _effectiveStart(entry); + if (start == null) continue; + if (approvedStartKeys.contains(_startKey(start))) { + overlapByUser.putIfAbsent(user, () => []).add(entry); + } + } + + final hasOverlap = overlapByUser.isNotEmpty; + final canToggle = pending.length > 1 && !hasOverlap; + final isExpanded = expandedAttrCodes.contains(attr); + + final nonOverlapPending = + pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList(); + final baseEntries = isExpanded ? approved : [...approved, ...nonOverlapPending]; + final baseSegments = + isExpanded ? approvedSegments : _segmentsForEntries(baseEntries, now); + + rowSpecs.add( + _TimelineRowSpec.primary( + attrCode: attr, + segments: baseSegments, + showExpandToggle: canToggle, + isExpanded: isExpanded, + ), ); - final segments = <_ValueSegment>[]; - for (int i = 0; i < items.length; i++) { - final entry = items[i]; - final start = _effectiveStart(entry) ?? now; - final nextStart = i < items.length - 1 - ? _effectiveStart(items[i + 1]) - : null; - final rawEnd = entry.validTo ?? nextStart ?? now; - final end = _safeEnd(start, rawEnd); - segments.add( - _ValueSegment( - start: start, - end: end, - value: _formatValueWithUnits(entry), - entry: entry, - ), - ); - minStart = minStart == null || start.isBefore(minStart!) - ? start - : minStart; - maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd; - } - attrSegments[attr] = segments; - }); + allSegments.addAll(baseSegments); - minStart ??= now.subtract(const Duration(days: 1)); - final effectiveMaxEnd = maxEnd ?? now; - - final boundaryDates = {}; - for (final segments in attrSegments.values) { - for (final seg in segments) { - boundaryDates.add(seg.start); - boundaryDates.add(seg.end); + final shouldShowPendingRows = isExpanded || hasOverlap; + if (shouldShowPendingRows) { + final users = isExpanded + ? pendingByUser.keys.toList() + : overlapByUser.keys.toList(); + users.sort(); + for (final user in users) { + final pendingEntries = isExpanded + ? (pendingByUser[user] ?? const []) + : (overlapByUser[user] ?? const []); + if (pendingEntries.isEmpty) continue; + final userPendingSegments = _segmentsForEntries(pendingEntries, now); + final combinedSegments = [ + ...approvedSegments, + ...userPendingSegments, + ]; + rowSpecs.add( + _TimelineRowSpec.pending( + attrCode: attr, + userLabel: user, + segments: combinedSegments, + ), + ); + allSegments.addAll(combinedSegments); + } } } - boundaryDates.add(effectiveMaxEnd); - var boundaries = boundaryDates.toList()..sort(); - if (boundaries.length < 2) { - boundaries = [minStart!, effectiveMaxEnd]; - } + + final boundaries = _buildBoundaries(allSegments, now); final axisSegments = <_AxisSegment>[]; const double yearWidth = 240.0; @@ -753,10 +975,10 @@ class _TimelineModel { final axisTotalWidth = axisSegments.fold(0, (sum, seg) => sum + seg.width); - final attrRows = >{}; - for (final entry in attrSegments.entries) { + final rows = <_TimelineRowData>[]; + for (final spec in rowSpecs) { final blocks = <_ValueBlock>[]; - for (final seg in entry.value) { + for (final seg in spec.segments) { final left = _positionForDate(seg.start, boundaries, axisSegments); final right = _positionForDate(seg.end, boundaries, axisSegments); final span = right - left; @@ -770,13 +992,24 @@ class _TimelineModel { ), ); } - attrRows[entry.key] = blocks; + rows.add( + _TimelineRowData( + id: spec.id, + attrCode: spec.attrCode, + blocks: blocks, + isPrimary: spec.isPrimary, + showExpandToggle: spec.showExpandToggle, + isExpanded: spec.isExpanded, + pendingUser: spec.userLabel, + ), + ); } - final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now'; + final endLabel = + boundaries.isNotEmpty ? _formatDate(boundaries.last) ?? 'Now' : 'Now'; return _TimelineModel( axisSegments: axisSegments, - attrRows: attrRows, + rows: rows, endLabel: endLabel, boundaries: boundaries, axisTotalWidth: axisTotalWidth,