add timeline pkg to flutter

This commit is contained in:
2026-02-24 19:26:17 +01:00
parent 0d1119b2af
commit 28898fb081
33 changed files with 2617 additions and 43 deletions

View File

@@ -0,0 +1,122 @@
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,
);
if (!enableDrag) {
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
width: width.clamp(0.0, double.infinity),
height: laneHeight,
child: pill,
);
}
final scope = ZTimelineScope.of(context);
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
width: width.clamp(0.0, double.infinity),
height: laneHeight,
child: Draggable<TimelineEntry>(
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: pill,
),
);
}
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,
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
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';
/// A semi-transparent ghost overlay showing where an entry will land.
///
/// Displayed during drag operations to give visual feedback about the
/// target position.
class GhostOverlay extends StatelessWidget {
const GhostOverlay({
required this.dragState,
required this.viewport,
required this.contentWidth,
required this.laneHeight,
super.key,
});
final EntryDragState dragState;
final TimelineViewportNotifier viewport;
final double contentWidth;
final double laneHeight;
@override
Widget build(BuildContext context) {
final startX = TimeScaleService.mapTimeToPosition(
dragState.targetStart,
viewport.start,
viewport.end,
);
final endX = TimeScaleService.mapTimeToPosition(
dragState.targetEnd,
viewport.start,
viewport.end,
);
// Use centralized coordinate service to ensure ghost matches pill layout
final left = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: startX,
contentWidth: contentWidth,
);
final width = LayoutCoordinateService.calculateItemWidth(
normalizedWidth: endX - startX,
contentWidth: contentWidth,
);
final top = LayoutCoordinateService.laneToY(
lane: dragState.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,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,119 @@
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,
super.key,
});
final TimelineGroup group;
final List<ProjectedEntry> entries;
final List<TimelineEntry> allEntries;
final TimelineViewportNotifier viewport;
final double contentWidth;
final double laneHeight;
final int lanesCount;
final OnEntryMoved? onEntryMoved;
final Widget child;
@override
Widget build(BuildContext context) {
final scope = ZTimelineScope.of(context);
return DragTarget<TimelineEntry>(
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,
);
final rawLane = LayoutCoordinateService.yToLane(
y: local.dy,
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
},
);
}
}

View File

