diff --git a/packages/z-flutter/packages/z_timeline/lib/src/constants.dart b/packages/z-flutter/packages/z_timeline/lib/src/constants.dart index 6fa6faa..cde688e 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/constants.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/constants.dart @@ -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 diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/projected_entry.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/projected_entry.dart deleted file mode 100644 index 68c2929..0000000 --- a/packages/z-flutter/packages/z_timeline/lib/src/models/projected_entry.dart +++ /dev/null @@ -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)'; -} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/services/layout_coordinate_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/layout_coordinate_service.dart index 02d2adf..e7d6e49 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/services/layout_coordinate_service.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/services/layout_coordinate_service.dart @@ -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. diff --git a/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_group_registry.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_group_registry.dart index 25db523..d98cf00 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_group_registry.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_group_registry.dart @@ -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; diff --git a/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_projection_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_projection_service.dart deleted file mode 100644 index 8e84d22..0000000 --- a/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_projection_service.dart +++ /dev/null @@ -1,42 +0,0 @@ -import '../models/timeline_entry.dart'; -import '../models/projected_entry.dart'; -import 'time_scale_service.dart'; - -class TimelineProjectionService { - const TimelineProjectionService(); - - Map> project({ - required Iterable entries, - required DateTime domainStart, - required DateTime domainEnd, - }) { - final byGroup = >{}; - 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] ??= []).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; - } -} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart index a56fd02..ba42a89 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart @@ -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 createState() => _EventPillState(); +} + +class _EventPillState extends State { + 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( - padding: ZTimelineConstants.pillPadding, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular( - ZTimelineConstants.pillBorderRadius, + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Container( + clipBehavior: Clip.antiAlias, + padding: ZTimelineConstants.pillPadding, + decoration: BoxDecoration( + 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: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: onColor, - fontWeight: FontWeight.w400, + 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( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + 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); diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart index d8917ef..9b4ced2 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart @@ -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 createState() => _InteractiveEventPillState(); @@ -69,17 +73,33 @@ class _InteractiveEventPillState extends State { /// 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 { 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 { _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 { 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,107 +306,46 @@ class _InteractiveEventPillState extends State { @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; - - final maxTextWidth = availableWidth - - ZTimelineConstants.pointEventCircleDiameter - - ZTimelineConstants.pointEventCircleTextGap - - ZTimelineConstants.pointEventTextGap; - - final pointWidget = EventPoint( - color: color, - label: label, - maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0, - isSelected: isSelected, - ); - - if (!widget.enableDrag) { - return Positioned( - top: top, - left: left.clamp(0.0, double.infinity), - width: availableWidth.clamp( - ZTimelineConstants.pointEventCircleDiameter, - double.infinity, - ), - height: widget.laneHeight, - child: Align(alignment: Alignment.centerLeft, child: pointWidget), - ); - } - - final scope = ZTimelineScope.of(context); - - return Positioned( - top: top, - left: left.clamp(0.0, double.infinity), - width: availableWidth.clamp( - ZTimelineConstants.pointEventCircleDiameter, - double.infinity, - ), - 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; - - return Opacity( - opacity: isDragging ? 0.3 : 1.0, - child: child, - ); - }, - child: _buildInteractivePoint(pointWidget), - ), - ); + return _buildPointEntry(entry, color, label, isSelected, top); } - // Range event (hasEnd == true) + 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 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; + entry.hasEnd && widget.onEntryResized != null; - return Positioned( - top: top, - left: left.clamp(0.0, double.infinity), - width: width, - height: widget.laneHeight, - child: ListenableBuilder( + Widget content; + if (!widget.enableDrag) { + content = pill; + } else { + final scope = ZTimelineScope.of(context); + content = ListenableBuilder( listenable: scope.interaction, builder: (context, child) { - final entryId = widget.entry.entry.id; + final entryId = entry.id; final isDragging = scope.interaction.isDraggingEntry && scope.interaction.dragState?.entryId == entryId; @@ -400,7 +359,130 @@ class _InteractiveEventPillState extends State { ); }, 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 - + ZTimelineConstants.pointEventCircleTextGap - + ZTimelineConstants.pointEventTextGap; + + final pointWidget = EventPoint( + color: color, + label: label, + maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0, + isSelected: isSelected, + ); + + if (!widget.enableDrag) { + return Positioned( + top: top, + left: left, + width: availableWidth.clamp( + ZTimelineConstants.pointEventCircleDiameter, + double.infinity, + ), + height: widget.laneHeight, + child: Align( + alignment: Alignment.centerLeft, + child: pointWidget, + ), + ); + } + + return Positioned( + top: top, + left: left, + width: availableWidth.clamp( + ZTimelineConstants.pointEventCircleDiameter, + double.infinity, + ), + height: widget.laneHeight, + child: ListenableBuilder( + listenable: scope!.interaction, + builder: (context, child) { + final entryId = entry.id; + final isDragging = scope!.interaction.isDraggingEntry && + scope!.interaction.dragState?.entryId == entryId; + + return Opacity( + opacity: isDragging ? 0.3 : 1.0, + child: child, + ); + }, + child: _buildInteractivePoint(pointWidget), + ), + ); + }, ); } @@ -410,7 +492,7 @@ class _InteractiveEventPillState extends State { 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 { 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 { 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 { 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 { 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 { 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 { 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, ), diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart index 25dbe07..8e0fd7d 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart @@ -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,52 +85,71 @@ 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 = >{}; + 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); + }); + } - return LayoutBuilder( - builder: (context, constraints) { - final contentWidth = constraints.maxWidth.isFinite - ? constraints.maxWidth - : ZTimelineConstants.minContentWidth; + // Pre-compute next-entry-in-lane lookup in O(n). + final nextEntryInLane = {}; + for (final list in grouped.values) { + final lastInLane = {}; + for (final entry in list) { + final prev = lastInLane[entry.lane]; + if (prev != null) { + nextEntryInLane[prev.id] = entry; + } + lastInLane[entry.lane] = entry; + } + } - return ListView.builder( - itemCount: groups.length, - itemBuilder: (context, index) { - final group = groups[index]; - final groupEntries = - projected[group.id] ?? const []; - final lanesCount = _countLanes(groupEntries); + return LayoutBuilder( + builder: (context, constraints) { + final contentWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : ZTimelineConstants.minContentWidth; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _GroupHeader(title: group.title, height: groupHeaderHeight), - _GroupLanes( - group: group, - entries: groupEntries, - allEntries: entries, - viewport: viewport, - lanesCount: lanesCount, - laneHeight: laneHeight, - colorBuilder: colorBuilder, - labelBuilder: labelBuilder, - contentWidth: contentWidth, - enableDrag: enableDrag, - onEntryResized: onEntryResized, - onEntryMoved: onEntryMoved, - onEntrySelected: onEntrySelected, - selectedEntryId: selectedEntryId, - ), - ], - ); - }, + return ListView.builder( + itemCount: groups.length, + itemBuilder: (context, index) { + final group = groups[index]; + final groupEntries = + grouped[group.id] ?? const []; + final lanesCount = _countLanes(groupEntries); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _GroupHeader(title: group.title, height: groupHeaderHeight), + RepaintBoundary( + child: _GroupLanes( + group: group, + entries: groupEntries, + allEntries: entries, + viewport: viewport, + lanesCount: lanesCount, + laneHeight: laneHeight, + colorBuilder: colorBuilder, + labelBuilder: labelBuilder, + contentWidth: contentWidth, + enableDrag: enableDrag, + onEntryResized: onEntryResized, + onEntryMoved: onEntryMoved, + onEntrySelected: onEntrySelected, + selectedEntryId: selectedEntryId, + nextEntryInLane: nextEntryInLane, + groupHeaderHeight: groupHeaderHeight, + ), + ), + ], ); }, ); @@ -136,10 +157,10 @@ class ZTimelineView extends StatelessWidget { ); } - int _countLanes(List entries) { + int _countLanes(List 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 entries; + final List entries; final List 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 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 = [ + 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,103 +352,25 @@ 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 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 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, _) { - final dragState = scope.interaction.dragState; - final resizeState = scope.interaction.resizeState; - - // Show ghost for resize (entry stays in its own group) - if (resizeState != null && - resizeState.originalEntry.groupId == widget.group.id) { - return DragPreview( - targetStart: resizeState.targetStart, - targetEnd: resizeState.targetEnd, - targetLane: resizeState.targetLane, - viewport: widget.viewport, - contentWidth: widget.contentWidth, - laneHeight: widget.laneHeight, - color: widget.colorBuilder(resizeState.originalEntry), - label: widget.labelBuilder(resizeState.originalEntry), - hasEnd: resizeState.originalEntry.hasEnd, - ); - } - - // Show ghost for drag-move - if (dragState != null && - dragState.targetGroupId == widget.group.id) { - return DragPreview( - targetStart: dragState.targetStart, - targetEnd: dragState.targetEnd, - targetLane: dragState.targetLane, - viewport: widget.viewport, - contentWidth: widget.contentWidth, - laneHeight: widget.laneHeight, - color: widget.colorBuilder(dragState.originalEntry), - label: widget.labelBuilder(dragState.originalEntry), - hasEnd: dragState.originalEntry.hasEnd, - ); - } - - return const SizedBox.shrink(); - }, - ), + if (widget.enableDrag && scope != null) _buildDragPreview(scope), ], ), ); @@ -416,4 +388,42 @@ class _GroupLanesState extends State<_GroupLanes> { ), ); } + + Widget _buildDragPreview(ZTimelineScopeData scope) { + final dragState = scope.interaction.dragState; + final resizeState = scope.interaction.resizeState; + + // Show ghost for resize (entry stays in its own group) + if (resizeState != null && + resizeState.originalEntry.groupId == widget.group.id) { + return DragPreview( + targetStart: resizeState.targetStart, + targetEnd: resizeState.targetEnd, + targetLane: resizeState.targetLane, + viewport: widget.viewport, + contentWidth: widget.contentWidth, + laneHeight: widget.laneHeight, + color: widget.colorBuilder(resizeState.originalEntry), + label: widget.labelBuilder(resizeState.originalEntry), + hasEnd: resizeState.originalEntry.hasEnd, + ); + } + + // Show ghost for drag-move + if (dragState != null && dragState.targetGroupId == widget.group.id) { + return DragPreview( + targetStart: dragState.targetStart, + targetEnd: dragState.targetEnd, + targetLane: dragState.targetLane, + viewport: widget.viewport, + contentWidth: widget.contentWidth, + laneHeight: widget.laneHeight, + color: widget.colorBuilder(dragState.originalEntry), + label: widget.labelBuilder(dragState.originalEntry), + hasEnd: dragState.originalEntry.hasEnd, + ); + } + + return const SizedBox.shrink(); + } } diff --git a/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart b/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart index 9d7f4c9..41fbb09 100644 --- a/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart +++ b/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart @@ -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'; diff --git a/packages/z-flutter/packages/z_timeline/test/helpers/test_factories.dart b/packages/z-flutter/packages/z_timeline/test/helpers/test_factories.dart index ab45032..21cf977 100644 --- a/packages/z-flutter/packages/z_timeline/test/helpers/test_factories.dart +++ b/packages/z-flutter/packages/z_timeline/test/helpers/test_factories.dart @@ -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, diff --git a/packages/z-flutter/packages/z_timeline/test/widgets/interactive_event_pill_test.dart b/packages/z-flutter/packages/z_timeline/test/widgets/interactive_event_pill_test.dart index 31abf4e..0b9a298 100644 --- a/packages/z-flutter/packages/z_timeline/test/widgets/interactive_event_pill_test.dart +++ b/packages/z-flutter/packages/z_timeline/test/widgets/interactive_event_pill_test.dart @@ -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 pumpPill( WidgetTester tester, { - ProjectedEntry? entry, + TimelineEntry? entry, bool enableDrag = true, List 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( - find.ancestor( - of: find.byType(EventPill), - matching: find.byType(Positioned), - ).first, + find + .ancestor( + of: find.byType(EventPill), + matching: find.byType(Positioned), + ) + .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', () { + // 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 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; }, @@ -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(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(find.byType(Opacity));