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 902f464..6fa6faa 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/constants.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/constants.dart @@ -19,6 +19,11 @@ class ZTimelineConstants { vertical: 6.0, ); + // Point events (hasEnd == false) + static const double pointEventCircleDiameter = 12.0; + static const double pointEventCircleTextGap = 6.0; + static const double pointEventTextGap = 8.0; + // Resize handles static const double resizeHandleWidth = 6.0; static const Duration minResizeDuration = Duration(hours: 1); diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/drag_preview.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/drag_preview.dart new file mode 100644 index 0000000..92c20b6 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/drag_preview.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../services/layout_coordinate_service.dart'; +import '../services/time_scale_service.dart'; +import '../state/timeline_viewport_notifier.dart'; +import 'event_pill.dart'; +import 'event_point.dart'; + +/// A preview overlay showing where an entry will land during drag/resize. +/// +/// Renders the actual [EventPill] or [EventPoint] at full opacity so the +/// preview looks identical to the real item. +class DragPreview extends StatelessWidget { + const DragPreview({ + required this.targetStart, + required this.targetEnd, + required this.targetLane, + required this.viewport, + required this.contentWidth, + required this.laneHeight, + required this.color, + required this.label, + this.hasEnd = true, + super.key, + }); + + final DateTime targetStart; + final DateTime targetEnd; + final int targetLane; + final TimelineViewportNotifier viewport; + final double contentWidth; + final double laneHeight; + final Color color; + final String label; + + /// Whether this is a range event. When false, renders [EventPoint]. + final bool hasEnd; + + @override + Widget build(BuildContext context) { + final startX = TimeScaleService.mapTimeToPosition( + targetStart, + viewport.start, + viewport.end, + ); + final top = LayoutCoordinateService.laneToY( + lane: targetLane, + laneHeight: laneHeight, + ); + final left = LayoutCoordinateService.normalizedToWidgetX( + normalizedX: startX, + contentWidth: contentWidth, + ); + + if (!hasEnd) { + return Positioned( + left: left.clamp(0.0, double.infinity), + top: top, + height: laneHeight, + child: IgnorePointer(child: EventPoint(color: color, label: label)), + ); + } + + final endX = TimeScaleService.mapTimeToPosition( + targetEnd, + viewport.start, + viewport.end, + ); + final width = LayoutCoordinateService.calculateItemWidth( + normalizedWidth: endX - startX, + contentWidth: contentWidth, + ); + + return Positioned( + left: left.clamp(0.0, double.infinity), + width: width.clamp(0.0, double.infinity), + top: top, + height: laneHeight, + child: IgnorePointer(child: EventPill(color: color, label: label)), + ); + } +} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_point.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_point.dart new file mode 100644 index 0000000..b38119b --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_point.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../constants.dart'; + +/// A point-event widget: small circle with a text label to its right. +/// +/// Used for entries with [TimelineEntry.hasEnd] == false. +class EventPoint extends StatelessWidget { + const EventPoint({ + required this.color, + required this.label, + this.maxTextWidth, + super.key, + }); + + final Color color; + final String label; + + /// Maximum width for the label text. When provided, text truncates with + /// ellipsis. When null the text sizes naturally. + final double? maxTextWidth; + + @override + Widget build(BuildContext context) { + final onColor = + ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : Colors.black87; + + final textWidget = Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: onColor, + fontWeight: FontWeight.w400, + ), + ); + + final showText = maxTextWidth == null || maxTextWidth! > 0; + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: ZTimelineConstants.pointEventCircleDiameter, + height: ZTimelineConstants.pointEventCircleDiameter, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + if (showText) ...[ + const SizedBox(width: ZTimelineConstants.pointEventCircleTextGap), + if (maxTextWidth != null) + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxTextWidth!), + child: textWidget, + ) + else + textWidget, + ], + ], + ); + } +} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/ghost_overlay.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/ghost_overlay.dart deleted file mode 100644 index 8df1ffa..0000000 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/ghost_overlay.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../services/layout_coordinate_service.dart'; -import '../services/time_scale_service.dart'; -import '../state/timeline_viewport_notifier.dart'; -import 'event_pill.dart'; - -/// A semi-transparent ghost overlay showing where an entry will land. -/// -/// Displayed during drag and resize operations to give visual feedback about -/// the target position. -class GhostOverlay extends StatelessWidget { - const GhostOverlay({ - required this.targetStart, - required this.targetEnd, - required this.targetLane, - required this.viewport, - required this.contentWidth, - required this.laneHeight, - this.title, - this.color, - this.label, - super.key, - }); - - final DateTime targetStart; - final DateTime targetEnd; - final int targetLane; - final TimelineViewportNotifier viewport; - final double contentWidth; - final double laneHeight; - final String? title; - - /// When provided, renders an [EventPill] with slight transparency instead of - /// the generic primary-colored box. Used for resize ghosts. - final Color? color; - - /// Label for the pill when [color] is provided. - final String? label; - - @override - Widget build(BuildContext context) { - final startX = TimeScaleService.mapTimeToPosition( - targetStart, - viewport.start, - viewport.end, - ); - final endX = TimeScaleService.mapTimeToPosition( - targetEnd, - viewport.start, - viewport.end, - ); - - // Use centralized coordinate service to ensure ghost matches pill layout - final left = LayoutCoordinateService.normalizedToWidgetX( - normalizedX: startX, - contentWidth: contentWidth, - ); - final width = LayoutCoordinateService.calculateItemWidth( - normalizedWidth: endX - startX, - contentWidth: contentWidth, - ); - final top = LayoutCoordinateService.laneToY( - lane: targetLane, - laneHeight: laneHeight, - ); - - return Positioned( - left: left.clamp(0.0, double.infinity), - width: width.clamp(0.0, double.infinity), - top: top, - height: laneHeight, - child: IgnorePointer( - child: color != null - ? Opacity( - opacity: 0.5, - child: EventPill(color: color!, label: label ?? ''), - ) - : _buildGenericGhost(context), - ), - ); - } - - Widget _buildGenericGhost(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - - return Container( - padding: ZTimelineConstants.pillPadding, - decoration: BoxDecoration( - color: scheme.primary.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular( - ZTimelineConstants.pillBorderRadius, - ), - border: Border.all( - color: scheme.primary.withValues(alpha: 0.6), - width: 2, - ), - ), - alignment: Alignment.centerLeft, - child: title != null - ? Text( - title!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: scheme.primary.withValues(alpha: 0.8), - ), - ) - : null, - ); - } -} 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 a23ec2f..98cdd9c 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 @@ -10,6 +10,7 @@ import '../services/time_scale_service.dart'; import '../services/timeline_group_registry.dart'; import '../state/timeline_viewport_notifier.dart'; import 'event_pill.dart'; +import 'event_point.dart'; import 'timeline_scope.dart'; import 'timeline_view.dart'; @@ -31,6 +32,7 @@ class InteractiveEventPill extends StatefulWidget { this.onEntryResized, this.onEntryMoved, this.groupRegistry, + this.nextEntryStartX, super.key, }); @@ -46,11 +48,16 @@ class InteractiveEventPill extends StatefulWidget { final OnEntryMoved? onEntryMoved; final TimelineGroupRegistry? groupRegistry; + /// Normalized startX of the next entry in the same lane, used to compute + /// available width for point events. + final double? nextEntryStartX; + @override State createState() => _InteractiveEventPillState(); } class _InteractiveEventPillState extends State { + final _pointInteractionKey = GlobalKey(); _InteractionMode? _mode; double _cumulativeDx = 0; @@ -66,6 +73,8 @@ class _InteractiveEventPillState extends State { } _InteractionMode _determineMode(Offset localPosition) { + if (!widget.entry.entry.hasEnd) return _InteractionMode.drag; + final pillWidth = _pillWidth; const handleWidth = ZTimelineConstants.resizeHandleWidth; @@ -273,10 +282,9 @@ class _InteractiveEventPillState extends State { @override Widget build(BuildContext context) { - final pill = EventPill( - color: widget.colorBuilder(widget.entry.entry), - label: widget.labelBuilder(widget.entry.entry), - ); + final isPoint = !widget.entry.entry.hasEnd; + final color = widget.colorBuilder(widget.entry.entry); + final label = widget.labelBuilder(widget.entry.entry); final top = LayoutCoordinateService.laneToY( lane: widget.entry.entry.lane, @@ -286,6 +294,68 @@ class _InteractiveEventPillState extends State { 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, + ); + + 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), + ), + ); + } + + // Range event (hasEnd == true) + final pill = EventPill(color: color, label: label); final width = _pillWidth; if (!widget.enableDrag) { @@ -299,7 +369,8 @@ class _InteractiveEventPillState extends State { } final scope = ZTimelineScope.of(context); - final hasResizeHandles = widget.onEntryResized != null && width >= 16; + final hasResizeHandles = + widget.entry.entry.hasEnd && widget.onEntryResized != null && width >= 16; return Positioned( top: top, @@ -341,6 +412,39 @@ class _InteractiveEventPillState extends State { scope.interaction.clearHoveredEntry(); } + void _onPointHoverEnter() { + final box = + _pointInteractionKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.attached) return; + final topLeft = box.localToGlobal(Offset.zero); + final rect = topLeft & box.size; + final scope = ZTimelineScope.of(context); + scope.interaction.setHoveredEntry(widget.entry.entry.id, rect); + } + + Widget _buildInteractivePoint(Widget pointWidget) { + return Align( + alignment: Alignment.centerLeft, + child: MouseRegion( + key: _pointInteractionKey, + cursor: SystemMouseCursors.click, + opaque: false, + hitTestBehavior: HitTestBehavior.deferToChild, + onEnter: (_) => _onPointHoverEnter(), + onExit: (_) => _onHoverExit(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: _onPanDown, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onPanCancel: _onPanCancel, + child: pointWidget, + ), + ), + ); + } + Widget _buildInteractivePill(Widget pill, bool hasResizeHandles) { return MouseRegion( 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 62f993b..b2807c6 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 @@ -8,7 +8,7 @@ import '../models/timeline_entry.dart'; import '../models/timeline_group.dart'; import '../services/timeline_projection_service.dart'; import '../state/timeline_viewport_notifier.dart'; -import 'ghost_overlay.dart'; +import 'drag_preview.dart'; import 'interactive_event_pill.dart'; import 'timeline_scope.dart'; @@ -285,6 +285,24 @@ 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) { final totalHeight = effectiveLanesCount * widget.laneHeight + @@ -300,9 +318,9 @@ class _GroupLanesState extends State<_GroupLanes> { child: Stack( children: [ // Event pills - for (final e in widget.entries) + for (var i = 0; i < widget.entries.length; i++) InteractiveEventPill( - entry: e, + entry: widget.entries[i], laneHeight: widget.laneHeight, labelBuilder: widget.labelBuilder, colorBuilder: widget.colorBuilder, @@ -313,6 +331,10 @@ class _GroupLanesState extends State<_GroupLanes> { onEntryResized: widget.onEntryResized, onEntryMoved: widget.onEntryMoved, groupRegistry: scope?.groupRegistry, + nextEntryStartX: _findNextEntryStartX( + widget.entries, + i, + ), ), // Ghost overlay for drag operations @@ -326,7 +348,7 @@ class _GroupLanesState extends State<_GroupLanes> { // Show ghost for resize (entry stays in its own group) if (resizeState != null && resizeState.originalEntry.groupId == widget.group.id) { - return GhostOverlay( + return DragPreview( targetStart: resizeState.targetStart, targetEnd: resizeState.targetEnd, targetLane: resizeState.targetLane, @@ -335,19 +357,23 @@ class _GroupLanesState extends State<_GroupLanes> { 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 GhostOverlay( + 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, ); } 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 f3815bf..9d7f4c9 100644 --- a/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart +++ b/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart @@ -35,7 +35,8 @@ export 'src/state/timeline_viewport_notifier.dart'; export 'src/widgets/breadcrumb_segment_chip.dart'; export 'src/widgets/entry_popover_overlay.dart'; export 'src/widgets/event_pill.dart'; -export 'src/widgets/ghost_overlay.dart'; +export 'src/widgets/event_point.dart'; +export 'src/widgets/drag_preview.dart'; export 'src/widgets/interactive_event_pill.dart'; export 'src/widgets/timeline_breadcrumb.dart'; export 'src/widgets/timeline_interactor.dart';