@@ -0,0 +1,242 @@
import 'package:flutter/gestures.dart'
show PointerScrollEvent, PointerSignalEvent;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'timeline_scope.dart';
/// Handles pan/zoom gestures for the timeline.
///
/// Must be used within a [ZTimelineScope]. Supports:
/// - Two-finger pinch-to-zoom (with focal point)
/// - Single-finger horizontal pan
/// - Ctrl/Cmd + mouse wheel zoom
/// - Horizontal mouse scroll for pan
/// - Keyboard shortcuts: arrows (pan), +/- (zoom)
/// - Mouse cursor feedback (grab/grabbing)
///
/// ```dart
/// ZTimelineScope(
/// viewport: viewport,
/// child: ZTimelineInteractor(
/// child: ZTimelineView(...),
/// ),
/// )
/// ```
class ZTimelineInteractor extends StatefulWidget {
const ZTimelineInteractor({
required this.child,
this.autofocus = true,
super.key,
});
/// The widget to wrap with gesture handling.
final Widget child;
/// Whether to automatically focus this widget for keyboard input.
final bool autofocus;
@override
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
}
class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
double _prevScaleValue = 1.0;
Offset? _lastFocalPoint;
late FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
double get _width {
final renderBox = context.findRenderObject() as RenderBox?;
return renderBox?.size.width ?? 1.0;
}
void _handleScaleStart(ScaleStartDetails details) {
_prevScaleValue = 1.0;
_lastFocalPoint = details.focalPoint;
final scope = ZTimelineScope.of(context);
scope.interaction.setGrabbing(true);
}
void _handleScaleUpdate(ScaleUpdateDetails details) {
final scope = ZTimelineScope.of(context);
final config = scope.config;
final width = _width;
// Two-finger pinch-to-zoom
if (details.pointerCount >= 2 && config.enablePinchZoom) {
if (details.scale != _prevScaleValue) {
final scaleFactor = details.scale / _prevScaleValue;
_prevScaleValue = details.scale;
final focalPosition = (details.focalPoint.dx / width).clamp(0.0, 1.0);
_performZoom(scaleFactor, focusPosition: focalPosition);
}
}
// Single-finger pan
else if (details.pointerCount == 1 &&
config.enablePan &&
!scope.interaction.isDraggingEntry) {
if (_lastFocalPoint != null) {
final diff = details.focalPoint - _lastFocalPoint!;
final ratio = -diff.dx / width;
if (ratio != 0) {
scope.viewport.pan(ratio);
}
}
}
_lastFocalPoint = details.focalPoint;
}
void _handleScaleEnd(ScaleEndDetails details) {
_lastFocalPoint = null;
final scope = ZTimelineScope.of(context);
scope.interaction.setGrabbing(false);
}
void _handlePointerSignal(PointerSignalEvent event) {
if (event is! PointerScrollEvent) return;
final scope = ZTimelineScope.of(context);
final config = scope.config;
final width = _width;
final pressed = HardwareKeyboard.instance.logicalKeysPressed;
final isCtrlOrMeta =
pressed.contains(LogicalKeyboardKey.controlLeft) ||
pressed.contains(LogicalKeyboardKey.controlRight) ||
pressed.contains(LogicalKeyboardKey.metaLeft) ||
pressed.contains(LogicalKeyboardKey.metaRight);
// Ctrl/Cmd + scroll = zoom
if (isCtrlOrMeta && config.enableMouseWheelZoom) {
final renderBox = context.findRenderObject() as RenderBox?;
final local = renderBox?.globalToLocal(event.position) ?? Offset.zero;
final focusPosition = (local.dx / width).clamp(0.0, 1.0);
final factor = event.scrollDelta.dy < 0
? config.zoomFactorIn
: config.zoomFactorOut;
_performZoom(factor, focusPosition: focusPosition);
return;
}
// Horizontal scroll = pan
if (config.enablePan) {
final dx = event.scrollDelta.dx;
if (dx == 0.0) return;
final ratio = dx / width;
if (ratio != 0) {
scope.viewport.pan(ratio);
}
}
}
void _performZoom(double factor, {double focusPosition = 0.5}) {
final scope = ZTimelineScope.of(context);
final config = scope.config;
final viewport = scope.viewport;
// Check limits before zooming
final currentDuration = viewport.end.difference(viewport.start);
final newDurationMs = (currentDuration.inMilliseconds / factor).round();
final newDuration = Duration(milliseconds: newDurationMs);
// Prevent zooming in too far
if (factor > 1 && newDuration < config.minZoomDuration) return;
// Prevent zooming out too far
if (factor < 1 && newDuration > config.maxZoomDuration) return;
viewport.zoom(factor, focusPosition: focusPosition);
}
Map<ShortcutActivator, VoidCallback> _buildKeyboardBindings(
ZTimelineScopeData scope,
) {
final config = scope.config;
if (!config.enableKeyboardShortcuts) {
return const {};
}
return {
// Pan left
const SingleActivator(LogicalKeyboardKey.arrowLeft): () {
if (config.enablePan) {
scope.viewport.pan(-config.keyboardPanRatio);
}
},
// Pan right
const SingleActivator(LogicalKeyboardKey.arrowRight): () {
if (config.enablePan) {
scope.viewport.pan(config.keyboardPanRatio);
}
},
// Zoom in (equals key, typically + without shift)
const SingleActivator(LogicalKeyboardKey.equal): () {
_performZoom(config.zoomFactorIn);
},
// Zoom out
const SingleActivator(LogicalKeyboardKey.minus): () {
_performZoom(config.zoomFactorOut);
},
// Zoom in (numpad +)
const SingleActivator(LogicalKeyboardKey.numpadAdd): () {
_performZoom(config.zoomFactorIn);
},
// Zoom out (numpad -)
const SingleActivator(LogicalKeyboardKey.numpadSubtract): () {
_performZoom(config.zoomFactorOut);
},
};
}
@override
Widget build(BuildContext context) {
final scope = ZTimelineScope.of(context);
return CallbackShortcuts(
bindings: _buildKeyboardBindings(scope),
child: Focus(
autofocus: widget.autofocus,
focusNode: _focusNode,
child: Listener(
onPointerSignal: _handlePointerSignal,
child: ListenableBuilder(
listenable: scope.interaction,
builder: (context, child) {
return MouseRegion(
cursor: scope.interaction.isGrabbing
? SystemMouseCursors.grabbing
: SystemMouseCursors.grab,
child: child,
);
},
child: GestureDetector(
onScaleStart: _handleScaleStart,
onScaleUpdate: _handleScaleUpdate,
onScaleEnd: _handleScaleEnd,
behavior: HitTestBehavior.deferToChild,
child: widget.child,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../models/interaction_config.dart';
import '../state/timeline_interaction_notifier.dart';
import '../state/timeline_viewport_notifier.dart';
/// Provides timeline viewport and interaction state to descendants.
///
/// Similar to Flutter's Theme or MediaQuery pattern. Wrap your timeline
/// widgets with this scope to enable interactions.
///
/// ```dart
/// ZTimelineScope(
/// viewport: myViewportNotifier,
/// config: const ZTimelineInteractionConfig(
/// minZoomDuration: Duration(days: 1),
/// ),
/// child: ZTimelineInteractor(
/// child: ZTimelineView(...),
/// ),
/// )
/// ```
class ZTimelineScope extends StatefulWidget {
const ZTimelineScope({
required this.viewport,
required this.child,
this.config = ZTimelineInteractionConfig.defaults,
super.key,
});
/// The viewport notifier managing the visible time domain.
final TimelineViewportNotifier viewport;
/// Configuration for interaction behavior.
final ZTimelineInteractionConfig config;
/// The widget subtree that can access this scope.
final Widget child;
/// Get the nearest [ZTimelineScopeData] or throw an assertion error.
static ZTimelineScopeData of(BuildContext context) {
final data = maybeOf(context);
assert(
data != null,
'No ZTimelineScope found in context. '
'Wrap your widget tree with ZTimelineScope.',
);
return data!;
}
/// Get the nearest [ZTimelineScopeData] or null if not found.
static ZTimelineScopeData? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_ZTimelineScopeInherited>()
?.data;
}
@override
State<ZTimelineScope> createState() => _ZTimelineScopeState();
}
class _ZTimelineScopeState extends State<ZTimelineScope> {
late final ZTimelineInteractionNotifier _interactionNotifier;
@override
void initState() {
super.initState();
_interactionNotifier = ZTimelineInteractionNotifier();
}
@override
void dispose() {
_interactionNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _ZTimelineScopeInherited(
data: ZTimelineScopeData(
viewport: widget.viewport,
interaction: _interactionNotifier,
config: widget.config,
),
child: widget.child,
);
}
}
class _ZTimelineScopeInherited extends InheritedWidget {
const _ZTimelineScopeInherited({required this.data, required super.child});
final ZTimelineScopeData data;
@override
bool updateShouldNotify(_ZTimelineScopeInherited oldWidget) {
return data != oldWidget.data;
}
}
/// Data provided by [ZTimelineScope].
@immutable
class ZTimelineScopeData {
const ZTimelineScopeData({
required this.viewport,
required this.interaction,
required this.config,
});
/// The viewport notifier for domain state (start/end times).
final TimelineViewportNotifier viewport;
/// The interaction notifier for transient UI state (grabbing, dragging).
final ZTimelineInteractionNotifier interaction;
/// Configuration for interaction behavior.
final ZTimelineInteractionConfig config;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ZTimelineScopeData &&
other.viewport == viewport &&
other.interaction == interaction &&
other.config == config;
}
@override
int get hashCode => Object.hash(viewport, interaction, config);
}

View File

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import '../constants.dart';
import '../models/entry_drag_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 'timeline_scope.dart';
typedef EntryLabelBuilder = String Function(TimelineEntry entry);
typedef EntryColorBuilder = Color Function(TimelineEntry entry);
/// Callback signature for when an entry is moved via drag-and-drop.
typedef OnEntryMoved =
void Function(
TimelineEntry entry,
DateTime newStart,
String newGroupId,
int newLane,
);
/// Base timeline view: renders groups with between-group headers and
/// lane rows containing event pills.
class ZTimelineView extends StatelessWidget {
const ZTimelineView({
super.key,
required this.groups,
required this.entries,
required this.viewport,
required this.labelBuilder,
required this.colorBuilder,
this.laneHeight = ZTimelineConstants.laneHeight,
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
this.onEntryMoved,
this.enableDrag = true,
});
final List<TimelineGroup> groups;
final List<TimelineEntry> entries;
final TimelineViewportNotifier viewport;
final EntryLabelBuilder labelBuilder;
final EntryColorBuilder colorBuilder;
final double laneHeight;
final double groupHeaderHeight;
/// Callback invoked when an entry is moved via drag-and-drop.
///
/// The consumer is responsible for updating their state with the new
/// position. The [newLane] is calculated to avoid collisions.
final OnEntryMoved? onEntryMoved;
/// Whether drag-and-drop is enabled.
final bool enableDrag;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: viewport,
builder: (context, _) {
final projected = const TimelineProjectionService().project(
entries: entries,
domainStart: viewport.start,
domainEnd: viewport.end,
);
return LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: ZTimelineConstants.minContentWidth;
return ListView.builder(
itemCount: groups.length,
itemBuilder: (context, index) {
final group = groups[index];
final groupEntries =
projected[group.id] ?? const <ProjectedEntry>[];
final lanesCount = _countLanes(groupEntries);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_GroupHeader(title: group.title, height: groupHeaderHeight),
_GroupLanes(
group: group,
entries: groupEntries,
allEntries: entries,
viewport: viewport,
lanesCount: lanesCount,
laneHeight: laneHeight,
colorBuilder: colorBuilder,
labelBuilder: labelBuilder,
contentWidth: contentWidth,
onEntryMoved: onEntryMoved,
enableDrag: enableDrag,
),
],
);
},
);
},
);
},
);
}
int _countLanes(List<ProjectedEntry> entries) {
var maxLane = 0;
for (final e in entries) {
if (e.entry.lane > maxLane) maxLane = e.entry.lane;
}
return maxLane.clamp(0, 1000); // basic guard
}
}
class _GroupHeader extends StatelessWidget {
const _GroupHeader({required this.title, required this.height});
final String title;
final double height;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
height: height,
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
),
),
child: Text(title, style: Theme.of(context).textTheme.titleSmall),
);
}
}
class _GroupLanes extends StatelessWidget {
const _GroupLanes({
required this.group,
required this.entries,
required this.allEntries,
required this.viewport,
required this.lanesCount,
required this.laneHeight,
required this.labelBuilder,
required this.colorBuilder,
required this.contentWidth,
required this.onEntryMoved,
required this.enableDrag,
});
final TimelineGroup group;
final List<ProjectedEntry> entries;
final List<TimelineEntry> allEntries;
final TimelineViewportNotifier viewport;
final int lanesCount;
final double laneHeight;
final EntryLabelBuilder labelBuilder;
final EntryColorBuilder colorBuilder;
final double contentWidth;
final OnEntryMoved? onEntryMoved;
final bool enableDrag;
@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);
}
// Listen to interaction notifier for drag state changes
return ListenableBuilder(
listenable: scope.interaction,
builder: (context, _) {
final effectiveLanesCount = _calculateEffectiveLanesCount(
actualLanesCount: lanesCount,
dragState: scope.interaction.dragState,
groupId: group.id,
);
return _buildContent(context, effectiveLanesCount);
},
);
}
int _calculateEffectiveLanesCount({
required int actualLanesCount,
required EntryDragState? dragState,
required String groupId,
}) {
// No drag active - use actual lane count
if (dragState == null) {
return actualLanesCount;
}
// Drag active but over different group - use actual lane count
if (dragState.targetGroupId != groupId) {
return actualLanesCount;
}
// Drag active over this group - expand to accommodate target lane
return actualLanesCount > dragState.targetLane
? actualLanesCount
: dragState.targetLane;
}
Widget _buildContent(BuildContext context, int effectiveLanesCount) {
final totalHeight =
effectiveLanesCount * laneHeight +
(effectiveLanesCount > 0
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
: 0);
final scope = ZTimelineScope.maybeOf(context);
// The inner Stack with pills and ghost overlay
Widget innerStack = SizedBox(
height: totalHeight,
width: double.infinity,
child: Stack(
children: [
// Event pills
for (final e in entries)
DraggableEventPill(
entry: e,
laneHeight: laneHeight,
labelBuilder: labelBuilder,
colorBuilder: colorBuilder,
contentWidth: contentWidth,
enableDrag: enableDrag,
),
// Ghost overlay (rendered in same coordinate space as pills)
if (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();
}
return GhostOverlay(
dragState: dragState,
viewport: viewport,
contentWidth: contentWidth,
laneHeight: laneHeight,
);
},
),
],
),
);
// Wrap with DragTarget if drag is enabled
if (enableDrag && onEntryMoved != null) {
innerStack = GroupDropTarget(
group: group,
entries: entries,
allEntries: allEntries,
viewport: viewport,
contentWidth: contentWidth,
laneHeight: laneHeight,
lanesCount: lanesCount,
onEntryMoved: onEntryMoved,
child: innerStack,
);
}
return AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.symmetric(
vertical: ZTimelineConstants.verticalOuterPadding,
),
child: innerStack,
),
);
}
}