This commit is contained in:
2026-03-04 14:16:51 +01:00
parent 1cca200eda
commit 765aa83fb6
24 changed files with 1370 additions and 424 deletions

View File

@@ -169,6 +169,47 @@ class _MainAppState extends State<MainApp> {
});
}
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<String, TimelineItemData>.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', <String, Object?>{
'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<MainApp> {
colorBuilder: _colorForEntry,
enableDrag: true,
onEntryMoved: _onEntryMoved,
onEntryResized: _onEntryResized,
),
),
),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)) {

View File

@@ -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 = <String, _GroupRegistration>{};
/// 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;
}

View File

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

View File

@@ -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),
),
),
),

View File

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

View File

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

View File

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

View File

@@ -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<ProjectedEntry> entries;
final List<TimelineEntry> 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<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,
);
// 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
},
);
}
}

View File

@@ -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<TimelineEntry> allEntries;
final OnEntryResized? onEntryResized;
final OnEntryMoved? onEntryMoved;
final TimelineGroupRegistry? groupRegistry;
@override
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
}
class _InteractiveEventPillState extends State<InteractiveEventPill> {
_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,
),
),
],
),
);
}
}

View File

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

View File

@@ -88,7 +88,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
// 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<ZTimelineInteractor> {
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,

View File

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

View File

@@ -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(

View File

@@ -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 <ProjectedEntry>[];
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<ProjectedEntry> entries;
final List<TimelineEntry> 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,
),

View File

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

View File

