add timeline pkg to flutter
This commit is contained in:
122
packages/z-timeline/lib/src/widgets/draggable_event_pill.dart
Normal file
122
packages/z-timeline/lib/src/widgets/draggable_event_pill.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
77
packages/z-timeline/lib/src/widgets/ghost_overlay.dart
Normal file
77
packages/z-timeline/lib/src/widgets/ghost_overlay.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
packages/z-timeline/lib/src/widgets/group_drop_target.dart
Normal file
119
packages/z-timeline/lib/src/widgets/group_drop_target.dart
Normal 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
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
242
packages/z-timeline/lib/src/widgets/timeline_interactor.dart
Normal file
242
packages/z-timeline/lib/src/widgets/timeline_interactor.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
130
packages/z-timeline/lib/src/widgets/timeline_scope.dart
Normal file
130
packages/z-timeline/lib/src/widgets/timeline_scope.dart
Normal 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);
|
||||
}
|
||||
291
packages/z-timeline/lib/src/widgets/timeline_view.dart
Normal file
291
packages/z-timeline/lib/src/widgets/timeline_view.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user