This commit is contained in:
2026-03-06 14:38:34 +01:00
parent 44ffe219b1
commit dc524cad24
7 changed files with 296 additions and 124 deletions

View File

@@ -19,6 +19,11 @@ class ZTimelineConstants {
vertical: 6.0, vertical: 6.0,
); );
// Point events (hasEnd == false)
static const double pointEventCircleDiameter = 12.0;
static const double pointEventCircleTextGap = 6.0;
static const double pointEventTextGap = 8.0;
// Resize handles // Resize handles
static const double resizeHandleWidth = 6.0; static const double resizeHandleWidth = 6.0;
static const Duration minResizeDuration = Duration(hours: 1); static const Duration minResizeDuration = Duration(hours: 1);

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../services/layout_coordinate_service.dart';
import '../services/time_scale_service.dart';
import '../state/timeline_viewport_notifier.dart';
import 'event_pill.dart';
import 'event_point.dart';
/// A preview overlay showing where an entry will land during drag/resize.
///
/// Renders the actual [EventPill] or [EventPoint] at full opacity so the
/// preview looks identical to the real item.
class DragPreview extends StatelessWidget {
const DragPreview({
required this.targetStart,
required this.targetEnd,
required this.targetLane,
required this.viewport,
required this.contentWidth,
required this.laneHeight,
required this.color,
required this.label,
this.hasEnd = true,
super.key,
});
final DateTime targetStart;
final DateTime targetEnd;
final int targetLane;
final TimelineViewportNotifier viewport;
final double contentWidth;
final double laneHeight;
final Color color;
final String label;
/// Whether this is a range event. When false, renders [EventPoint].
final bool hasEnd;
@override
Widget build(BuildContext context) {
final startX = TimeScaleService.mapTimeToPosition(
targetStart,
viewport.start,
viewport.end,
);
final top = LayoutCoordinateService.laneToY(
lane: targetLane,
laneHeight: laneHeight,
);
final left = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: startX,
contentWidth: contentWidth,
);
if (!hasEnd) {
return Positioned(
left: left.clamp(0.0, double.infinity),
top: top,
height: laneHeight,
child: IgnorePointer(child: EventPoint(color: color, label: label)),
);
}
final endX = TimeScaleService.mapTimeToPosition(
targetEnd,
viewport.start,
viewport.end,
);
final width = LayoutCoordinateService.calculateItemWidth(
normalizedWidth: endX - startX,
contentWidth: contentWidth,
);
return Positioned(
left: left.clamp(0.0, double.infinity),
width: width.clamp(0.0, double.infinity),
top: top,
height: laneHeight,
child: IgnorePointer(child: EventPill(color: color, label: label)),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../constants.dart';
/// A point-event widget: small circle with a text label to its right.
///
/// Used for entries with [TimelineEntry.hasEnd] == false.
class EventPoint extends StatelessWidget {
const EventPoint({
required this.color,
required this.label,
this.maxTextWidth,
super.key,
});
final Color color;
final String label;
/// Maximum width for the label text. When provided, text truncates with
/// ellipsis. When null the text sizes naturally.
final double? maxTextWidth;
@override
Widget build(BuildContext context) {
final onColor =
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
? Colors.white
: Colors.black87;
final textWidget = Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: onColor,
fontWeight: FontWeight.w400,
),
);
final showText = maxTextWidth == null || maxTextWidth! > 0;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: ZTimelineConstants.pointEventCircleDiameter,
height: ZTimelineConstants.pointEventCircleDiameter,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
if (showText) ...[
const SizedBox(width: ZTimelineConstants.pointEventCircleTextGap),
if (maxTextWidth != null)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxTextWidth!),
child: textWidget,
)
else
textWidget,
],
],
);
}
}

View File

