new pending visibility
Some checks failed
Release / meta (push) Successful in 8s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled

This commit is contained in:
2026-01-12 16:03:47 +00:00
parent 5c0043146f
commit 45042b5001
2 changed files with 330 additions and 74 deletions

View File

@@ -11,6 +11,8 @@ class _TimelineGrid extends StatefulWidget {
this.onDeleteEntry,
this.onModeratePending,
this.pendingActionEventIds = const {},
this.expandedPendingAttrs = const {},
this.onTogglePendingAttr,
});
final List<LocoAttrVersion> entries;
@@ -21,6 +23,8 @@ class _TimelineGrid extends StatefulWidget {
_PendingModerationAction action,
)? onModeratePending;
final Set<int> pendingActionEventIds;
final Set<String> 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<int> approvedStartKeys) {
final start = _effectiveStart(entry);
if (start == null) return false;
return approvedStartKeys.contains(_startKey(start));
}
List<_ValueSegment> _segmentsForEntries(
List<LocoAttrVersion> 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<DateTime> _buildBoundaries(
List<_ValueSegment> segments,
DateTime now,
) {
DateTime? minStart;
DateTime? maxEnd;
final boundaryDates = <DateTime>{};
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<String, List<_ValueBlock>> attrRows;
final List<_TimelineRowData> rows;
final String endLabel;
final List<DateTime> 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<LocoAttrVersion> entries) {
factory _TimelineModel.fromEntries(
List<LocoAttrVersion> entries, {
Set<String> expandedAttrCodes = const {},
}) {
final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null)
.toList();
final grouped = <String, List<LocoAttrVersion>>{};
final attrOrder = <String>[];
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 = <String, List<_ValueSegment>>{};
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 = <int>{};
for (final entry in approved) {
final start = _effectiveStart(entry);
if (start == null) continue;
approvedStartKeys.add(_startKey(start));
}
final pendingByUser = <String, List<LocoAttrVersion>>{};
final overlapByUser = <String, List<LocoAttrVersion>>{};
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 = <DateTime>{};
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<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{};
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,