@@ -1,46 +1,338 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="description" content="Zendegi Timeline - Dev Mode">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="z_flutter">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>z_flutter</title>
<title>Zendegi Timeline (Dev)</title>
<link rel="manifest" href="manifest.json">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
flex-direction: column;
height: 100vh;
}
#toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #16213e;
border-bottom: 1px solid #0f3460;
font-size: 13px;
flex-shrink: 0;
flex-wrap: wrap;
}
#toolbar .group { display: flex; align-items: center; gap: 4px; }
#toolbar label { color: #8899aa; }
#toolbar button {
padding: 4px 10px;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
cursor: pointer;
font-size: 12px;
}
#toolbar button:hover { background: #0f3460; }
#toolbar .sep { width: 1px; height: 20px; background: #0f3460; }
#event-log {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 180px;
overflow-y: auto;
background: #0d1117;
border-top: 1px solid #0f3460;
font-family: monospace;
font-size: 12px;
padding: 6px 10px;
z-index: 100;
}
#event-log .entry { padding: 2px 0; border-bottom: 1px solid #161b22; }
#event-log .type { color: #58a6ff; }
#event-log .time { color: #6e7681; margin-right: 8px; }
#event-log .payload { color: #8b949e; }
#flutter-container { flex: 1; min-height: 0; }
</style>
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
<div id="toolbar">
<div class="group">
<label>Theme:</label>
<button id="btn-light">Light</button>
<button id="btn-dark">Dark</button>
</div>
<div class="sep"></div>
<div class="group">
<label>Data:</label>
<button id="btn-few">Few items</button>
<button id="btn-many">Many items</button>
<button id="btn-empty">Empty</button>
</div>
<div class="sep"></div>
<div class="group">
<button id="btn-push">Push state</button>
<button id="btn-clear-log">Clear log</button>
</div>
</div>
<div id="flutter-container"></div>
<div id="event-log"></div>
<script>
// -----------------------------------------------------------------------
// Test data generators
// -----------------------------------------------------------------------
function makeId() {
return crypto.randomUUID();
}
function iso(year, month, day) {
return new Date(year, month - 1, day).toISOString();
}
function buildFewItems() {
const g1 = makeId();
const g2 = makeId();
const g3 = makeId();
const items = {};
const addItem = (groupId, title, start, end, lane, desc) => {
const id = makeId();
items[id] = {
id, groupId, title, description: desc ?? null,
start, end: end ?? null, lane,
};
};
// Work group
addItem(g1, "Project Alpha", iso(2026, 1, 5), iso(2026, 3, 15), 1, "Main project");
addItem(g1, "Project Beta", iso(2026, 2, 10), iso(2026, 5, 20), 2, "Secondary project");
addItem(g1, "Code Review", iso(2026, 3, 1), iso(2026, 3, 5), 1);
addItem(g1, "Sprint Planning", iso(2026, 1, 15), null, 3); // point event
// Personal group
addItem(g2, "Vacation", iso(2026, 4, 1), iso(2026, 4, 14), 1, "Spring break");
addItem(g2, "Birthday", iso(2026, 6, 12), null, 1); // point event
addItem(g2, "Move apartments", iso(2026, 3, 20), iso(2026, 3, 25), 2);
// Learning group
addItem(g3, "Flutter course", iso(2026, 1, 1), iso(2026, 2, 28), 1);
addItem(g3, "Rust book", iso(2026, 2, 15), iso(2026, 4, 30), 2);
addItem(g3, "Conference talk", iso(2026, 5, 10), null, 1); // point event
return {
timeline: { id: makeId(), title: "My Timeline" },
groups: {
[g1]: { id: g1, title: "Work", sortOrder: 0 },
[g2]: { id: g2, title: "Personal", sortOrder: 1 },
[g3]: { id: g3, title: "Learning", sortOrder: 2 },
},
items,
groupOrder: [g1, g2, g3],
selectedItemId: null,
darkMode: true,
};
}
function buildManyItems() {
const groupCount = 5;
const groupIds = Array.from({ length: groupCount }, makeId);
const groupNames = ["Engineering", "Design", "Marketing", "Operations", "Research"];
const groups = {};
for (let i = 0; i < groupCount; i++) {
groups[groupIds[i]] = { id: groupIds[i], title: groupNames[i], sortOrder: i };
}
const items = {};
const baseDate = new Date(2026, 0, 1);
let itemIndex = 0;
for (const gId of groupIds) {
for (let lane = 1; lane <= 3; lane++) {
for (let j = 0; j < 4; j++) {
const id = makeId();
const startOffset = j * 45 + lane * 5 + Math.floor(Math.random() * 10);
const duration = 14 + Math.floor(Math.random() * 30);
const start = new Date(baseDate);
start.setDate(start.getDate() + startOffset);
const end = new Date(start);
end.setDate(end.getDate() + duration);
const isPoint = Math.random() < 0.15;
items[id] = {
id, groupId: gId,
title: `Task ${++itemIndex}`,
description: null,
start: start.toISOString(),
end: isPoint ? null : end.toISOString(),
lane,
};
}
}
}
return {
timeline: { id: makeId(), title: "Large Timeline" },
groups, items,
groupOrder: groupIds,
selectedItemId: null,
darkMode: true,
};
}
function buildEmpty() {
const g1 = makeId();
return {
timeline: { id: makeId(), title: "Empty Timeline" },
groups: { [g1]: { id: g1, title: "Untitled Group", sortOrder: 0 } },
items: {},
groupOrder: [g1],
selectedItemId: null,
darkMode: true,
};
}
// -----------------------------------------------------------------------
// Bridge state
// -----------------------------------------------------------------------
let currentState = buildFewItems();
let updateStateCallback = null;
window.__zendegi__ = {
getState: () => JSON.stringify(currentState),
onEvent: (json) => {
const event = JSON.parse(json);
logEvent(event);
handleEvent(event);
},
set updateState(cb) { updateStateCallback = cb; },
get updateState() { return updateStateCallback; },
};
function pushState(state) {
currentState = state;
if (updateStateCallback) {
updateStateCallback(JSON.stringify(state));
}
}
// -----------------------------------------------------------------------
// Event handling
// -----------------------------------------------------------------------
function handleEvent(event) {
switch (event.type) {
case "content_height": {
const container = document.getElementById("flutter-container");
if (container) container.style.height = event.payload.height + "px";
break;
}
case "entry_moved": {
const { entryId, newStart, newEnd, newGroupId, newLane } = event.payload;
const item = currentState.items[entryId];
if (item) {
currentState.items[entryId] = {
...item,
start: newStart,
end: newEnd,
groupId: newGroupId,
lane: newLane,
};
}
break;
}
case "entry_resized": {
const { entryId, newStart, newEnd, groupId, lane } = event.payload;
const item = currentState.items[entryId];
if (item) {
currentState.items[entryId] = {
...item,
start: newStart,
end: newEnd,
groupId: groupId,
lane: lane,
};
}
break;
}
case "item_selected":
currentState.selectedItemId = event.payload.itemId;
break;
case "item_deselected":
currentState.selectedItemId = null;
break;
}
}
// -----------------------------------------------------------------------
// Event log
// -----------------------------------------------------------------------
function logEvent(event) {
const log = document.getElementById("event-log");
const entry = document.createElement("div");
entry.className = "entry";
const now = new Date().toLocaleTimeString("en-GB", { hour12: false });
const payloadStr = event.payload ? " " + JSON.stringify(event.payload) : "";
entry.innerHTML =
`<span class="time">${now}</span>` +
`<span class="type">${event.type}</span>` +
`<span class="payload">${payloadStr}</span>`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
// -----------------------------------------------------------------------
// Toolbar actions
// -----------------------------------------------------------------------
document.getElementById("btn-dark").addEventListener("click", () => {
currentState.darkMode = true;
document.body.style.background = "#1a1a2e";
pushState(currentState);
});
document.getElementById("btn-light").addEventListener("click", () => {
currentState.darkMode = false;
document.body.style.background = "#f0f0f0";
document.body.style.color = "#333";
pushState(currentState);
});
document.getElementById("btn-few").addEventListener("click", () => {
pushState(buildFewItems());
});
document.getElementById("btn-many").addEventListener("click", () => {
pushState(buildManyItems());
});
document.getElementById("btn-empty").addEventListener("click", () => {
pushState(buildEmpty());
});
document.getElementById("btn-push").addEventListener("click", () => {
pushState(currentState);
});
document.getElementById("btn-clear-log").addEventListener("click", () => {
document.getElementById("event-log").innerHTML = "";
});
</script>
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>