@@ -1,113 +0,0 @@
import 'package:flutter/material.dart';
import '../constants.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 and resize operations to give visual feedback about
/// the target position.
class GhostOverlay extends StatelessWidget {
const GhostOverlay({
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 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(
targetStart,
viewport.start,
viewport.end,
);
final endX = TimeScaleService.mapTimeToPosition(
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: targetLane,
laneHeight: laneHeight,
);
return Positioned(
left: left.clamp(0.0, double.infinity),
width: width.clamp(0.0, double.infinity),
top: top,
height: laneHeight,
child: IgnorePointer(
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

@@ -10,6 +10,7 @@ import '../services/time_scale_service.dart';
import '../services/timeline_group_registry.dart'; import '../services/timeline_group_registry.dart';
import '../state/timeline_viewport_notifier.dart'; import '../state/timeline_viewport_notifier.dart';
import 'event_pill.dart'; import 'event_pill.dart';
import 'event_point.dart';
import 'timeline_scope.dart'; import 'timeline_scope.dart';
import 'timeline_view.dart'; import 'timeline_view.dart';
@@ -31,6 +32,7 @@ class InteractiveEventPill extends StatefulWidget {
this.onEntryResized, this.onEntryResized,
this.onEntryMoved, this.onEntryMoved,
this.groupRegistry, this.groupRegistry,
this.nextEntryStartX,
super.key, super.key,
}); });
@@ -46,11 +48,16 @@ class InteractiveEventPill extends StatefulWidget {
final OnEntryMoved? onEntryMoved; final OnEntryMoved? onEntryMoved;
final TimelineGroupRegistry? groupRegistry; final TimelineGroupRegistry? groupRegistry;
/// Normalized startX of the next entry in the same lane, used to compute
/// available width for point events.
final double? nextEntryStartX;
@override @override
State<InteractiveEventPill> createState() => _InteractiveEventPillState(); State<InteractiveEventPill> createState() => _InteractiveEventPillState();
} }
class _InteractiveEventPillState extends State<InteractiveEventPill> { class _InteractiveEventPillState extends State<InteractiveEventPill> {
final _pointInteractionKey = GlobalKey();
_InteractionMode? _mode; _InteractionMode? _mode;
double _cumulativeDx = 0; double _cumulativeDx = 0;
@@ -66,6 +73,8 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
} }
_InteractionMode _determineMode(Offset localPosition) { _InteractionMode _determineMode(Offset localPosition) {
if (!widget.entry.entry.hasEnd) return _InteractionMode.drag;
final pillWidth = _pillWidth; final pillWidth = _pillWidth;
const handleWidth = ZTimelineConstants.resizeHandleWidth; const handleWidth = ZTimelineConstants.resizeHandleWidth;
@@ -273,10 +282,9 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pill = EventPill( final isPoint = !widget.entry.entry.hasEnd;
color: widget.colorBuilder(widget.entry.entry), final color = widget.colorBuilder(widget.entry.entry);
label: widget.labelBuilder(widget.entry.entry), final label = widget.labelBuilder(widget.entry.entry);
);
final top = LayoutCoordinateService.laneToY( final top = LayoutCoordinateService.laneToY(
lane: widget.entry.entry.lane, lane: widget.entry.entry.lane,
@@ -286,6 +294,68 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
normalizedX: widget.entry.startX, normalizedX: widget.entry.startX,
contentWidth: widget.contentWidth, contentWidth: widget.contentWidth,
); );
if (isPoint) {
// Available width from this entry to the next in the same lane
// (or to the right edge of the content area).
final availableWidth = widget.nextEntryStartX != null
? (widget.nextEntryStartX! - widget.entry.startX) *
widget.contentWidth
: (1.0 - widget.entry.startX) * widget.contentWidth;
final maxTextWidth = availableWidth -
ZTimelineConstants.pointEventCircleDiameter -
ZTimelineConstants.pointEventCircleTextGap -
ZTimelineConstants.pointEventTextGap;
final pointWidget = EventPoint(
color: color,
label: label,
maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0,
);
if (!widget.enableDrag) {
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
width: availableWidth.clamp(
ZTimelineConstants.pointEventCircleDiameter,
double.infinity,
),
height: widget.laneHeight,
child: Align(alignment: Alignment.centerLeft, child: pointWidget),
);
}
final scope = ZTimelineScope.of(context);
return Positioned(
top: top,
left: left.clamp(0.0, double.infinity),
width: availableWidth.clamp(
ZTimelineConstants.pointEventCircleDiameter,
double.infinity,
),
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;
return Opacity(
opacity: isDragging ? 0.3 : 1.0,
child: child,
);
},
child: _buildInteractivePoint(pointWidget),
),
);
}
// Range event (hasEnd == true)
final pill = EventPill(color: color, label: label);
final width = _pillWidth; final width = _pillWidth;
if (!widget.enableDrag) { if (!widget.enableDrag) {
@@ -299,7 +369,8 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
} }
final scope = ZTimelineScope.of(context); final scope = ZTimelineScope.of(context);
final hasResizeHandles = widget.onEntryResized != null && width >= 16; final hasResizeHandles =
widget.entry.entry.hasEnd && widget.onEntryResized != null && width >= 16;
return Positioned( return Positioned(
top: top, top: top,
@@ -341,6 +412,39 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
scope.interaction.clearHoveredEntry(); scope.interaction.clearHoveredEntry();
} }
void _onPointHoverEnter() {
final box =
_pointInteractionKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.attached) return;
final topLeft = box.localToGlobal(Offset.zero);
final rect = topLeft & box.size;
final scope = ZTimelineScope.of(context);
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
}
Widget _buildInteractivePoint(Widget pointWidget) {
return Align(
alignment: Alignment.centerLeft,
child: MouseRegion(
key: _pointInteractionKey,
cursor: SystemMouseCursors.click,
opaque: false,
hitTestBehavior: HitTestBehavior.deferToChild,
onEnter: (_) => _onPointHoverEnter(),
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: _onPanDown,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onPanCancel: _onPanCancel,
child: pointWidget,
),
),
);
}
Widget _buildInteractivePill(Widget pill, bool hasResizeHandles) { Widget _buildInteractivePill(Widget pill, bool hasResizeHandles) {
return MouseRegion( return MouseRegion(
opaque: false, opaque: false,

View File

@@ -8,7 +8,7 @@ import '../models/timeline_entry.dart';
import '../models/timeline_group.dart'; import '../models/timeline_group.dart';
import '../services/timeline_projection_service.dart'; import '../services/timeline_projection_service.dart';
import '../state/timeline_viewport_notifier.dart'; import '../state/timeline_viewport_notifier.dart';
import 'ghost_overlay.dart'; import 'drag_preview.dart';
import 'interactive_event_pill.dart'; import 'interactive_event_pill.dart';
import 'timeline_scope.dart'; import 'timeline_scope.dart';
@@ -285,6 +285,24 @@ class _GroupLanesState extends State<_GroupLanes> {
return effective; return effective;
} }
/// Finds the startX of the next entry in the same lane, or null if none.
double? _findNextEntryStartX(List<ProjectedEntry> entries, int index) {
final current = entries[index];
final lane = current.entry.lane;
final startX = current.startX;
double? closest;
for (var j = 0; j < entries.length; j++) {
if (j == index) continue;
final other = entries[j];
if (other.entry.lane != lane) continue;
if (other.startX <= startX) continue;
if (closest == null || other.startX < closest) {
closest = other.startX;
}
}
return closest;
}
Widget _buildContent(BuildContext context, int effectiveLanesCount) { Widget _buildContent(BuildContext context, int effectiveLanesCount) {
final totalHeight = final totalHeight =
effectiveLanesCount * widget.laneHeight + effectiveLanesCount * widget.laneHeight +
@@ -300,9 +318,9 @@ class _GroupLanesState extends State<_GroupLanes> {
child: Stack( child: Stack(
children: [ children: [
// Event pills // Event pills
for (final e in widget.entries) for (var i = 0; i < widget.entries.length; i++)
InteractiveEventPill( InteractiveEventPill(
entry: e, entry: widget.entries[i],
laneHeight: widget.laneHeight, laneHeight: widget.laneHeight,
labelBuilder: widget.labelBuilder, labelBuilder: widget.labelBuilder,
colorBuilder: widget.colorBuilder, colorBuilder: widget.colorBuilder,
@@ -313,6 +331,10 @@ class _GroupLanesState extends State<_GroupLanes> {
onEntryResized: widget.onEntryResized, onEntryResized: widget.onEntryResized,
onEntryMoved: widget.onEntryMoved, onEntryMoved: widget.onEntryMoved,
groupRegistry: scope?.groupRegistry, groupRegistry: scope?.groupRegistry,
nextEntryStartX: _findNextEntryStartX(
widget.entries,
i,
),
), ),
// Ghost overlay for drag operations // Ghost overlay for drag operations
@@ -326,7 +348,7 @@ class _GroupLanesState extends State<_GroupLanes> {
// Show ghost for resize (entry stays in its own group) // Show ghost for resize (entry stays in its own group)
if (resizeState != null && if (resizeState != null &&
resizeState.originalEntry.groupId == widget.group.id) { resizeState.originalEntry.groupId == widget.group.id) {
return GhostOverlay( return DragPreview(
targetStart: resizeState.targetStart, targetStart: resizeState.targetStart,
targetEnd: resizeState.targetEnd, targetEnd: resizeState.targetEnd,
targetLane: resizeState.targetLane, targetLane: resizeState.targetLane,
@@ -335,19 +357,23 @@ class _GroupLanesState extends State<_GroupLanes> {
laneHeight: widget.laneHeight, laneHeight: widget.laneHeight,
color: widget.colorBuilder(resizeState.originalEntry), color: widget.colorBuilder(resizeState.originalEntry),
label: widget.labelBuilder(resizeState.originalEntry), label: widget.labelBuilder(resizeState.originalEntry),
hasEnd: resizeState.originalEntry.hasEnd,
); );
} }
// Show ghost for drag-move // Show ghost for drag-move
if (dragState != null && if (dragState != null &&
dragState.targetGroupId == widget.group.id) { dragState.targetGroupId == widget.group.id) {
return GhostOverlay( return DragPreview(
targetStart: dragState.targetStart, targetStart: dragState.targetStart,
targetEnd: dragState.targetEnd, targetEnd: dragState.targetEnd,
targetLane: dragState.targetLane, targetLane: dragState.targetLane,
viewport: widget.viewport, viewport: widget.viewport,
contentWidth: widget.contentWidth, contentWidth: widget.contentWidth,
laneHeight: widget.laneHeight, laneHeight: widget.laneHeight,
color: widget.colorBuilder(dragState.originalEntry),
label: widget.labelBuilder(dragState.originalEntry),
hasEnd: dragState.originalEntry.hasEnd,
); );
} }

View File

@@ -35,7 +35,8 @@ export 'src/state/timeline_viewport_notifier.dart';
export 'src/widgets/breadcrumb_segment_chip.dart'; export 'src/widgets/breadcrumb_segment_chip.dart';
export 'src/widgets/entry_popover_overlay.dart'; export 'src/widgets/entry_popover_overlay.dart';
export 'src/widgets/event_pill.dart'; export 'src/widgets/event_pill.dart';
export 'src/widgets/ghost_overlay.dart'; export 'src/widgets/event_point.dart';
export 'src/widgets/drag_preview.dart';
export 'src/widgets/interactive_event_pill.dart'; export 'src/widgets/interactive_event_pill.dart';
export 'src/widgets/timeline_breadcrumb.dart'; export 'src/widgets/timeline_breadcrumb.dart';
export 'src/widgets/timeline_interactor.dart'; export 'src/widgets/timeline_interactor.dart';