From 765aa83fb614a9f3dc0a0d6149dd0a762f3007d0 Mon Sep 17 00:00:00 2001 From: Jonatan Granqvist Date: Wed, 4 Mar 2026 14:16:51 +0100 Subject: [PATCH] resize --- .../src/hooks/use-entry-resized-mutation.ts | 66 +++ apps/web/src/lib/flutter-bridge.ts | 10 + apps/web/src/routes/timeline.$timelineId.tsx | 7 +- packages/z-flutter/lib/main.dart | 42 ++ .../z_timeline/lib/src/constants.dart | 4 + .../lib/src/models/entry_resize_state.dart | 88 ++++ .../lib/src/models/interaction_state.dart | 12 +- .../services/layout_coordinate_service.dart | 16 +- .../lib/src/services/tiered_tick_service.dart | 6 +- .../src/services/timeline_group_registry.dart | 103 +++++ .../state/timeline_interaction_notifier.dart | 56 ++- .../src/widgets/breadcrumb_segment_chip.dart | 14 +- .../lib/src/widgets/draggable_event_pill.dart | 135 ------ .../lib/src/widgets/event_pill.dart | 40 ++ .../lib/src/widgets/ghost_overlay.dart | 80 +++- .../lib/src/widgets/group_drop_target.dart | 128 ------ .../src/widgets/interactive_event_pill.dart | 383 ++++++++++++++++++ .../lib/src/widgets/timeline_breadcrumb.dart | 11 +- .../lib/src/widgets/timeline_interactor.dart | 9 +- .../lib/src/widgets/timeline_scope.dart | 13 +- .../src/widgets/timeline_tiered_header.dart | 18 +- .../lib/src/widgets/timeline_view.dart | 199 ++++++--- .../packages/z_timeline/lib/z_timeline.dart | 6 +- packages/z-flutter/web/index.html | 348 ++++++++++++++-- 24 files changed, 1370 insertions(+), 424 deletions(-) create mode 100644 apps/web/src/hooks/use-entry-resized-mutation.ts create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/models/entry_resize_state.dart create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/services/timeline_group_registry.dart delete mode 100644 packages/z-flutter/packages/z_timeline/lib/src/widgets/draggable_event_pill.dart create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart delete mode 100644 packages/z-flutter/packages/z_timeline/lib/src/widgets/group_drop_target.dart create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart diff --git a/apps/web/src/hooks/use-entry-resized-mutation.ts b/apps/web/src/hooks/use-entry-resized-mutation.ts new file mode 100644 index 0000000..4ae7bd3 --- /dev/null +++ b/apps/web/src/hooks/use-entry-resized-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { NormalizedTimeline } from "@/functions/get-timeline"; +import { updateTimelineItem } from "@/functions/update-timeline-item"; + +type EntryResizedVars = { + entryId: string; + newStart: string; + newEnd: string | null; + groupId: string; + lane: number; +}; + +export function useEntryResizedMutation(timelineId: string) { + const queryClient = useQueryClient(); + const queryKey = ["timeline", timelineId]; + + return useMutation({ + mutationFn: (vars: EntryResizedVars) => + updateTimelineItem({ + data: { + id: vars.entryId, + start: vars.newStart, + end: vars.newEnd, + timelineGroupId: vars.groupId, + lane: vars.lane, + }, + }), + + onMutate: async (vars) => { + // Cancel in-flight fetches so they don't overwrite our optimistic update + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old) => { + if (!old) return old; + + return { + ...old, + items: { + ...old.items, + [vars.entryId]: { + ...old.items[vars.entryId], + start: new Date(vars.newStart), + end: vars.newEnd ? new Date(vars.newEnd) : null, + lane: vars.lane, + }, + }, + }; + }); + + return { previous }; + }, + + onError: (_err, _vars, context) => { + // Roll back to the previous cache state on server error + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + + onSettled: () => { + // Re-fetch from server to ensure consistency + queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/apps/web/src/lib/flutter-bridge.ts b/apps/web/src/lib/flutter-bridge.ts index bf87a96..5511292 100644 --- a/apps/web/src/lib/flutter-bridge.ts +++ b/apps/web/src/lib/flutter-bridge.ts @@ -55,4 +55,14 @@ export type FlutterEvent = newGroupId: string; newLane: number; }; + } + | { + type: "entry_resized"; + payload: { + entryId: string; + newStart: string; + newEnd: string | null; + groupId: string; + lane: number; + }; }; diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index 1fb38c8..119adf8 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -5,6 +5,7 @@ import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge"; import { timelineQueryOptions } from "@/functions/get-timeline"; import { FlutterView } from "@/components/flutter-view"; import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation"; +import { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation"; import { useTheme } from "@/lib/theme"; export const Route = createFileRoute("/timeline/$timelineId")({ @@ -22,6 +23,7 @@ function RouteComponent() { const [selectedItemId, setSelectedItemId] = useState(null); const [flutterHeight, setFlutterHeight] = useState(); const entryMoved = useEntryMovedMutation(timelineId); + const entryResized = useEntryResizedMutation(timelineId); const { theme } = useTheme(); const flutterState: FlutterTimelineState = useMemo( @@ -64,9 +66,12 @@ function RouteComponent() { case "entry_moved": entryMoved.mutate(event.payload); break; + case "entry_resized": + entryResized.mutate(event.payload); + break; } }, - [entryMoved] + [entryMoved, entryResized] ); return ( diff --git a/packages/z-flutter/lib/main.dart b/packages/z-flutter/lib/main.dart index fa4bbf1..f9484af 100644 --- a/packages/z-flutter/lib/main.dart +++ b/packages/z-flutter/lib/main.dart @@ -169,6 +169,47 @@ class _MainAppState extends State { }); } + void _onEntryResized( + TimelineEntry entry, + DateTime newStart, + DateTime newEnd, + int newLane, + ) { + // Optimistic update -- apply locally before the host round-trips. + if (_state case final state?) { + final oldItem = state.items[entry.id]; + if (oldItem != null) { + final updatedItems = Map.of(state.items); + updatedItems[entry.id] = TimelineItemData( + id: oldItem.id, + groupId: oldItem.groupId, + title: oldItem.title, + description: oldItem.description, + start: newStart.toIso8601String(), + end: entry.hasEnd ? newEnd.toIso8601String() : null, + lane: newLane, + ); + final updatedState = TimelineState( + timeline: state.timeline, + groups: state.groups, + items: updatedItems, + groupOrder: state.groupOrder, + selectedItemId: state.selectedItemId, + darkMode: state.darkMode, + ); + _applyState(updatedState); + } + } + + emitEvent('entry_resized', { + 'entryId': entry.id, + 'newStart': newStart.toIso8601String(), + 'newEnd': entry.hasEnd ? newEnd.toIso8601String() : null, + 'groupId': entry.groupId, + 'lane': newLane, + }); + } + void _emitContentHeight() { // Start with the fixed chrome heights. var totalHeight = _tieredHeaderHeight + _breadcrumbHeight; @@ -242,6 +283,7 @@ class _MainAppState extends State { colorBuilder: _colorForEntry, enableDrag: true, onEntryMoved: _onEntryMoved, + onEntryResized: _onEntryResized, ), ), ), 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 1c0fcfb..902f464 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,10 @@ class ZTimelineConstants { vertical: 6.0, ); + // Resize handles + static const double resizeHandleWidth = 6.0; + static const Duration minResizeDuration = Duration(hours: 1); + // Content width static const double minContentWidth = 1200.0; } diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/entry_resize_state.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/entry_resize_state.dart new file mode 100644 index 0000000..71c08ab --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/models/entry_resize_state.dart @@ -0,0 +1,88 @@ +import 'package:flutter/foundation.dart'; + +import 'timeline_entry.dart'; + +/// Which edge of the pill is being resized. +enum ResizeEdge { start, end } + +/// Immutable state tracking during resize operations. +/// +/// Captures the entry being resized, which edge, and the target bounds. +@immutable +class EntryResizeState { + const EntryResizeState({ + required this.entryId, + required this.originalEntry, + required this.edge, + required this.targetStart, + required this.targetEnd, + required this.targetLane, + }); + + /// The ID of the entry being resized. + final String entryId; + + /// The original entry (for reverting / reference). + final TimelineEntry originalEntry; + + /// Which edge the user is dragging. + final ResizeEdge edge; + + /// The current target start time. + final DateTime targetStart; + + /// The current target end time. + final DateTime targetEnd; + + /// The resolved lane (collision-aware). + final int targetLane; + + EntryResizeState copyWith({ + String? entryId, + TimelineEntry? originalEntry, + ResizeEdge? edge, + DateTime? targetStart, + DateTime? targetEnd, + int? targetLane, + }) { + return EntryResizeState( + entryId: entryId ?? this.entryId, + originalEntry: originalEntry ?? this.originalEntry, + edge: edge ?? this.edge, + targetStart: targetStart ?? this.targetStart, + targetEnd: targetEnd ?? this.targetEnd, + targetLane: targetLane ?? this.targetLane, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is EntryResizeState && + other.entryId == entryId && + other.originalEntry == originalEntry && + other.edge == edge && + other.targetStart == targetStart && + other.targetEnd == targetEnd && + other.targetLane == targetLane; + } + + @override + int get hashCode => Object.hash( + entryId, + originalEntry, + edge, + targetStart, + targetEnd, + targetLane, + ); + + @override + String toString() => + 'EntryResizeState(' + 'entryId: $entryId, ' + 'edge: $edge, ' + 'targetStart: $targetStart, ' + 'targetEnd: $targetEnd, ' + 'targetLane: $targetLane)'; +} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/interaction_state.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/interaction_state.dart index 0842e40..34ef32e 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/models/interaction_state.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/models/interaction_state.dart @@ -9,22 +9,27 @@ class ZTimelineInteractionState { const ZTimelineInteractionState({ this.isGrabbing = false, this.isDraggingEntry = false, + this.isResizingEntry = false, }); /// Whether the user is actively panning (for cursor feedback). final bool isGrabbing; /// Whether an entry is being dragged (disables pan gesture). - /// This will be used by future drag-and-drop functionality. final bool isDraggingEntry; + /// Whether an entry is being resized (disables pan gesture). + final bool isResizingEntry; + ZTimelineInteractionState copyWith({ bool? isGrabbing, bool? isDraggingEntry, + bool? isResizingEntry, }) { return ZTimelineInteractionState( isGrabbing: isGrabbing ?? this.isGrabbing, isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry, + isResizingEntry: isResizingEntry ?? this.isResizingEntry, ); } @@ -33,9 +38,10 @@ class ZTimelineInteractionState { if (identical(this, other)) return true; return other is ZTimelineInteractionState && other.isGrabbing == isGrabbing && - other.isDraggingEntry == isDraggingEntry; + other.isDraggingEntry == isDraggingEntry && + other.isResizingEntry == isResizingEntry; } @override - int get hashCode => Object.hash(isGrabbing, isDraggingEntry); + int get hashCode => Object.hash(isGrabbing, isDraggingEntry, isResizingEntry); } 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 e54f4a0..02d2adf 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 @@ -35,8 +35,7 @@ class LayoutCoordinateService { required double normalizedWidth, required double contentWidth, }) { - return (normalizedWidth * contentWidth) - .clamp(0.0, double.infinity); + return (normalizedWidth * contentWidth).clamp(0.0, double.infinity); } // ───────────────────────────────────────────────────────────────────────── @@ -62,8 +61,7 @@ class LayoutCoordinateService { required double widgetX, required double contentWidth, }) { - final adjustedX = - (widgetX).clamp(0.0, contentWidth); + final adjustedX = (widgetX).clamp(0.0, contentWidth); return contentWidth == 0 ? 0.0 : adjustedX / contentWidth; } @@ -75,10 +73,7 @@ class LayoutCoordinateService { /// /// Used by all positioned elements (pills, ghost overlay) within the /// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0. - static double laneToY({ - required int lane, - required double laneHeight, - }) { + static double laneToY({required int lane, required double laneHeight}) { return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing); } @@ -86,10 +81,7 @@ class LayoutCoordinateService { /// /// Used by drop targets to determine which lane the cursor is over. /// The Y coordinate should be relative to the timeline Stack. - static int yToLane({ - required double y, - required double laneHeight, - }) { + static int yToLane({required double y, required double laneHeight}) { final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing; return (y / laneStep).floor() + 1; } diff --git a/packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart index 7c40dcb..733c0e6 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart @@ -286,8 +286,10 @@ class TieredTickService { // Start one period before to ensure we cover partial sections at the edge var current = alignToUnit(startUtc, unit); - final oneBeforeStart = - alignToUnit(current.subtract(const Duration(milliseconds: 1)), unit); + final oneBeforeStart = alignToUnit( + current.subtract(const Duration(milliseconds: 1)), + unit, + ); current = oneBeforeStart; while (current.isBefore(endUtc)) { 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 new file mode 100644 index 0000000..25db523 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_group_registry.dart @@ -0,0 +1,103 @@ +import 'package:flutter/widgets.dart'; + +import '../constants.dart'; + +/// Result of a hit test against registered groups. +class GroupHitResult { + const GroupHitResult({required this.groupId, required this.localPosition}); + + /// The ID of the group that was hit. + final String groupId; + + /// The position relative to the group's lanes area. + final Offset localPosition; +} + +/// Registration data for a timeline group's lanes area. +class _GroupRegistration { + const _GroupRegistration({ + required this.key, + required this.verticalOffset, + required this.lanesCount, + required this.laneHeight, + required this.contentWidth, + }); + + final GlobalKey key; + final double verticalOffset; + final int lanesCount; + final double laneHeight; + final double contentWidth; +} + +/// Registry for timeline group lane areas, enabling cross-group hit detection. +/// +/// Each group registers its lanes area [GlobalKey] so that during drag +/// operations we can determine which group contains a given global position +/// without relying on Flutter's [DragTarget] system. +class TimelineGroupRegistry { + final _groups = {}; + + /// Register a group's lanes area. + void register( + String groupId, + GlobalKey key, { + required double verticalOffset, + required int lanesCount, + required double laneHeight, + required double contentWidth, + }) { + _groups[groupId] = _GroupRegistration( + key: key, + verticalOffset: verticalOffset, + lanesCount: lanesCount, + laneHeight: laneHeight, + contentWidth: contentWidth, + ); + } + + /// Unregister a group. + void unregister(String groupId) { + _groups.remove(groupId); + } + + /// Hit test against all registered groups. + /// + /// Returns the group whose lanes area contains [globalPosition], + /// along with the local position within that lanes area. + GroupHitResult? hitTest(Offset globalPosition) { + for (final entry in _groups.entries) { + final reg = entry.value; + final renderBox = + reg.key.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.attached) continue; + + final local = renderBox.globalToLocal(globalPosition); + final adjustedY = local.dy - reg.verticalOffset; + + // Check horizontal bounds + if (local.dx < 0 || local.dx > reg.contentWidth) continue; + + // Check vertical bounds: allow one extra lane for expansion + final maxLanes = (reg.lanesCount <= 0 ? 1 : reg.lanesCount) + 1; + final maxY = + maxLanes * (reg.laneHeight + ZTimelineConstants.laneVerticalSpacing); + if (adjustedY < 0 || adjustedY > maxY) continue; + + return GroupHitResult( + groupId: entry.key, + localPosition: Offset(local.dx, adjustedY), + ); + } + return null; + } + + /// Get the lane height for a registered group. + double? laneHeightFor(String groupId) => _groups[groupId]?.laneHeight; + + /// Get the content width for a registered group. + double? contentWidthFor(String groupId) => _groups[groupId]?.contentWidth; + + /// Get the lanes count for a registered group. + int? lanesCountFor(String groupId) => _groups[groupId]?.lanesCount; +} 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 df29b73..5ae1ed2 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,6 +1,7 @@ import 'package:flutter/foundation.dart'; import '../models/entry_drag_state.dart'; +import '../models/entry_resize_state.dart'; import '../models/interaction_state.dart'; import '../models/timeline_entry.dart'; @@ -13,6 +14,7 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { ZTimelineInteractionState _state = const ZTimelineInteractionState(); EntryDragState? _dragState; + EntryResizeState? _resizeState; /// The current interaction state. ZTimelineInteractionState get state => _state; @@ -20,12 +22,21 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { /// The current drag state, or null if no drag is active. EntryDragState? get dragState => _dragState; + /// The current resize state, or null if no resize is active. + EntryResizeState? get resizeState => _resizeState; + /// Whether the user is actively panning (for cursor feedback). bool get isGrabbing => _state.isGrabbing; /// Whether an entry is being dragged (disables pan gesture). bool get isDraggingEntry => _state.isDraggingEntry; + /// Whether an entry is being resized. + bool get isResizingEntry => _state.isResizingEntry; + + /// Whether any entry interaction (drag or resize) is active. + bool get isInteracting => isDraggingEntry || isResizingEntry; + /// Update the grabbing state. void setGrabbing(bool value) { if (_state.isGrabbing == value) return; @@ -82,11 +93,44 @@ class ZTimelineInteractionNotifier extends ChangeNotifier { /// Cancel the drag (alias for [endDrag]). void cancelDrag() => endDrag(); - /// Called by drag-drop system when an entry drag starts. - @Deprecated('Use beginDrag instead') - void beginEntryDrag() => setDraggingEntry(true); + /// Begin resizing an entry edge. + void beginResize(TimelineEntry entry, ResizeEdge edge) { + _resizeState = EntryResizeState( + entryId: entry.id, + originalEntry: entry, + edge: edge, + targetStart: entry.start, + targetEnd: entry.end, + targetLane: entry.lane, + ); + _state = _state.copyWith(isResizingEntry: true); + notifyListeners(); + } - /// Called by drag-drop system when an entry drag ends. - @Deprecated('Use endDrag instead') - void endEntryDrag() => setDraggingEntry(false); + /// Update the resize target times and optional lane. + void updateResizeTarget({ + required DateTime targetStart, + required DateTime targetEnd, + int? targetLane, + }) { + if (_resizeState == null) return; + final newState = _resizeState!.copyWith( + targetStart: targetStart, + targetEnd: targetEnd, + targetLane: targetLane, + ); + if (newState == _resizeState) return; + _resizeState = newState; + notifyListeners(); + } + + /// End the resize and clear state. + void endResize() { + _resizeState = null; + _state = _state.copyWith(isResizingEntry: false); + notifyListeners(); + } + + /// Cancel the resize (alias for [endResize]). + void cancelResize() => endResize(); } diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart index f17146b..73eaf8f 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart @@ -29,7 +29,9 @@ class BreadcrumbSegmentChip extends StatelessWidget { duration: const Duration(milliseconds: 200), child: AnimatedSize( duration: const Duration(milliseconds: 200), - child: segment.isVisible ? _buildChip(context) : const SizedBox.shrink(), + child: segment.isVisible + ? _buildChip(context) + : const SizedBox.shrink(), ), ); } @@ -45,14 +47,16 @@ class BreadcrumbSegmentChip extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), borderRadius: BorderRadius.circular(4), ), child: Text( segment.label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface), ), ), ), diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/draggable_event_pill.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/draggable_event_pill.dart deleted file mode 100644 index 4a3246e..0000000 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/draggable_event_pill.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../models/projected_entry.dart'; -import '../models/timeline_entry.dart'; -import '../services/layout_coordinate_service.dart'; -import 'timeline_scope.dart'; -import 'timeline_view.dart'; - -/// A draggable event pill widget. -/// -/// Renders a timeline entry as a pill that can be dragged to move it -/// to a new position. Uses Flutter's built-in [Draggable] widget. -class DraggableEventPill extends StatelessWidget { - const DraggableEventPill({ - required this.entry, - required this.laneHeight, - required this.labelBuilder, - required this.colorBuilder, - required this.contentWidth, - required this.enableDrag, - super.key, - }); - - final ProjectedEntry entry; - final double laneHeight; - final EntryLabelBuilder labelBuilder; - final EntryColorBuilder colorBuilder; - final double contentWidth; - final bool enableDrag; - - @override - Widget build(BuildContext context) { - final pill = _buildPill(context); - - // Use centralized coordinate service for consistent positioning - final top = LayoutCoordinateService.laneToY( - lane: entry.entry.lane, - laneHeight: laneHeight, - ); - final left = LayoutCoordinateService.normalizedToWidgetX( - normalizedX: entry.startX, - contentWidth: contentWidth, - ); - final width = LayoutCoordinateService.calculateItemWidth( - normalizedWidth: entry.widthX, - contentWidth: contentWidth, - ); - - final scope = ZTimelineScope.of(context); - - // Wrap pill in a MouseRegion so hovering shows a pointer cursor. - // During an active entry drag, defer to the parent cursor (grabbing). - final cursorPill = ListenableBuilder( - listenable: scope.interaction, - builder: (context, child) => MouseRegion( - cursor: scope.interaction.isDraggingEntry - ? MouseCursor.defer - : SystemMouseCursors.click, - child: child, - ), - child: pill, - ); - - if (!enableDrag) { - return Positioned( - top: top, - left: left.clamp(0.0, double.infinity), - width: width.clamp(0.0, double.infinity), - height: laneHeight, - child: cursorPill, - ); - } - - return Positioned( - top: top, - left: left.clamp(0.0, double.infinity), - width: width.clamp(0.0, double.infinity), - height: laneHeight, - child: Draggable( - data: entry.entry, - onDragStarted: () { - scope.interaction.beginDrag(entry.entry); - }, - onDraggableCanceled: (_, __) { - scope.interaction.cancelDrag(); - }, - onDragCompleted: () { - // Handled by DragTarget - }, - feedback: DefaultTextStyle( - style: Theme.of(context).textTheme.labelMedium ?? const TextStyle(), - child: Opacity( - opacity: 0.8, - child: SizedBox( - width: width.clamp(0.0, double.infinity), - height: laneHeight, - child: pill, - ), - ), - ), - childWhenDragging: Opacity(opacity: 0.3, child: pill), - child: cursorPill, - ), - ); - } - - Widget _buildPill(BuildContext context) { - final color = colorBuilder(entry.entry); - final onColor = - ThemeData.estimateBrightnessForColor(color) == Brightness.dark - ? Colors.white - : Colors.black87; - - return Container( - padding: ZTimelineConstants.pillPadding, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular( - ZTimelineConstants.pillBorderRadius, - ), - ), - alignment: Alignment.centerLeft, - child: Text( - labelBuilder(entry.entry), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: onColor, - fontWeight: FontWeight.w400, - ), - ), - ); - } -} 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 new file mode 100644 index 0000000..aa70293 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.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 { + const EventPill({required this.color, required this.label, super.key}); + + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + final onColor = + ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : Colors.black87; + + return Container( + padding: ZTimelineConstants.pillPadding, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular( + ZTimelineConstants.pillBorderRadius, + ), + ), + alignment: Alignment.centerLeft, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: onColor, + fontWeight: FontWeight.w400, + ), + ), + ); + } +} 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 index fec3ba1..8df1ffa 100644 --- 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 @@ -1,38 +1,53 @@ import 'package:flutter/material.dart'; import '../constants.dart'; -import '../models/entry_drag_state.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 operations to give visual feedback about the -/// target position. +/// Displayed during drag and resize operations to give visual feedback about +/// the target position. class GhostOverlay extends StatelessWidget { const GhostOverlay({ - required this.dragState, + 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 EntryDragState dragState; + 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( - dragState.targetStart, + targetStart, viewport.start, viewport.end, ); final endX = TimeScaleService.mapTimeToPosition( - dragState.targetEnd, + targetEnd, viewport.start, viewport.end, ); @@ -47,31 +62,52 @@ class GhostOverlay extends StatelessWidget { contentWidth: contentWidth, ); final top = LayoutCoordinateService.laneToY( - lane: dragState.targetLane, + lane: targetLane, laneHeight: laneHeight, ); - final scheme = Theme.of(context).colorScheme; - return Positioned( left: left.clamp(0.0, double.infinity), width: width.clamp(0.0, double.infinity), top: top, height: laneHeight, child: IgnorePointer( - child: Container( - 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, - ), - ), - ), + 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/group_drop_target.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/group_drop_target.dart deleted file mode 100644 index 3f8915d..0000000 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/group_drop_target.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models/projected_entry.dart'; -import '../models/timeline_entry.dart'; -import '../models/timeline_group.dart'; -import '../services/entry_placement_service.dart'; -import '../services/layout_coordinate_service.dart'; -import '../services/time_scale_service.dart'; -import '../state/timeline_viewport_notifier.dart'; -import 'timeline_scope.dart'; -import 'timeline_view.dart'; - -/// A drop target wrapper for a timeline group. -/// -/// Wraps group lanes content and handles drag-and-drop operations. -/// The ghost overlay is rendered by the parent widget in the same Stack. -class GroupDropTarget extends StatelessWidget { - const GroupDropTarget({ - required this.group, - required this.entries, - required this.allEntries, - required this.viewport, - required this.contentWidth, - required this.laneHeight, - required this.lanesCount, - required this.onEntryMoved, - required this.child, - this.verticalOffset = 0.0, - super.key, - }); - - final TimelineGroup group; - final List entries; - final List allEntries; - final TimelineViewportNotifier viewport; - final double contentWidth; - final double laneHeight; - final int lanesCount; - final OnEntryMoved? onEntryMoved; - final Widget child; - - /// Vertical offset from the top of this widget to the top of the lanes area. - /// Used to correctly map pointer y-coordinates to lanes when this target - /// wraps content above the lanes (e.g. group headers). - final double verticalOffset; - - @override - Widget build(BuildContext context) { - final scope = ZTimelineScope.of(context); - - return DragTarget( - builder: (context, candidateData, rejectedData) { - return child; - }, - onWillAcceptWithDetails: (details) => true, - onMove: (details) { - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox == null) return; - - final local = renderBox.globalToLocal(details.offset); - - // Use centralized coordinate service for consistent transformations - final ratio = LayoutCoordinateService.widgetXToNormalized( - widgetX: local.dx, - contentWidth: contentWidth, - ); - - final targetStart = TimeScaleService.mapPositionToTime( - ratio, - viewport.start, - viewport.end, - ); - - // Adjust y to account for content above the lanes (e.g. group header) - final adjustedY = local.dy - verticalOffset; - - final rawLane = LayoutCoordinateService.yToLane( - y: adjustedY, - laneHeight: laneHeight, - ); - final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1; - final targetLane = rawLane.clamp(1, maxAllowedLane); - - // Resolve with collision avoidance - final resolved = EntryPlacementService.resolvePlacement( - entry: details.data, - targetGroupId: group.id, - targetLane: targetLane, - targetStart: targetStart, - existingEntries: allEntries, - ); - - scope.interaction.updateDragTarget( - targetGroupId: group.id, - targetLane: resolved.lane, - targetStart: targetStart, - ); - }, - onAcceptWithDetails: (details) { - final dragState = scope.interaction.dragState; - if (dragState == null || onEntryMoved == null) { - scope.interaction.endDrag(); - return; - } - - final resolved = EntryPlacementService.resolvePlacement( - entry: details.data, - targetGroupId: dragState.targetGroupId, - targetLane: dragState.targetLane, - targetStart: dragState.targetStart, - existingEntries: allEntries, - ); - - onEntryMoved!( - details.data, - dragState.targetStart, - dragState.targetGroupId, - resolved.lane, - ); - - scope.interaction.endDrag(); - }, - onLeave: (data) { - // Don't clear on leave - entry might move to another group - }, - ); - } -} 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 new file mode 100644 index 0000000..43f0aeb --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart @@ -0,0 +1,383 @@ +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'; +import '../services/time_scale_service.dart'; +import '../services/timeline_group_registry.dart'; +import '../state/timeline_viewport_notifier.dart'; +import 'event_pill.dart'; +import 'timeline_scope.dart'; +import 'timeline_view.dart'; + +/// Interaction mode determined on pan-down. +enum _InteractionMode { resizeStart, resizeEnd, drag } + +/// An interactive event pill that handles both resize and drag-move +/// with a single [GestureDetector] using pan gestures. +class InteractiveEventPill extends StatefulWidget { + const InteractiveEventPill({ + required this.entry, + required this.laneHeight, + required this.labelBuilder, + required this.colorBuilder, + required this.contentWidth, + required this.enableDrag, + required this.viewport, + this.allEntries = const [], + this.onEntryResized, + this.onEntryMoved, + this.groupRegistry, + super.key, + }); + + final ProjectedEntry entry; + final double laneHeight; + final EntryLabelBuilder labelBuilder; + final EntryColorBuilder colorBuilder; + final double contentWidth; + final bool enableDrag; + final TimelineViewportNotifier viewport; + final List allEntries; + final OnEntryResized? onEntryResized; + final OnEntryMoved? onEntryMoved; + final TimelineGroupRegistry? groupRegistry; + + @override + State createState() => _InteractiveEventPillState(); +} + +class _InteractiveEventPillState extends State { + _InteractionMode? _mode; + double _cumulativeDx = 0; + + /// Normalized offset from the entry's start to the grab point, + /// so dragging doesn't snap the entry start to the cursor. + double _grabOffsetNormalized = 0; + + double get _pillWidth { + return LayoutCoordinateService.calculateItemWidth( + normalizedWidth: widget.entry.widthX, + contentWidth: widget.contentWidth, + ).clamp(0.0, double.infinity); + } + + _InteractionMode _determineMode(Offset localPosition) { + final pillWidth = _pillWidth; + const handleWidth = ZTimelineConstants.resizeHandleWidth; + + if (widget.onEntryResized != null && pillWidth >= 16) { + if (localPosition.dx <= handleWidth) { + return _InteractionMode.resizeStart; + } + if (localPosition.dx >= pillWidth - handleWidth) { + return _InteractionMode.resizeEnd; + } + } + return _InteractionMode.drag; + } + + void _onPanDown(DragDownDetails details) { + _mode = _determineMode(details.localPosition); + _grabOffsetNormalized = details.localPosition.dx / widget.contentWidth; + } + + void _onPanStart(DragStartDetails details) { + final scope = ZTimelineScope.of(context); + final entry = widget.entry.entry; + + _cumulativeDx = 0; + + switch (_mode!) { + case _InteractionMode.resizeStart: + scope.interaction.beginResize(entry, ResizeEdge.start); + case _InteractionMode.resizeEnd: + scope.interaction.beginResize(entry, ResizeEdge.end); + case _InteractionMode.drag: + scope.interaction.beginDrag(entry); + } + } + + void _onPanUpdate(DragUpdateDetails details) { + switch (_mode!) { + case _InteractionMode.resizeStart: + case _InteractionMode.resizeEnd: + _handleResizeUpdate(details); + case _InteractionMode.drag: + _handleDragUpdate(details); + } + } + + void _handleResizeUpdate(DragUpdateDetails details) { + final scope = ZTimelineScope.of(context); + _cumulativeDx += details.delta.dx; + + final deltaNormalized = _cumulativeDx / widget.contentWidth; + final originalEntry = widget.entry.entry; + final edge = _mode == _InteractionMode.resizeStart + ? ResizeEdge.start + : ResizeEdge.end; + + DateTime newStart = originalEntry.start; + DateTime newEnd = originalEntry.end; + + if (edge == ResizeEdge.start) { + final originalStartNorm = TimeScaleService.mapTimeToPosition( + originalEntry.start, + widget.viewport.start, + widget.viewport.end, + ); + newStart = TimeScaleService.mapPositionToTime( + originalStartNorm + deltaNormalized, + widget.viewport.start, + widget.viewport.end, + ); + final maxStart = newEnd.subtract(ZTimelineConstants.minResizeDuration); + if (newStart.isAfter(maxStart)) { + newStart = maxStart; + } + } else { + final originalEndNorm = TimeScaleService.mapTimeToPosition( + originalEntry.end, + widget.viewport.start, + widget.viewport.end, + ); + newEnd = TimeScaleService.mapPositionToTime( + originalEndNorm + deltaNormalized, + widget.viewport.start, + widget.viewport.end, + ); + final minEnd = newStart.add(ZTimelineConstants.minResizeDuration); + if (newEnd.isBefore(minEnd)) { + newEnd = minEnd; + } + } + + final resolvedLane = EntryPlacementService.findNearestAvailableLane( + entryId: originalEntry.id, + targetGroupId: originalEntry.groupId, + targetLane: originalEntry.lane, + targetStart: newStart, + targetEnd: newEnd, + existingEntries: widget.allEntries, + ); + + scope.interaction.updateResizeTarget( + targetStart: newStart, + targetEnd: newEnd, + targetLane: resolvedLane, + ); + } + + void _handleDragUpdate(DragUpdateDetails details) { + final scope = ZTimelineScope.of(context); + final registry = widget.groupRegistry; + if (registry == null) return; + + final hit = registry.hitTest(details.globalPosition); + if (hit == null) return; + + final laneHeight = registry.laneHeightFor(hit.groupId) ?? widget.laneHeight; + final contentWidth = + registry.contentWidthFor(hit.groupId) ?? widget.contentWidth; + final lanesCount = registry.lanesCountFor(hit.groupId) ?? 1; + + final ratio = LayoutCoordinateService.widgetXToNormalized( + widgetX: hit.localPosition.dx, + contentWidth: contentWidth, + ); + + final targetStart = TimeScaleService.mapPositionToTime( + ratio - _grabOffsetNormalized, + widget.viewport.start, + widget.viewport.end, + ); + + final rawLane = LayoutCoordinateService.yToLane( + y: hit.localPosition.dy, + laneHeight: laneHeight, + ); + final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1; + final targetLane = rawLane.clamp(1, maxAllowedLane); + + final resolved = EntryPlacementService.resolvePlacement( + entry: widget.entry.entry, + targetGroupId: hit.groupId, + targetLane: targetLane, + targetStart: targetStart, + existingEntries: widget.allEntries, + ); + + scope.interaction.updateDragTarget( + targetGroupId: hit.groupId, + targetLane: resolved.lane, + targetStart: targetStart, + ); + } + + void _onPanEnd(DragEndDetails details) { + final scope = ZTimelineScope.of(context); + + switch (_mode!) { + case _InteractionMode.resizeStart: + case _InteractionMode.resizeEnd: + final resizeState = scope.interaction.resizeState; + if (resizeState != null && widget.onEntryResized != null) { + widget.onEntryResized!( + resizeState.originalEntry, + resizeState.targetStart, + resizeState.targetEnd, + resizeState.targetLane, + ); + } + scope.interaction.endResize(); + case _InteractionMode.drag: + final dragState = scope.interaction.dragState; + if (dragState != null && widget.onEntryMoved != null) { + widget.onEntryMoved!( + dragState.originalEntry, + dragState.targetStart, + dragState.targetGroupId, + dragState.targetLane, + ); + } + scope.interaction.endDrag(); + } + + _mode = null; + } + + void _onPanCancel() { + final scope = ZTimelineScope.of(context); + + switch (_mode) { + case _InteractionMode.resizeStart: + case _InteractionMode.resizeEnd: + scope.interaction.cancelResize(); + case _InteractionMode.drag: + scope.interaction.cancelDrag(); + case null: + break; + } + + _mode = null; + } + + @override + Widget build(BuildContext context) { + final pill = EventPill( + color: widget.colorBuilder(widget.entry.entry), + label: widget.labelBuilder(widget.entry.entry), + ); + + final top = LayoutCoordinateService.laneToY( + lane: widget.entry.entry.lane, + laneHeight: widget.laneHeight, + ); + final left = LayoutCoordinateService.normalizedToWidgetX( + normalizedX: widget.entry.startX, + contentWidth: widget.contentWidth, + ); + 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.onEntryResized != null && width >= 16; + + return Positioned( + top: top, + left: left.clamp(0.0, double.infinity), + width: width, + height: widget.laneHeight, + child: ListenableBuilder( + listenable: scope.interaction, + builder: (context, child) { + final entryId = widget.entry.entry.id; + final isDragging = + scope.interaction.isDraggingEntry && + scope.interaction.dragState?.entryId == entryId; + final isResizing = + scope.interaction.isResizingEntry && + scope.interaction.resizeState?.entryId == entryId; + + return Opacity( + opacity: isDragging || isResizing ? 0.3 : 1.0, + child: child, + ); + }, + child: _buildInteractivePill(pill, hasResizeHandles), + ), + ); + } + + 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, + ), + ), + // 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, + ), + ) + else + Positioned.fill( + child: MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + ), + ), + ], + ), + ); + } +} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart index f6da961..85e3f99 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart @@ -98,10 +98,7 @@ class ZTimelineBreadcrumb extends StatelessWidget { } class _BreadcrumbSegmentRow extends StatelessWidget { - const _BreadcrumbSegmentRow({ - required this.segments, - required this.viewport, - }); + const _BreadcrumbSegmentRow({required this.segments, required this.viewport}); final List segments; final TimelineViewportNotifier viewport; @@ -173,9 +170,9 @@ class _ZoomLevelIndicator extends StatelessWidget { ), child: Text( level.label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ); } diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart index 80fa2c8..46cb831 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart @@ -88,7 +88,7 @@ class _ZTimelineInteractorState extends State { // Single-finger pan else if (details.pointerCount == 1 && config.enablePan && - !scope.interaction.isDraggingEntry) { + !scope.interaction.isInteracting) { if (_lastFocalPoint != null) { final diff = details.focalPoint - _lastFocalPoint!; final ratio = -diff.dx / width; @@ -221,9 +221,10 @@ class _ZTimelineInteractorState extends State { listenable: scope.interaction, builder: (context, child) { return MouseRegion( - cursor: - scope.interaction.isGrabbing || - scope.interaction.isDraggingEntry + cursor: scope.interaction.isResizingEntry + ? SystemMouseCursors.resizeColumn + : scope.interaction.isGrabbing || + scope.interaction.isDraggingEntry ? SystemMouseCursors.grabbing : SystemMouseCursors.basic, child: child, diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_scope.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_scope.dart index 1512973..097ece7 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_scope.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_scope.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/interaction_config.dart'; +import '../services/timeline_group_registry.dart'; import '../state/timeline_interaction_notifier.dart'; import '../state/timeline_viewport_notifier.dart'; @@ -61,11 +62,13 @@ class ZTimelineScope extends StatefulWidget { class _ZTimelineScopeState extends State { late final ZTimelineInteractionNotifier _interactionNotifier; + late final TimelineGroupRegistry _groupRegistry; @override void initState() { super.initState(); _interactionNotifier = ZTimelineInteractionNotifier(); + _groupRegistry = TimelineGroupRegistry(); } @override @@ -81,6 +84,7 @@ class _ZTimelineScopeState extends State { viewport: widget.viewport, interaction: _interactionNotifier, config: widget.config, + groupRegistry: _groupRegistry, ), child: widget.child, ); @@ -105,6 +109,7 @@ class ZTimelineScopeData { required this.viewport, required this.interaction, required this.config, + required this.groupRegistry, }); /// The viewport notifier for domain state (start/end times). @@ -116,15 +121,19 @@ class ZTimelineScopeData { /// Configuration for interaction behavior. final ZTimelineInteractionConfig config; + /// Registry for group lane areas, used for cross-group hit detection. + final TimelineGroupRegistry groupRegistry; + @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is ZTimelineScopeData && other.viewport == viewport && other.interaction == interaction && - other.config == config; + other.config == config && + other.groupRegistry == groupRegistry; } @override - int get hashCode => Object.hash(viewport, interaction, config); + int get hashCode => Object.hash(viewport, interaction, config, groupRegistry); } diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart index 81ba4a7..930582b 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart @@ -97,8 +97,9 @@ class ZTimelineTieredHeader extends StatelessWidget { domainEnd: effectiveViewport.end, borderColor: Theme.of(context).colorScheme.outlineVariant, labelColor: Theme.of(context).colorScheme.onSurface, - secondaryLabelColor: - Theme.of(context).colorScheme.onSurfaceVariant, + secondaryLabelColor: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), ), ), @@ -126,9 +127,9 @@ class _ConfigIndicator extends StatelessWidget { color: colorScheme.surfaceContainerHighest, child: Text( 'Zoom: $configName', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ); } @@ -155,10 +156,9 @@ class _TieredHeaderPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final borderPaint = - Paint() - ..color = borderColor - ..strokeWidth = 1.0; + final borderPaint = Paint() + ..color = borderColor + ..strokeWidth = 1.0; // Draw horizontal border between tiers canvas.drawLine( 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 fbf2438..62f993b 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 @@ -2,14 +2,14 @@ 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 'draggable_event_pill.dart'; import 'ghost_overlay.dart'; -import 'group_drop_target.dart'; +import 'interactive_event_pill.dart'; import 'timeline_scope.dart'; typedef EntryLabelBuilder = String Function(TimelineEntry entry); @@ -24,6 +24,15 @@ typedef OnEntryMoved = int newLane, ); +/// Callback signature for when an entry is resized via edge drag. +typedef OnEntryResized = + void Function( + TimelineEntry entry, + DateTime newStart, + DateTime newEnd, + int newLane, + ); + /// Base timeline view: renders groups with between-group headers and /// lane rows containing event pills. class ZTimelineView extends StatelessWidget { @@ -37,6 +46,7 @@ class ZTimelineView extends StatelessWidget { this.laneHeight = ZTimelineConstants.laneHeight, this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight, this.onEntryMoved, + this.onEntryResized, this.enableDrag = true, }); @@ -54,6 +64,9 @@ class ZTimelineView extends StatelessWidget { /// position. The [newLane] is calculated to avoid collisions. final OnEntryMoved? onEntryMoved; + /// Callback invoked when an entry is resized via edge drag. + final OnEntryResized? onEntryResized; + /// Whether drag-and-drop is enabled. final bool enableDrag; @@ -82,13 +95,14 @@ class ZTimelineView extends StatelessWidget { projected[group.id] ?? const []; final lanesCount = _countLanes(groupEntries); - Widget groupColumn = Column( + 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, @@ -96,30 +110,11 @@ class ZTimelineView extends StatelessWidget { labelBuilder: labelBuilder, contentWidth: contentWidth, enableDrag: enableDrag, + onEntryResized: onEntryResized, + onEntryMoved: onEntryMoved, ), ], ); - - // Wrap the entire group (header + lanes) in a DragTarget - // so dragging over headers doesn't create a dead zone. - if (enableDrag && onEntryMoved != null) { - groupColumn = GroupDropTarget( - group: group, - entries: groupEntries, - allEntries: entries, - viewport: viewport, - contentWidth: contentWidth, - laneHeight: laneHeight, - lanesCount: lanesCount, - onEntryMoved: onEntryMoved, - verticalOffset: - groupHeaderHeight + - ZTimelineConstants.verticalOuterPadding, - child: groupColumn, - ); - } - - return groupColumn; }, ); }, @@ -162,10 +157,11 @@ class _GroupHeader extends StatelessWidget { } } -class _GroupLanes extends StatelessWidget { +class _GroupLanes extends StatefulWidget { const _GroupLanes({ required this.group, required this.entries, + required this.allEntries, required this.viewport, required this.lanesCount, required this.laneHeight, @@ -173,10 +169,13 @@ class _GroupLanes extends StatelessWidget { required this.colorBuilder, required this.contentWidth, required this.enableDrag, + this.onEntryResized, + this.onEntryMoved, }); final TimelineGroup group; final List entries; + final List allEntries; final TimelineViewportNotifier viewport; final int lanesCount; final double laneHeight; @@ -184,24 +183,76 @@ class _GroupLanes extends StatelessWidget { final EntryColorBuilder colorBuilder; final double contentWidth; final bool enableDrag; + final OnEntryResized? onEntryResized; + final OnEntryMoved? onEntryMoved; + + @override + State<_GroupLanes> createState() => _GroupLanesState(); +} + +class _GroupLanesState extends State<_GroupLanes> { + final GlobalKey _lanesKey = GlobalKey(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _registerGroup(); + } + + @override + void didUpdateWidget(_GroupLanes oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.group.id != widget.group.id) { + final scope = ZTimelineScope.maybeOf(context); + scope?.groupRegistry.unregister(oldWidget.group.id); + } + if (oldWidget.lanesCount != widget.lanesCount || + oldWidget.laneHeight != widget.laneHeight || + oldWidget.contentWidth != widget.contentWidth || + oldWidget.group.id != widget.group.id) { + _registerGroup(); + } + } + + @override + void dispose() { + final scope = ZTimelineScope.maybeOf(context); + scope?.groupRegistry.unregister(widget.group.id); + super.dispose(); + } + + void _registerGroup() { + final scope = ZTimelineScope.maybeOf(context); + if (scope == null || !widget.enableDrag) return; + + scope.groupRegistry.register( + widget.group.id, + _lanesKey, + verticalOffset: ZTimelineConstants.verticalOuterPadding, + lanesCount: widget.lanesCount, + laneHeight: widget.laneHeight, + contentWidth: widget.contentWidth, + ); + } @override Widget build(BuildContext context) { final scope = ZTimelineScope.maybeOf(context); // If no scope (drag not enabled), use static height - if (scope == null || !enableDrag) { - return _buildContent(context, lanesCount); + if (scope == null || !widget.enableDrag) { + return _buildContent(context, widget.lanesCount); } - // Listen to interaction notifier for drag state changes + // Listen to interaction notifier for drag/resize state changes return ListenableBuilder( listenable: scope.interaction, builder: (context, _) { final effectiveLanesCount = _calculateEffectiveLanesCount( - actualLanesCount: lanesCount, + actualLanesCount: widget.lanesCount, dragState: scope.interaction.dragState, - groupId: group.id, + resizeState: scope.interaction.resizeState, + groupId: widget.group.id, ); return _buildContent(context, effectiveLanesCount); @@ -212,27 +263,31 @@ class _GroupLanes extends StatelessWidget { int _calculateEffectiveLanesCount({ required int actualLanesCount, required EntryDragState? dragState, + required EntryResizeState? resizeState, required String groupId, }) { - // No drag active - use actual lane count - if (dragState == null) { - return actualLanesCount; + var effective = actualLanesCount; + + // Expand for drag target lane + if (dragState != null && dragState.targetGroupId == groupId) { + if (dragState.targetLane > effective) { + effective = dragState.targetLane; + } } - // Drag active but over different group - use actual lane count - if (dragState.targetGroupId != groupId) { - return actualLanesCount; + // Expand for resize target lane + if (resizeState != null && resizeState.originalEntry.groupId == groupId) { + if (resizeState.targetLane > effective) { + effective = resizeState.targetLane; + } } - // Drag active over this group - expand to accommodate target lane - return actualLanesCount > dragState.targetLane - ? actualLanesCount - : dragState.targetLane; + return effective; } Widget _buildContent(BuildContext context, int effectiveLanesCount) { final totalHeight = - effectiveLanesCount * laneHeight + + effectiveLanesCount * widget.laneHeight + (effectiveLanesCount > 0 ? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing : 0); @@ -245,31 +300,58 @@ class _GroupLanes extends StatelessWidget { child: Stack( children: [ // Event pills - for (final e in entries) - DraggableEventPill( + for (final e in widget.entries) + InteractiveEventPill( entry: e, - laneHeight: laneHeight, - labelBuilder: labelBuilder, - colorBuilder: colorBuilder, - contentWidth: contentWidth, - enableDrag: enableDrag, + 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, + groupRegistry: scope?.groupRegistry, ), - // Ghost overlay (rendered in same coordinate space as pills) - if (enableDrag && scope != null) + // Ghost overlay for drag operations + if (widget.enableDrag && scope != null) ListenableBuilder( listenable: scope.interaction, builder: (context, _) { final dragState = scope.interaction.dragState; - if (dragState == null || dragState.targetGroupId != group.id) { - return const SizedBox.shrink(); + 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 GhostOverlay( + 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), + ); } - return GhostOverlay( - dragState: dragState, - viewport: viewport, - contentWidth: contentWidth, - laneHeight: laneHeight, - ); + + // Show ghost for drag-move + if (dragState != null && + dragState.targetGroupId == widget.group.id) { + return GhostOverlay( + targetStart: dragState.targetStart, + targetEnd: dragState.targetEnd, + targetLane: dragState.targetLane, + viewport: widget.viewport, + contentWidth: widget.contentWidth, + laneHeight: widget.laneHeight, + ); + } + + return const SizedBox.shrink(); }, ), ], @@ -281,6 +363,7 @@ class _GroupLanes extends StatelessWidget { curve: Curves.easeInOut, alignment: Alignment.topCenter, child: Padding( + key: _lanesKey, padding: EdgeInsets.symmetric( vertical: ZTimelineConstants.verticalOuterPadding, ), 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 1625449..bc3eb07 100644 --- a/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart +++ b/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart @@ -7,6 +7,7 @@ export 'src/constants.dart'; // Models export 'src/models/breadcrumb_segment.dart'; 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'; @@ -23,6 +24,7 @@ export 'src/services/entry_placement_service.dart'; 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 @@ -31,9 +33,9 @@ export 'src/state/timeline_viewport_notifier.dart'; // Widgets export 'src/widgets/breadcrumb_segment_chip.dart'; -export 'src/widgets/draggable_event_pill.dart'; +export 'src/widgets/event_pill.dart'; export 'src/widgets/ghost_overlay.dart'; -export 'src/widgets/group_drop_target.dart'; +export 'src/widgets/interactive_event_pill.dart'; export 'src/widgets/timeline_breadcrumb.dart'; export 'src/widgets/timeline_interactor.dart'; export 'src/widgets/timeline_scope.dart'; diff --git a/packages/z-flutter/web/index.html b/packages/z-flutter/web/index.html index eb7a840..4d26af4 100644 --- a/packages/z-flutter/web/index.html +++ b/packages/z-flutter/web/index.html @@ -1,46 +1,338 @@ - - - - - + - - - - z_flutter + Zendegi Timeline (Dev) + -