add popover

This commit is contained in:
2026-03-05 17:04:00 +01:00
parent 765aa83fb6
commit acb2878ed6
5 changed files with 561 additions and 68 deletions

View File

@@ -254,6 +254,74 @@ class _MainAppState extends State<MainApp> {
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,6 +337,9 @@ class _MainAppState extends State<MainApp> {
? const Center(child: Text('Waiting for state...'))
: ZTimelineScope(
viewport: viewport,
child: EntryPopoverOverlay(
popoverContentBuilder: _buildPopoverContent,
compactDateBuilder: _buildCompactDate,
child: Column(
children: [
const ZTimelineBreadcrumb(),
@@ -291,6 +362,7 @@ class _MainAppState extends State<MainApp> {
),
),
),
),
);
}
}

View File

@@ -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();
}

View File

@@ -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<EntryPopoverOverlay> createState() => _EntryPopoverOverlayState();
}
class _EntryPopoverOverlayState extends State<EntryPopoverOverlay> {
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<double>(
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,
);
}
}

View File

@@ -91,6 +91,9 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
_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<InteractiveEventPill> {
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<InteractiveEventPill> {
void _handleDragUpdate(DragUpdateDetails details) {
final scope = ZTimelineScope.of(context);
scope.interaction.updateInteractionPosition(details.globalPosition);
final registry = widget.groupRegistry;
if (registry == null) return;
@@ -322,8 +327,27 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
);
}
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(
return MouseRegion(
opaque: false,
hitTestBehavior: HitTestBehavior.deferToChild,
onEnter: (_) => _onHoverEnter(),
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: _onPanDown,
onPanStart: _onPanStart,
@@ -378,6 +402,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
),
],
),
),
);
}
}

View File

@@ -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';