diff --git a/packages/z-flutter/lib/main.dart b/packages/z-flutter/lib/main.dart index f9484af..0912160 100644 --- a/packages/z-flutter/lib/main.dart +++ b/packages/z-flutter/lib/main.dart @@ -254,6 +254,74 @@ class _MainAppState extends State { return _groupColors[groupIndex % _groupColors.length]; } + static const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + String _formatDate(DateTime d) { + return '${_months[d.month - 1]} ${d.day}, ${d.year}'; + } + + String _formatDateRange(DateTime start, DateTime? end) { + final s = _formatDate(start); + if (end == null) return s; + final e = _formatDate(end); + return s == e ? s : '$s – $e'; + } + + Widget? _buildPopoverContent(String entryId) { + final item = _state?.items[entryId]; + if (item == null) return null; + + final start = DateTime.parse(item.start); + final end = item.end != null ? DateTime.parse(item.end!) : null; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.description != null && item.description!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.description!, + style: const TextStyle(fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 4), + Text( + _formatDateRange(start, end), + style: TextStyle(fontSize: 11, color: Colors.grey[400]), + ), + ], + ); + } + + Widget _buildCompactDate(DateTime start, DateTime end) { + return Text( + _formatDateRange(start, end), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ); + } + @override Widget build(BuildContext context) { final viewport = _viewport; @@ -269,25 +337,29 @@ class _MainAppState extends State { ? const Center(child: Text('Waiting for state...')) : ZTimelineScope( viewport: viewport, - child: Column( - children: [ - const ZTimelineBreadcrumb(), - const ZTimelineTieredHeader(), - Expanded( - child: ZTimelineInteractor( - child: ZTimelineView( - groups: _groups, - entries: _entries, - viewport: viewport, - labelBuilder: _labelForEntry, - colorBuilder: _colorForEntry, - enableDrag: true, - onEntryMoved: _onEntryMoved, - onEntryResized: _onEntryResized, + child: EntryPopoverOverlay( + popoverContentBuilder: _buildPopoverContent, + compactDateBuilder: _buildCompactDate, + child: Column( + children: [ + const ZTimelineBreadcrumb(), + const ZTimelineTieredHeader(), + Expanded( + child: ZTimelineInteractor( + child: ZTimelineView( + groups: _groups, + entries: _entries, + viewport: viewport, + labelBuilder: _labelForEntry, + colorBuilder: _colorForEntry, + enableDrag: true, + onEntryMoved: _onEntryMoved, + onEntryResized: _onEntryResized, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/packages/z-flutter/packages/z_timeline/lib/src/state/timeline_interaction_notifier.dart b/packages/z-flutter/packages/z_timeline/lib/src/state/timeline_interaction_notifier.dart index 5ae1ed2..de105f7 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/state/timeline_interaction_notifier.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/state/timeline_interaction_notifier.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import '../models/entry_drag_state.dart'; @@ -16,6 +18,13 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { EntryDragState? _dragState; EntryResizeState? _resizeState; + // -- Hover state -- + String? _hoveredEntryId; + Rect? _hoveredPillGlobalRect; + + // -- Interaction cursor position (during drag/resize) -- + Offset? _interactionGlobalPosition; + /// The current interaction state. ZTimelineInteractionState get state => _state; @@ -37,6 +46,37 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { /// Whether any entry interaction (drag or resize) is active. bool get isInteracting => isDraggingEntry || isResizingEntry; + /// The currently hovered entry ID, or null. + String? get hoveredEntryId => _hoveredEntryId; + + /// The global rect of the hovered pill, or null. + Rect? get hoveredPillGlobalRect => _hoveredPillGlobalRect; + + /// The global cursor position during drag/resize, or null. + Offset? get interactionGlobalPosition => _interactionGlobalPosition; + + /// Set the hovered entry and its global rect. + void setHoveredEntry(String id, Rect globalRect) { + if (_hoveredEntryId == id && _hoveredPillGlobalRect == globalRect) return; + _hoveredEntryId = id; + _hoveredPillGlobalRect = globalRect; + notifyListeners(); + } + + /// Clear the hovered entry. + void clearHoveredEntry() { + if (_hoveredEntryId == null) return; + _hoveredEntryId = null; + _hoveredPillGlobalRect = null; + notifyListeners(); + } + + /// Update the cursor position during drag/resize. + void updateInteractionPosition(Offset globalPosition) { + _interactionGlobalPosition = globalPosition; + notifyListeners(); + } + /// Update the grabbing state. void setGrabbing(bool value) { if (_state.isGrabbing == value) return; @@ -54,7 +94,10 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { /// Begin dragging an entry. /// /// Sets drag state and marks [isDraggingEntry] as true. + /// Clears hover state since the user is now dragging. void beginDrag(TimelineEntry entry) { + _hoveredEntryId = null; + _hoveredPillGlobalRect = null; _dragState = EntryDragState( entryId: entry.id, originalEntry: entry, @@ -87,6 +130,7 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { /// End the drag and clear state. void endDrag() { _dragState = null; + _interactionGlobalPosition = null; setDraggingEntry(false); } @@ -94,7 +138,11 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { void cancelDrag() => endDrag(); /// Begin resizing an entry edge. + /// + /// Clears hover state since the user is now resizing. void beginResize(TimelineEntry entry, ResizeEdge edge) { + _hoveredEntryId = null; + _hoveredPillGlobalRect = null; _resizeState = EntryResizeState( entryId: entry.id, originalEntry: entry, @@ -127,6 +175,7 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { /// End the resize and clear state. void endResize() { _resizeState = null; + _interactionGlobalPosition = null; _state = _state.copyWith(isResizingEntry: false); notifyListeners(); } diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/entry_popover_overlay.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/entry_popover_overlay.dart new file mode 100644 index 0000000..54c55df --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/entry_popover_overlay.dart @@ -0,0 +1,346 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../state/timeline_interaction_notifier.dart'; +import 'timeline_scope.dart'; + +/// Builder for full popover content shown on hover. +/// +/// Return `null` to suppress the popover for a given entry. +typedef PopoverContentBuilder = Widget? Function(String entryId); + +/// Builder for compact date labels shown during drag/resize. +typedef CompactDateBuilder = Widget Function(DateTime start, DateTime end); + +/// Gap between the pill and the popover. +const double _kPopoverGap = 6.0; + +/// Maximum popover width. +const double _kMaxPopoverWidth = 280.0; + +/// Animation duration. +const Duration _kAnimationDuration = Duration(milliseconds: 120); + +/// Wraps a child and manages a single [OverlayEntry] that shows either: +/// - A hover popover (full card) when the user hovers over a pill. +/// - A compact date popover near the cursor during drag/resize. +class EntryPopoverOverlay extends StatefulWidget { + const EntryPopoverOverlay({ + required this.child, + this.popoverContentBuilder, + this.compactDateBuilder, + super.key, + }); + + final Widget child; + final PopoverContentBuilder? popoverContentBuilder; + final CompactDateBuilder? compactDateBuilder; + + @override + State createState() => _EntryPopoverOverlayState(); +} + +class _EntryPopoverOverlayState extends State { + OverlayEntry? _overlayEntry; + ZTimelineInteractionNotifier? _notifier; + + // Track current mode to know when to rebuild. + String? _currentHoveredId; + bool _currentIsInteracting = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = ZTimelineScope.maybeOf(context); + final newNotifier = scope?.interaction; + + if (newNotifier != _notifier) { + _notifier?.removeListener(_onInteractionChanged); + _notifier = newNotifier; + _notifier?.addListener(_onInteractionChanged); + } + } + + @override + void dispose() { + _notifier?.removeListener(_onInteractionChanged); + _removeOverlay(); + super.dispose(); + } + + void _onInteractionChanged() { + final notifier = _notifier; + if (notifier == null) return; + + final hoveredId = notifier.hoveredEntryId; + final isInteracting = notifier.isInteracting; + final interactionPos = notifier.interactionGlobalPosition; + + // Determine what we should show. + final shouldShowHover = + hoveredId != null && + !isInteracting && + widget.popoverContentBuilder != null; + final shouldShowCompact = + isInteracting && + interactionPos != null && + widget.compactDateBuilder != null; + + if (shouldShowHover) { + if (_currentHoveredId != hoveredId || _currentIsInteracting) { + _removeOverlay(); + _currentHoveredId = hoveredId; + _currentIsInteracting = false; + _showHoverPopover(notifier); + } + } else if (shouldShowCompact) { + if (!_currentIsInteracting) { + _removeOverlay(); + _currentIsInteracting = true; + _currentHoveredId = null; + _showCompactPopover(notifier); + } else { + // Just mark dirty to reposition. + _overlayEntry?.markNeedsBuild(); + } + } else { + if (_overlayEntry != null) { + _removeOverlay(); + _currentHoveredId = null; + _currentIsInteracting = false; + } + } + } + + void _showHoverPopover(ZTimelineInteractionNotifier notifier) { + _overlayEntry = OverlayEntry( + builder: (_) => _HoverPopover( + notifier: notifier, + contentBuilder: widget.popoverContentBuilder!, + ), + ); + Overlay.of(context).insert(_overlayEntry!); + } + + void _showCompactPopover(ZTimelineInteractionNotifier notifier) { + _overlayEntry = OverlayEntry( + builder: (_) => _CompactPopover( + notifier: notifier, + compactDateBuilder: widget.compactDateBuilder!, + ), + ); + Overlay.of(context).insert(_overlayEntry!); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + @override + Widget build(BuildContext context) => widget.child; +} + +// --------------------------------------------------------------------------- +// Hover popover — positioned above the pill rect +// --------------------------------------------------------------------------- + +class _HoverPopover extends StatelessWidget { + const _HoverPopover({required this.notifier, required this.contentBuilder}); + + final ZTimelineInteractionNotifier notifier; + final PopoverContentBuilder contentBuilder; + + @override + Widget build(BuildContext context) { + final entryId = notifier.hoveredEntryId; + final pillRect = notifier.hoveredPillGlobalRect; + if (entryId == null || pillRect == null) return const SizedBox.shrink(); + + final content = contentBuilder(entryId); + if (content == null) return const SizedBox.shrink(); + + return _AnimatedPopoverWrapper( + child: CustomSingleChildLayout( + delegate: _PopoverLayoutDelegate(pillRect: pillRect), + child: IgnorePointer(child: _PopoverCard(child: content)), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Compact popover — positioned near cursor during drag/resize +// --------------------------------------------------------------------------- + +class _CompactPopover extends StatelessWidget { + const _CompactPopover({ + required this.notifier, + required this.compactDateBuilder, + }); + + final ZTimelineInteractionNotifier notifier; + final CompactDateBuilder compactDateBuilder; + + @override + Widget build(BuildContext context) { + final pos = notifier.interactionGlobalPosition; + if (pos == null) return const SizedBox.shrink(); + + // Determine dates from drag or resize state. + DateTime? start; + DateTime? end; + + if (notifier.dragState case final drag?) { + start = drag.targetStart; + final duration = drag.originalEntry.end.difference( + drag.originalEntry.start, + ); + end = drag.targetStart.add(duration); + } else if (notifier.resizeState case final resize?) { + start = resize.targetStart; + end = resize.targetEnd; + } + + if (start == null || end == null) return const SizedBox.shrink(); + + return _AnimatedPopoverWrapper( + child: CustomSingleChildLayout( + delegate: _CursorPopoverDelegate(cursorPosition: pos), + child: IgnorePointer( + child: _PopoverCard( + compact: true, + child: compactDateBuilder(start, end), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Layout delegates +// --------------------------------------------------------------------------- + +/// Positions the popover above the pill, flipping below if not enough room. +/// Clamps horizontally to screen bounds. +class _PopoverLayoutDelegate extends SingleChildLayoutDelegate { + _PopoverLayoutDelegate({required this.pillRect}); + + final Rect pillRect; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + maxWidth: math.min(_kMaxPopoverWidth, constraints.maxWidth - 16), + maxHeight: constraints.maxHeight * 0.4, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + // Try above first. + final aboveY = pillRect.top - _kPopoverGap - childSize.height; + final belowY = pillRect.bottom + _kPopoverGap; + final y = aboveY >= 8 ? aboveY : belowY; + + // Center on pill horizontally, clamped to screen. + final idealX = pillRect.center.dx - childSize.width / 2; + final x = idealX.clamp(8.0, size.width - childSize.width - 8); + + return Offset(x, y); + } + + @override + bool shouldRelayout(_PopoverLayoutDelegate oldDelegate) { + return pillRect != oldDelegate.pillRect; + } +} + +/// Positions the compact date label above the cursor. +class _CursorPopoverDelegate extends SingleChildLayoutDelegate { + _CursorPopoverDelegate({required this.cursorPosition}); + + final Offset cursorPosition; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + maxWidth: math.min(_kMaxPopoverWidth, constraints.maxWidth - 16), + maxHeight: 60, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + const offsetAbove = 24.0; + final idealX = cursorPosition.dx - childSize.width / 2; + final x = idealX.clamp(8.0, size.width - childSize.width - 8); + final y = (cursorPosition.dy - offsetAbove - childSize.height).clamp( + 8.0, + size.height - childSize.height - 8, + ); + + return Offset(x, y); + } + + @override + bool shouldRelayout(_CursorPopoverDelegate oldDelegate) { + return cursorPosition != oldDelegate.cursorPosition; + } +} + +// --------------------------------------------------------------------------- +// Shared card styling +// --------------------------------------------------------------------------- + +class _PopoverCard extends StatelessWidget { + const _PopoverCard({required this.child, this.compact = false}); + + final Widget child; + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: theme.colorScheme.surfaceContainer, + child: Padding( + padding: compact + ? const EdgeInsets.symmetric(horizontal: 10, vertical: 6) + : const EdgeInsets.all(12), + child: child, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Animation wrapper +// --------------------------------------------------------------------------- + +class _AnimatedPopoverWrapper extends StatelessWidget { + const _AnimatedPopoverWrapper({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: _kAnimationDuration, + curve: Curves.easeOut, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.scale(scale: 0.95 + 0.05 * value, child: child), + ); + }, + child: child, + ); + } +} 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 43f0aeb..a23ec2f 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 @@ -91,6 +91,9 @@ class _InteractiveEventPillState extends State { _cumulativeDx = 0; + // Clear hover as safety net when drag/resize begins. + scope.interaction.clearHoveredEntry(); + switch (_mode!) { case _InteractionMode.resizeStart: scope.interaction.beginResize(entry, ResizeEdge.start); @@ -113,6 +116,7 @@ class _InteractiveEventPillState extends State { void _handleResizeUpdate(DragUpdateDetails details) { final scope = ZTimelineScope.of(context); + scope.interaction.updateInteractionPosition(details.globalPosition); _cumulativeDx += details.delta.dx; final deltaNormalized = _cumulativeDx / widget.contentWidth; @@ -174,6 +178,7 @@ class _InteractiveEventPillState extends State { void _handleDragUpdate(DragUpdateDetails details) { final scope = ZTimelineScope.of(context); + scope.interaction.updateInteractionPosition(details.globalPosition); final registry = widget.groupRegistry; if (registry == null) return; @@ -322,61 +327,81 @@ class _InteractiveEventPillState extends State { ); } + void _onHoverEnter() { + final box = context.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); + } + + void _onHoverExit() { + final scope = ZTimelineScope.of(context); + scope.interaction.clearHoveredEntry(); + } + Widget _buildInteractivePill(Widget pill, bool hasResizeHandles) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: _onPanDown, - onPanStart: _onPanStart, - onPanUpdate: _onPanUpdate, - onPanEnd: _onPanEnd, - onPanCancel: _onPanCancel, - child: Stack( - children: [ - pill, - // Left resize cursor zone - if (hasResizeHandles) - Positioned( - left: 0, - top: 0, - bottom: 0, - width: ZTimelineConstants.resizeHandleWidth, - child: MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - opaque: false, + return MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.deferToChild, + onEnter: (_) => _onHoverEnter(), + onExit: (_) => _onHoverExit(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: _onPanDown, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onPanCancel: _onPanCancel, + child: Stack( + children: [ + pill, + // Left resize cursor zone + if (hasResizeHandles) + Positioned( + left: 0, + top: 0, + bottom: 0, + width: ZTimelineConstants.resizeHandleWidth, + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + opaque: false, + ), ), - ), - // Right resize cursor zone - if (hasResizeHandles) - Positioned( - right: 0, - top: 0, - bottom: 0, - width: ZTimelineConstants.resizeHandleWidth, - child: MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - opaque: false, + // Right resize cursor zone + if (hasResizeHandles) + Positioned( + right: 0, + top: 0, + bottom: 0, + width: ZTimelineConstants.resizeHandleWidth, + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + opaque: false, + ), ), - ), - // Center click cursor zone (between handles) - if (hasResizeHandles) - Positioned( - left: ZTimelineConstants.resizeHandleWidth, - right: ZTimelineConstants.resizeHandleWidth, - top: 0, - bottom: 0, - child: MouseRegion( - cursor: SystemMouseCursors.click, - opaque: false, + // Center click cursor zone (between handles) + if (hasResizeHandles) + Positioned( + left: ZTimelineConstants.resizeHandleWidth, + right: ZTimelineConstants.resizeHandleWidth, + top: 0, + bottom: 0, + child: MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + ), + ) + else + Positioned.fill( + child: MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + ), ), - ) - else - Positioned.fill( - child: MouseRegion( - cursor: SystemMouseCursors.click, - opaque: false, - ), - ), - ], + ], + ), ), ); } 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 bc3eb07..f3815bf 100644 --- a/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart +++ b/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart @@ -33,6 +33,7 @@ export 'src/state/timeline_viewport_notifier.dart'; // Widgets 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/interactive_event_pill.dart';