pill design and performance

This commit is contained in:
2026-03-15 19:32:33 +01:00
parent c39dcb6164
commit 6c872624d2
11 changed files with 516 additions and 448 deletions

View File

@@ -4,8 +4,20 @@ import 'package:flutter/material.dart';
class ZTimelineConstants {
const ZTimelineConstants._();
// Pill body
static const double pillInnerBorderWidth = 1.0;
static const double pillContentHeight = 16.0; // labelMedium line height
// Computed pill height (single source of truth for lane height)
// pillPadding.vertical (6.0 * 2 = 12.0) inlined because EdgeInsets
// getters are not compile-time constants.
static const double pillHeight =
pillInnerBorderWidth * 2 +
12.0 + // pillPadding.vertical
pillContentHeight; // = 30.0
// Heights
static const double laneHeight = 28.0;
static const double laneHeight = pillHeight;
static const double groupHeaderHeight = 34.0;
// Spacing

View File

@@ -1,37 +0,0 @@
import 'package:flutter/foundation.dart';
import 'timeline_entry.dart';
/// Represents a projected entry on the timeline.
/// This is used to represent an entry on the timeline in a normalized space.
/// The startX and endX are normalized in [0, 1] and represent the position of
/// the entry on the timeline.
/// The widthX is the width of the entry in the normalized space.
@immutable
class ProjectedEntry {
const ProjectedEntry({
required this.entry,
required this.startX,
required this.endX,
}) : assert(startX <= endX, 'Projected startX must be <= endX');
final TimelineEntry entry;
final double startX; // normalized in [0, 1]
final double endX; // normalized in [0, 1]
double get widthX => (endX - startX).clamp(0.0, 1.0);
@override
int get hashCode => Object.hash(entry, startX, endX);
@override
bool operator ==(Object other) {
return other is ProjectedEntry &&
other.entry == entry &&
other.startX == startX &&
other.endX == endX;
}
@override
String toString() =>
'ProjectedEntry(entry: ${entry.id}, startX: $startX, endX: $endX)';
}

View File

@@ -10,7 +10,7 @@ import '../constants.dart';
/// The timeline uses two coordinate spaces:
///
/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain.
/// Used by [TimeScaleService] and stored in [ProjectedEntry].
/// Computed via [TimeScaleService].
///
/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline.
/// What gets passed to [Positioned] widgets.

View File

@@ -21,6 +21,7 @@ class _GroupRegistration {
required this.lanesCount,
required this.laneHeight,
required this.contentWidth,
this.headerHeight = 0,
});
final GlobalKey key;
@@ -28,6 +29,7 @@ class _GroupRegistration {
final int lanesCount;
final double laneHeight;
final double contentWidth;
final double headerHeight;
}
/// Registry for timeline group lane areas, enabling cross-group hit detection.
@@ -46,6 +48,7 @@ class TimelineGroupRegistry {
required int lanesCount,
required double laneHeight,
required double contentWidth,
double headerHeight = 0,
}) {
_groups[groupId] = _GroupRegistration(
key: key,
@@ -53,6 +56,7 @@ class TimelineGroupRegistry {
lanesCount: lanesCount,
laneHeight: laneHeight,
contentWidth: contentWidth,
headerHeight: headerHeight,
);
}
@@ -78,15 +82,20 @@ class TimelineGroupRegistry {
// Check horizontal bounds
if (local.dx < 0 || local.dx > reg.contentWidth) continue;
// Check vertical bounds: allow one extra lane for expansion
// Check vertical bounds: extend upward by headerHeight to cover
// the group header dead zone, and allow one extra lane for expansion.
if (adjustedY < -reg.headerHeight) continue;
final maxLanes = (reg.lanesCount <= 0 ? 1 : reg.lanesCount) + 1;
final maxY =
maxLanes * (reg.laneHeight + ZTimelineConstants.laneVerticalSpacing);
if (adjustedY < 0 || adjustedY > maxY) continue;
if (adjustedY > maxY) continue;
// Clamp negative values (pointer over header) to top of lanes area.
final clampedY = adjustedY < 0 ? 0.0 : adjustedY;
return GroupHitResult(
groupId: entry.key,
localPosition: Offset(local.dx, adjustedY),
localPosition: Offset(local.dx, clampedY),
);
}
return null;

View File

@@ -1,42 +0,0 @@
import '../models/timeline_entry.dart';
import '../models/projected_entry.dart';
import 'time_scale_service.dart';
class TimelineProjectionService {
const TimelineProjectionService();
Map<String, List<ProjectedEntry>> project({
required Iterable<TimelineEntry> entries,
required DateTime domainStart,
required DateTime domainEnd,
}) {
final byGroup = <String, List<ProjectedEntry>>{};
for (final e in entries) {
if (e.overlaps(domainStart, domainEnd)) {
final startX = TimeScaleService.mapTimeToPosition(
e.start.isBefore(domainStart) ? domainStart : e.start,
domainStart,
domainEnd,
).clamp(0.0, 1.0);
final endX = TimeScaleService.mapTimeToPosition(
e.end.isAfter(domainEnd) ? domainEnd : e.end,
domainStart,
domainEnd,
).clamp(0.0, 1.0);
final pe = ProjectedEntry(entry: e, startX: startX, endX: endX);
(byGroup[e.groupId] ??= <ProjectedEntry>[]).add(pe);
}
}
// Keep original order stable by lane then startX
for (final list in byGroup.values) {
list.sort((a, b) {
final laneCmp = a.entry.lane.compareTo(b.entry.lane);
if (laneCmp != 0) return laneCmp;
return a.startX.compareTo(b.startX);
});
}
return byGroup;
}
}

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/widget_previews.dart';
import '../constants.dart';
/// A standalone event pill widget with entry color, rounded corners,
/// and auto text color based on brightness.
class EventPill extends StatelessWidget {
/// A standalone event pill widget with a tinted gradient background,
/// colored border, indicator dot, and an outer selection ring.
class EventPill extends StatefulWidget {
const EventPill({
required this.color,
required this.label,
@@ -16,36 +16,101 @@ class EventPill extends StatelessWidget {
final String label;
final bool isSelected;
@override
State<EventPill> createState() => _EventPillState();
}
class _EventPillState extends State<EventPill> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final onColor =
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
? Colors.white
: Colors.black87;
final color = widget.color;
final isSelected = widget.isSelected;
final primaryColor = Theme.of(context).colorScheme.primary;
final double bgAlphaStart =
isSelected ? 0.24 : _isHovered ? 0.18 : 0.08;
final double bgAlphaEnd =
isSelected ? 0.34 : _isHovered ? 0.28 : 0.16;
return Container(
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Container(
clipBehavior: Clip.antiAlias,
padding: ZTimelineConstants.pillPadding,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(
ZTimelineConstants.pillBorderRadius,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: bgAlphaStart),
color.withValues(alpha: bgAlphaEnd),
],
),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: color,
width: ZTimelineConstants.pillInnerBorderWidth,
),
border: isSelected
? Border.all(color: primaryColor, width: 2)
: null,
),
alignment: Alignment.centerLeft,
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 20) return const SizedBox.shrink();
return Row(
children: [
if (isSelected)
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: color),
),
alignment: Alignment.center,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle),
),
)
else
Container(
width: 8,
height: 8,
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: onColor,
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w400,
),
),
),
],
);
},
),
),
);
}
}
@Preview(name: 'Event pill', brightness: Brightness.light)
@Preview(name: 'Event pill', brightness: Brightness.dark)
Widget eventPillPreview() =>
EventPill(color: Colors.blue, label: 'Sample Event', isSelected: false);
@Preview(name: 'Event pill - selected', brightness: Brightness.light)
@Preview(name: 'Event pill - selected', brightness: Brightness.dark)
Widget eventPillSelectedPreview() =>
EventPill(color: Colors.blue, label: 'Sample Event', isSelected: true);

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import '../constants.dart';
import '../models/entry_resize_state.dart';
import '../models/projected_entry.dart';
import '../models/timeline_entry.dart';
import '../services/entry_placement_service.dart';
import '../services/layout_coordinate_service.dart';
@@ -19,6 +18,11 @@ enum _InteractionMode { resizeStart, resizeEnd, drag }
/// An interactive event pill that handles both resize and drag-move
/// with a single [GestureDetector] using pan gestures.
///
/// Each pill listens to the [viewport] internally via [AnimatedBuilder],
/// so only the [Positioned] wrapper updates during pan — the visual
/// content ([EventPill] / [EventPoint]) is in the `child:` slot and
/// is NOT rebuilt on every frame.
class InteractiveEventPill extends StatefulWidget {
const InteractiveEventPill({
required this.entry,
@@ -34,11 +38,11 @@ class InteractiveEventPill extends StatefulWidget {
this.onEntrySelected,
this.selectedEntryId,
this.groupRegistry,
this.nextEntryStartX,
this.nextEntryInLane,
super.key,
});
final ProjectedEntry entry;
final TimelineEntry entry;
final double laneHeight;
final EntryLabelBuilder labelBuilder;
final EntryColorBuilder colorBuilder;
@@ -52,9 +56,9 @@ class InteractiveEventPill extends StatefulWidget {
final String? selectedEntryId;
final TimelineGroupRegistry? groupRegistry;
/// Normalized startX of the next entry in the same lane, used to compute
/// available width for point events.
final double? nextEntryStartX;
/// Next entry in the same lane, used to compute available width for
/// point events.
final TimelineEntry? nextEntryInLane;
@override
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
@@ -69,17 +73,33 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
/// so dragging doesn't snap the entry start to the cursor.
double _grabOffsetNormalized = 0;
double get _pillWidth {
/// Compute pill width from an entry's start/end mapped through the current
/// viewport. Shared by [_currentPillWidth] and [_buildRangeEntry] to avoid
/// drift between the two code paths.
double _pillWidthFromEntry(TimelineEntry entry) {
final startX = TimeScaleService.mapTimeToPosition(
entry.start,
widget.viewport.start,
widget.viewport.end,
);
final endX = TimeScaleService.mapTimeToPosition(
entry.end,
widget.viewport.start,
widget.viewport.end,
);
return LayoutCoordinateService.calculateItemWidth(
normalizedWidth: widget.entry.widthX,
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
contentWidth: widget.contentWidth,
).clamp(0.0, double.infinity);
);
}
_InteractionMode _determineMode(Offset localPosition) {
if (!widget.entry.entry.hasEnd) return _InteractionMode.drag;
/// Compute current pill width from the raw entry + viewport.
double get _currentPillWidth => _pillWidthFromEntry(widget.entry);
final pillWidth = _pillWidth;
_InteractionMode _determineMode(Offset localPosition) {
if (!widget.entry.hasEnd) return _InteractionMode.drag;
final pillWidth = _currentPillWidth;
const handleWidth = ZTimelineConstants.resizeHandleWidth;
if (widget.onEntryResized != null && pillWidth >= 16) {
@@ -100,7 +120,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
void _onPanStart(DragStartDetails details) {
final scope = ZTimelineScope.of(context);
final entry = widget.entry.entry;
final entry = widget.entry;
_cumulativeDx = 0;
@@ -133,7 +153,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
_cumulativeDx += details.delta.dx;
final deltaNormalized = _cumulativeDx / widget.contentWidth;
final originalEntry = widget.entry.entry;
final originalEntry = widget.entry;
final edge = _mode == _InteractionMode.resizeStart
? ResizeEdge.start
: ResizeEdge.end;
@@ -222,7 +242,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
final targetLane = rawLane.clamp(1, maxAllowedLane);
final resolved = EntryPlacementService.resolvePlacement(
entry: widget.entry.entry,
entry: widget.entry,
targetGroupId: hit.groupId,
targetLane: targetLane,
targetStart: targetStart,
@@ -286,27 +306,130 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
@override
Widget build(BuildContext context) {
final isPoint = !widget.entry.entry.hasEnd;
final color = widget.colorBuilder(widget.entry.entry);
final label = widget.labelBuilder(widget.entry.entry);
final isSelected = widget.entry.entry.id == widget.selectedEntryId;
final entry = widget.entry;
final isPoint = !entry.hasEnd;
final color = widget.colorBuilder(entry);
final label = widget.labelBuilder(entry);
final isSelected = entry.id == widget.selectedEntryId;
final top = LayoutCoordinateService.laneToY(
lane: widget.entry.entry.lane,
lane: entry.lane,
laneHeight: widget.laneHeight,
);
final left = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: widget.entry.startX,
contentWidth: widget.contentWidth,
);
if (isPoint) {
// Available width from this entry to the next in the same lane
// (or to the right edge of the content area).
final availableWidth = widget.nextEntryStartX != null
? (widget.nextEntryStartX! - widget.entry.startX) *
widget.contentWidth
: (1.0 - widget.entry.startX) * widget.contentWidth;
return _buildPointEntry(entry, color, label, isSelected, top);
}
return _buildRangeEntry(entry, color, label, isSelected, top);
}
/// Range event: EventPill goes in the AnimatedBuilder child: slot
/// and is NOT rebuilt during pan — only the Positioned wrapper updates.
Widget _buildRangeEntry(
TimelineEntry entry,
Color color,
String label,
bool isSelected,
double top,
) {
final pill = EventPill(color: color, label: label, isSelected: isSelected);
final hasResizeHandles =
entry.hasEnd && widget.onEntryResized != null;
Widget content;
if (!widget.enableDrag) {
content = pill;
} else {
final scope = ZTimelineScope.of(context);
content = ListenableBuilder(
listenable: scope.interaction,
builder: (context, child) {
final entryId = entry.id;
final isDragging =
scope.interaction.isDraggingEntry &&
scope.interaction.dragState?.entryId == entryId;
final isResizing =
scope.interaction.isResizingEntry &&
scope.interaction.resizeState?.entryId == entryId;
return Opacity(
opacity: isDragging || isResizing ? 0.3 : 1.0,
child: child,
);
},
child: _buildInteractivePill(pill, hasResizeHandles),
);
}
return AnimatedBuilder(
animation: widget.viewport,
builder: (context, child) {
final startX = TimeScaleService.mapTimeToPosition(
entry.start,
widget.viewport.start,
widget.viewport.end,
);
final left = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: startX,
contentWidth: widget.contentWidth,
);
final width = _pillWidthFromEntry(entry);
return Positioned(
top: top,
left: left,
width: width,
height: widget.laneHeight,
child: child!,
);
},
child: content,
);
}
/// Point event: maxTextWidth depends on viewport, so EventPoint is
/// built inside the AnimatedBuilder. Point events are typically few,
/// so this has negligible cost.
Widget _buildPointEntry(
TimelineEntry entry,
Color color,
String label,
bool isSelected,
double top,
) {
// For point events we need to rebuild EventPoint in the builder because
// maxTextWidth depends on the viewport position of the next entry.
//
// Hoist scope lookup before AnimatedBuilder to avoid re-looking it up on
// every animation frame (InheritedWidget lookup is cheap but unnecessary).
final scope = widget.enableDrag ? ZTimelineScope.of(context) : null;
return AnimatedBuilder(
animation: widget.viewport,
builder: (context, _) {
final startX = TimeScaleService.mapTimeToPosition(
entry.start,
widget.viewport.start,
widget.viewport.end,
);
double? nextStartX;
if (widget.nextEntryInLane != null) {
nextStartX = TimeScaleService.mapTimeToPosition(
widget.nextEntryInLane!.start,
widget.viewport.start,
widget.viewport.end,
);
}
final left = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: startX,
contentWidth: widget.contentWidth,
);
final availableWidth = nextStartX != null
? (nextStartX - startX) * widget.contentWidth
: (1.0 - startX) * widget.contentWidth;
final maxTextWidth = availableWidth -
ZTimelineConstants.pointEventCircleDiameter -
@@ -323,32 +446,33 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
if (!widget.enableDrag) {
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
left: left,
width: availableWidth.clamp(
ZTimelineConstants.pointEventCircleDiameter,
double.infinity,
),
height: widget.laneHeight,
child: Align(alignment: Alignment.centerLeft, child: pointWidget),
child: Align(
alignment: Alignment.centerLeft,
child: pointWidget,
),
);
}
final scope = ZTimelineScope.of(context);
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
left: left,
width: availableWidth.clamp(
ZTimelineConstants.pointEventCircleDiameter,
double.infinity,
),
height: widget.laneHeight,
child: ListenableBuilder(
listenable: scope.interaction,
listenable: scope!.interaction,
builder: (context, child) {
final entryId = widget.entry.entry.id;
final isDragging = scope.interaction.isDraggingEntry &&
scope.interaction.dragState?.entryId == entryId;
final entryId = entry.id;
final isDragging = scope!.interaction.isDraggingEntry &&
scope!.interaction.dragState?.entryId == entryId;
return Opacity(
opacity: isDragging ? 0.3 : 1.0,
@@ -358,49 +482,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
child: _buildInteractivePoint(pointWidget),
),
);
}
// Range event (hasEnd == true)
final pill = EventPill(color: color, label: label, isSelected: isSelected);
final width = _pillWidth;
if (!widget.enableDrag) {
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
width: width,
height: widget.laneHeight,
child: pill,
);
}
final scope = ZTimelineScope.of(context);
final hasResizeHandles =
widget.entry.entry.hasEnd && widget.onEntryResized != null && width >= 16;
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
width: width,
height: widget.laneHeight,
child: ListenableBuilder(
listenable: scope.interaction,
builder: (context, child) {
final entryId = widget.entry.entry.id;
final isDragging =
scope.interaction.isDraggingEntry &&
scope.interaction.dragState?.entryId == entryId;
final isResizing =
scope.interaction.isResizingEntry &&
scope.interaction.resizeState?.entryId == entryId;
return Opacity(
opacity: isDragging || isResizing ? 0.3 : 1.0,
child: child,
);
},
child: _buildInteractivePill(pill, hasResizeHandles),
),
);
}
@@ -410,7 +492,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
final topLeft = box.localToGlobal(Offset.zero);
final rect = topLeft & box.size;
final scope = ZTimelineScope.of(context);
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
scope.interaction.setHoveredEntry(widget.entry.id, rect);
}
void _onHoverExit() {
@@ -425,7 +507,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
final topLeft = box.localToGlobal(Offset.zero);
final rect = topLeft & box.size;
final scope = ZTimelineScope.of(context);
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
scope.interaction.setHoveredEntry(widget.entry.id, rect);
}
Widget _buildInteractivePoint(Widget pointWidget) {
@@ -440,7 +522,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
onTap: () => widget.onEntrySelected?.call(widget.entry),
onPanDown: _onPanDown,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
@@ -460,7 +542,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
onTap: () => widget.onEntrySelected?.call(widget.entry),
onPanDown: _onPanDown,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
@@ -476,7 +558,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
top: 0,
bottom: 0,
width: ZTimelineConstants.resizeHandleWidth,
child: MouseRegion(
child: const MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
opaque: false,
),
@@ -488,7 +570,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
top: 0,
bottom: 0,
width: ZTimelineConstants.resizeHandleWidth,
child: MouseRegion(
child: const MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
opaque: false,
),
@@ -500,14 +582,14 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
right: ZTimelineConstants.resizeHandleWidth,
top: 0,
bottom: 0,
child: MouseRegion(
child: const MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
),
)
else
Positioned.fill(
child: MouseRegion(
child: const MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
),

View File

@@ -3,10 +3,8 @@ import 'package:flutter/material.dart';
import '../constants.dart';
import '../models/entry_drag_state.dart';
import '../models/entry_resize_state.dart';
import '../models/projected_entry.dart';
import '../models/timeline_entry.dart';
import '../models/timeline_group.dart';
import '../services/timeline_projection_service.dart';
import '../state/timeline_viewport_notifier.dart';
import 'drag_preview.dart';
import 'interactive_event_pill.dart';
@@ -38,6 +36,10 @@ typedef OnEntrySelected = void Function(TimelineEntry entry);
/// Base timeline view: renders groups with between-group headers and
/// lane rows containing event pills.
///
/// Viewport changes (pan/zoom) are handled by each pill's internal
/// [AnimatedBuilder] — the parent tree only rebuilds when [entries]
/// or [groups] change.
class ZTimelineView extends StatelessWidget {
const ZTimelineView({
super.key,
@@ -83,14 +85,31 @@ class ZTimelineView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: viewport,
builder: (context, _) {
final projected = const TimelineProjectionService().project(
entries: entries,
domainStart: viewport.start,
domainEnd: viewport.end,
);
// Group entries by groupId and sort by lane+start.
// Runs once per entries/groups change, NOT on every pan frame.
final grouped = <String, List<TimelineEntry>>{};
for (final e in entries) {
(grouped[e.groupId] ??= []).add(e);
}
for (final list in grouped.values) {
list.sort((a, b) {
final cmp = a.lane.compareTo(b.lane);
return cmp != 0 ? cmp : a.start.compareTo(b.start);
});
}
// Pre-compute next-entry-in-lane lookup in O(n).
final nextEntryInLane = <String, TimelineEntry>{};
for (final list in grouped.values) {
final lastInLane = <int, TimelineEntry>{};
for (final entry in list) {
final prev = lastInLane[entry.lane];
if (prev != null) {
nextEntryInLane[prev.id] = entry;
}
lastInLane[entry.lane] = entry;
}
}
return LayoutBuilder(
builder: (context, constraints) {
@@ -103,14 +122,15 @@ class ZTimelineView extends StatelessWidget {
itemBuilder: (context, index) {
final group = groups[index];
final groupEntries =
projected[group.id] ?? const <ProjectedEntry>[];
grouped[group.id] ?? const <TimelineEntry>[];
final lanesCount = _countLanes(groupEntries);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_GroupHeader(title: group.title, height: groupHeaderHeight),
_GroupLanes(
RepaintBoundary(
child: _GroupLanes(
group: group,
entries: groupEntries,
allEntries: entries,
@@ -125,6 +145,9 @@ class ZTimelineView extends StatelessWidget {
onEntryMoved: onEntryMoved,
onEntrySelected: onEntrySelected,
selectedEntryId: selectedEntryId,
nextEntryInLane: nextEntryInLane,
groupHeaderHeight: groupHeaderHeight,
),
),
],
);
@@ -132,14 +155,12 @@ class ZTimelineView extends StatelessWidget {
);
},
);
},
);
}
int _countLanes(List<ProjectedEntry> entries) {
int _countLanes(List<TimelineEntry> entries) {
var maxLane = 0;
for (final e in entries) {
if (e.entry.lane > maxLane) maxLane = e.entry.lane;
if (e.lane > maxLane) maxLane = e.lane;
}
return maxLane.clamp(0, 1000); // basic guard
}
@@ -182,6 +203,8 @@ class _GroupLanes extends StatefulWidget {
required this.colorBuilder,
required this.contentWidth,
required this.enableDrag,
required this.nextEntryInLane,
required this.groupHeaderHeight,
this.onEntryResized,
this.onEntryMoved,
this.onEntrySelected,
@@ -189,7 +212,7 @@ class _GroupLanes extends StatefulWidget {
});
final TimelineGroup group;
final List<ProjectedEntry> entries;
final List<TimelineEntry> entries;
final List<TimelineEntry> allEntries;
final TimelineViewportNotifier viewport;
final int lanesCount;
@@ -202,6 +225,8 @@ class _GroupLanes extends StatefulWidget {
final OnEntryMoved? onEntryMoved;
final OnEntrySelected? onEntrySelected;
final String? selectedEntryId;
final Map<String, TimelineEntry> nextEntryInLane;
final double groupHeaderHeight;
@override
State<_GroupLanes> createState() => _GroupLanesState();
@@ -249,6 +274,7 @@ class _GroupLanesState extends State<_GroupLanes> {
lanesCount: widget.lanesCount,
laneHeight: widget.laneHeight,
contentWidth: widget.contentWidth,
headerHeight: widget.groupHeaderHeight,
);
}
@@ -256,12 +282,36 @@ class _GroupLanesState extends State<_GroupLanes> {
Widget build(BuildContext context) {
final scope = ZTimelineScope.maybeOf(context);
// Build pill widgets once per build() call (stable between interaction
// changes). When the ListenableBuilder fires, the same widget objects
// are reused — Flutter matches by identity and skips rebuilding.
final pillWidgets = <Widget>[
for (final entry in widget.entries)
InteractiveEventPill(
key: ValueKey(entry.id),
entry: entry,
laneHeight: widget.laneHeight,
labelBuilder: widget.labelBuilder,
colorBuilder: widget.colorBuilder,
contentWidth: widget.contentWidth,
enableDrag: widget.enableDrag,
viewport: widget.viewport,
allEntries: widget.allEntries,
onEntryResized: widget.onEntryResized,
onEntryMoved: widget.onEntryMoved,
onEntrySelected: widget.onEntrySelected,
selectedEntryId: widget.selectedEntryId,
groupRegistry: scope?.groupRegistry,
nextEntryInLane: widget.nextEntryInLane[entry.id],
),
];
// If no scope (drag not enabled), use static height
if (scope == null || !widget.enableDrag) {
return _buildContent(context, widget.lanesCount);
return _wrapContent(pillWidgets, widget.lanesCount, null);
}
// Listen to interaction notifier for drag/resize state changes
// Listen to interaction only for height changes + drag preview.
return ListenableBuilder(
listenable: scope.interaction,
builder: (context, _) {
@@ -272,7 +322,7 @@ class _GroupLanesState extends State<_GroupLanes> {
groupId: widget.group.id,
);
return _buildContent(context, effectiveLanesCount);
return _wrapContent(pillWidgets, effectiveLanesCount, scope);
},
);
}
@@ -302,65 +352,44 @@ class _GroupLanesState extends State<_GroupLanes> {
return effective;
}
/// Finds the startX of the next entry in the same lane, or null if none.
double? _findNextEntryStartX(List<ProjectedEntry> entries, int index) {
final current = entries[index];
final lane = current.entry.lane;
final startX = current.startX;
double? closest;
for (var j = 0; j < entries.length; j++) {
if (j == index) continue;
final other = entries[j];
if (other.entry.lane != lane) continue;
if (other.startX <= startX) continue;
if (closest == null || other.startX < closest) {
closest = other.startX;
}
}
return closest;
}
Widget _buildContent(BuildContext context, int effectiveLanesCount) {
Widget _wrapContent(
List<Widget> pillWidgets,
int effectiveLanesCount,
ZTimelineScopeData? scope,
) {
final totalHeight =
effectiveLanesCount * widget.laneHeight +
(effectiveLanesCount > 0
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
: 0);
final scope = ZTimelineScope.maybeOf(context);
// The inner Stack with pills and ghost overlay
Widget innerStack = SizedBox(
final innerStack = SizedBox(
height: totalHeight,
width: double.infinity,
child: Stack(
children: [
// Event pills
for (var i = 0; i < widget.entries.length; i++)
InteractiveEventPill(
entry: widget.entries[i],
laneHeight: widget.laneHeight,
labelBuilder: widget.labelBuilder,
colorBuilder: widget.colorBuilder,
contentWidth: widget.contentWidth,
enableDrag: widget.enableDrag,
viewport: widget.viewport,
allEntries: widget.allEntries,
onEntryResized: widget.onEntryResized,
onEntryMoved: widget.onEntryMoved,
onEntrySelected: widget.onEntrySelected,
selectedEntryId: widget.selectedEntryId,
groupRegistry: scope?.groupRegistry,
nextEntryStartX: _findNextEntryStartX(
widget.entries,
i,
),
),
...pillWidgets,
// Ghost overlay for drag operations
if (widget.enableDrag && scope != null)
ListenableBuilder(
listenable: scope.interaction,
builder: (context, _) {
if (widget.enableDrag && scope != null) _buildDragPreview(scope),
],
),
);
return AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Padding(
key: _lanesKey,
padding: EdgeInsets.symmetric(
vertical: ZTimelineConstants.verticalOuterPadding,
),
child: innerStack,
),
);
}
Widget _buildDragPreview(ZTimelineScopeData scope) {
final dragState = scope.interaction.dragState;
final resizeState = scope.interaction.resizeState;
@@ -381,8 +410,7 @@ class _GroupLanesState extends State<_GroupLanes> {
}
// Show ghost for drag-move
if (dragState != null &&
dragState.targetGroupId == widget.group.id) {
if (dragState != null && dragState.targetGroupId == widget.group.id) {
return DragPreview(
targetStart: dragState.targetStart,
targetEnd: dragState.targetEnd,
@@ -397,23 +425,5 @@ class _GroupLanesState extends State<_GroupLanes> {
}
return const SizedBox.shrink();
},
),
],
),
);
return AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Padding(
key: _lanesKey,
padding: EdgeInsets.symmetric(
vertical: ZTimelineConstants.verticalOuterPadding,
),
child: innerStack,
),
);
}
}

View File

@@ -10,7 +10,6 @@ export 'src/models/entry_drag_state.dart';
export 'src/models/entry_resize_state.dart';
export 'src/models/interaction_config.dart';
export 'src/models/interaction_state.dart';
export 'src/models/projected_entry.dart';
export 'src/models/tier_config.dart';
export 'src/models/tier_section.dart';
export 'src/models/tiered_tick_data.dart';
@@ -25,8 +24,6 @@ export 'src/services/layout_coordinate_service.dart';
export 'src/services/tiered_tick_service.dart';
export 'src/services/time_scale_service.dart';
export 'src/services/timeline_group_registry.dart';
export 'src/services/timeline_projection_service.dart';
// State
export 'src/state/timeline_interaction_notifier.dart';
export 'src/state/timeline_viewport_notifier.dart';

View File

@@ -1,6 +1,5 @@
import 'package:z_timeline/src/models/entry_drag_state.dart';
import 'package:z_timeline/src/models/entry_resize_state.dart';
import 'package:z_timeline/src/models/projected_entry.dart';
import 'package:z_timeline/src/models/timeline_entry.dart';
import 'package:z_timeline/src/models/timeline_group.dart';
@@ -30,19 +29,6 @@ TimelineGroup makeGroup({String? id, String? title}) {
return TimelineGroup(id: id ?? 'group-1', title: title ?? 'Group 1');
}
/// Creates a [ProjectedEntry] with entry + normalized positions.
ProjectedEntry makeProjectedEntry({
TimelineEntry? entry,
double startX = 0.0,
double endX = 0.1,
}) {
return ProjectedEntry(
entry: entry ?? makeEntry(),
startX: startX,
endX: endX,
);
}
/// Creates an [EntryDragState] with sensible defaults.
EntryDragState makeDragState({
String? entryId,

View File

@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/constants.dart';
import 'package:z_timeline/src/models/entry_resize_state.dart';
import 'package:z_timeline/src/models/projected_entry.dart';
import 'package:z_timeline/src/models/timeline_entry.dart';
import 'package:z_timeline/src/services/layout_coordinate_service.dart';
import 'package:z_timeline/src/services/time_scale_service.dart';
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
import 'package:z_timeline/src/widgets/event_pill.dart';
import 'package:z_timeline/src/widgets/interactive_event_pill.dart';
@@ -23,10 +23,7 @@ void main() {
late TimelineViewportNotifier viewport;
setUp(() {
viewport = TimelineViewportNotifier(
start: kDomainStart,
end: kDomainEnd,
);
viewport = TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
});
tearDown(() {
@@ -36,18 +33,13 @@ void main() {
/// Helper to pump an InteractiveEventPill in a test harness.
Future<void> pumpPill(
WidgetTester tester, {
ProjectedEntry? entry,
TimelineEntry? entry,
bool enableDrag = true,
List<TimelineEntry> allEntries = const [],
void Function(TimelineEntry, DateTime, String, int)? onEntryMoved,
void Function(TimelineEntry, DateTime, DateTime, int)? onEntryResized,
}) async {
final projectedEntry = entry ??
makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.3,
);
final testEntry = entry ?? makeEntry();
await tester.pumpTimeline(
SizedBox(
@@ -56,7 +48,7 @@ void main() {
child: Stack(
children: [
InteractiveEventPill(
entry: projectedEntry,
entry: testEntry,
laneHeight: laneHeight,
labelBuilder: (e) => e.id,
colorBuilder: (_) => Colors.blue,
@@ -76,31 +68,39 @@ void main() {
group('rendering', () {
testWidgets('pill appears at correct position', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(lane: 2),
startX: 0.2,
endX: 0.5,
);
final testEntry = makeEntry(lane: 2);
await pumpPill(tester, entry: projEntry);
await pumpPill(tester, entry: testEntry);
final positioned = tester.widget<Positioned>(
find.ancestor(
find
.ancestor(
of: find.byType(EventPill),
matching: find.byType(Positioned),
).first,
)
.first,
);
final expectedTop = LayoutCoordinateService.laneToY(
lane: 2,
laneHeight: laneHeight,
);
final startX = TimeScaleService.mapTimeToPosition(
testEntry.start,
viewport.start,
viewport.end,
);
final endX = TimeScaleService.mapTimeToPosition(
testEntry.end,
viewport.start,
viewport.end,
);
final expectedLeft = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: 0.2,
normalizedX: startX,
contentWidth: contentWidth,
);
final expectedWidth = LayoutCoordinateService.calculateItemWidth(
normalizedWidth: 0.3, // 0.5 - 0.2
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
contentWidth: contentWidth,
);
@@ -174,14 +174,9 @@ void main() {
group('drag flow', () {
testWidgets('pan from center triggers beginDrag', (tester) async {
// Use a wide pill so center is clearly in drag zone
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
final testEntry = makeEntry();
await pumpPill(tester, entry: projEntry);
await pumpPill(tester, entry: testEntry);
final pillCenter = tester.getCenter(find.byType(EventPill));
@@ -202,17 +197,21 @@ void main() {
});
group('resize', () {
testWidgets('pan from left edge triggers resize start', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
// Resize tests need a pill wide enough for resize handles (>= 16px).
// With a 30-day viewport and 800px contentWidth, a 7-day entry gives
// ~186px — well above the 16px threshold.
TimelineEntry makeWideEntry() => makeEntry(
start: kAnchor,
end: kAnchor.add(const Duration(days: 7)),
);
testWidgets('pan from left edge triggers resize start', (tester) async {
final testEntry = makeWideEntry();
TimelineEntry? resizedEntry;
await pumpPill(
tester,
entry: projEntry,
entry: testEntry,
onEntryResized: (entry, newStart, newEnd, newLane) {
resizedEntry = entry;
},
@@ -233,16 +232,12 @@ void main() {
});
testWidgets('pan from right edge triggers resize end', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
final testEntry = makeWideEntry();
TimelineEntry? resizedEntry;
await pumpPill(
tester,
entry: projEntry,
entry: testEntry,
onEntryResized: (entry, newStart, newEnd, newLane) {
resizedEntry = entry;
},
@@ -261,20 +256,22 @@ void main() {
expect(resizedEntry, isNotNull);
});
testWidgets('narrow pill (< 16px) always uses drag mode', (
tester,
) async {
// Make a very narrow pill (< 16px)
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.1 + (15 / contentWidth), // ~15px wide
testWidgets('narrow pill (< 16px) always uses drag mode', (tester) async {
// Make a very narrow pill (< 16px) — start close to end
final narrowStart = kAnchor;
final viewDuration =
kDomainEnd.difference(kDomainStart).inMilliseconds;
// 15px out of 800px contentWidth → 15/800 = 0.01875 of normalized
final narrowDurationMs = (0.01875 * viewDuration).round();
final narrowEnd = narrowStart.add(
Duration(milliseconds: narrowDurationMs),
);
final testEntry = makeEntry(start: narrowStart, end: narrowEnd);
TimelineEntry? resizedEntry;
await pumpPill(
tester,
entry: projEntry,
entry: testEntry,
onEntryResized: (entry, newStart, newEnd, newLane) {
resizedEntry = entry;
},
@@ -302,18 +299,14 @@ void main() {
});
testWidgets('pill at 0.3 opacity during drag', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
await pumpPill(tester, entry: projEntry);
final testEntry = makeEntry();
await pumpPill(tester, entry: testEntry);
// Start a drag by triggering beginDrag on the notifier directly
final scope = ZTimelineScope.of(
tester.element(find.byType(InteractiveEventPill)),
);
scope.interaction.beginDrag(projEntry.entry);
scope.interaction.beginDrag(testEntry);
await tester.pump();
final opacity = tester.widget<Opacity>(find.byType(Opacity));
@@ -321,24 +314,17 @@ void main() {
});
testWidgets('pill at 0.3 opacity during resize', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
final testEntry = makeEntry();
await pumpPill(
tester,
entry: projEntry,
onEntryResized: (_, __, ___, ____) {},
entry: testEntry,
onEntryResized: (_, _, _, _) {},
);
final scope = ZTimelineScope.of(
tester.element(find.byType(InteractiveEventPill)),
);
scope.interaction.beginResize(
projEntry.entry,
ResizeEdge.end,
);
scope.interaction.beginResize(testEntry, ResizeEdge.end);
await tester.pump();
final opacity = tester.widget<Opacity>(find.byType(Opacity));