event
This commit is contained in:
@@ -19,6 +19,11 @@ class ZTimelineConstants {
|
||||
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
|
||||
static const double resizeHandleWidth = 6.0;
|
||||
static const Duration minResizeDuration = Duration(hours: 1);
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../services/time_scale_service.dart';
|
||||
import '../services/timeline_group_registry.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'event_pill.dart';
|
||||
import 'event_point.dart';
|
||||
import 'timeline_scope.dart';
|
||||
import 'timeline_view.dart';
|
||||
|
||||
@@ -31,6 +32,7 @@ class InteractiveEventPill extends StatefulWidget {
|
||||
this.onEntryResized,
|
||||
this.onEntryMoved,
|
||||
this.groupRegistry,
|
||||
this.nextEntryStartX,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -46,11 +48,16 @@ class InteractiveEventPill extends StatefulWidget {
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
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
|
||||
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
|
||||
}
|
||||
|
||||
class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
final _pointInteractionKey = GlobalKey();
|
||||
_InteractionMode? _mode;
|
||||
double _cumulativeDx = 0;
|
||||
|
||||
@@ -66,6 +73,8 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
}
|
||||
|
||||
_InteractionMode _determineMode(Offset localPosition) {
|
||||
if (!widget.entry.entry.hasEnd) return _InteractionMode.drag;
|
||||
|
||||
final pillWidth = _pillWidth;
|
||||
const handleWidth = ZTimelineConstants.resizeHandleWidth;
|
||||
|
||||
@@ -273,10 +282,9 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pill = EventPill(
|
||||
color: widget.colorBuilder(widget.entry.entry),
|
||||
label: widget.labelBuilder(widget.entry.entry),
|
||||
);
|
||||
final isPoint = !widget.entry.entry.hasEnd;
|
||||
final color = widget.colorBuilder(widget.entry.entry);
|
||||
final label = widget.labelBuilder(widget.entry.entry);
|
||||
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: widget.entry.entry.lane,
|
||||
@@ -286,6 +294,68 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
normalizedX: widget.entry.startX,
|
||||
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;
|
||||
|
||||
if (!widget.enableDrag) {
|
||||
@@ -299,7 +369,8 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
}
|
||||
|
||||
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(
|
||||
top: top,
|
||||
@@ -341,6 +412,39 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
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) {
|
||||
return MouseRegion(
|
||||
opaque: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ import '../models/timeline_entry.dart';
|
||||
import '../models/timeline_group.dart';
|
||||
import '../services/timeline_projection_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'ghost_overlay.dart';
|
||||
import 'drag_preview.dart';
|
||||
import 'interactive_event_pill.dart';
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
@@ -285,6 +285,24 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
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) {
|
||||
final totalHeight =
|
||||
effectiveLanesCount * widget.laneHeight +
|
||||
@@ -300,9 +318,9 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Event pills
|
||||
for (final e in widget.entries)
|
||||
for (var i = 0; i < widget.entries.length; i++)
|
||||
InteractiveEventPill(
|
||||
entry: e,
|
||||
entry: widget.entries[i],
|
||||
laneHeight: widget.laneHeight,
|
||||
labelBuilder: widget.labelBuilder,
|
||||
colorBuilder: widget.colorBuilder,
|
||||
@@ -313,6 +331,10 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
onEntryResized: widget.onEntryResized,
|
||||
onEntryMoved: widget.onEntryMoved,
|
||||
groupRegistry: scope?.groupRegistry,
|
||||
nextEntryStartX: _findNextEntryStartX(
|
||||
widget.entries,
|
||||
i,
|
||||
),
|
||||
),
|
||||
|
||||
// Ghost overlay for drag operations
|
||||
@@ -326,7 +348,7 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
// Show ghost for resize (entry stays in its own group)
|
||||
if (resizeState != null &&
|
||||
resizeState.originalEntry.groupId == widget.group.id) {
|
||||
return GhostOverlay(
|
||||
return DragPreview(
|
||||
targetStart: resizeState.targetStart,
|
||||
targetEnd: resizeState.targetEnd,
|
||||
targetLane: resizeState.targetLane,
|
||||
@@ -335,19 +357,23 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
laneHeight: widget.laneHeight,
|
||||
color: widget.colorBuilder(resizeState.originalEntry),
|
||||
label: widget.labelBuilder(resizeState.originalEntry),
|
||||
hasEnd: resizeState.originalEntry.hasEnd,
|
||||
);
|
||||
}
|
||||
|
||||
// Show ghost for drag-move
|
||||
if (dragState != null &&
|
||||
dragState.targetGroupId == widget.group.id) {
|
||||
return GhostOverlay(
|
||||
return DragPreview(
|
||||
targetStart: dragState.targetStart,
|
||||
targetEnd: dragState.targetEnd,
|
||||
targetLane: dragState.targetLane,
|
||||
viewport: widget.viewport,
|
||||
contentWidth: widget.contentWidth,
|
||||
laneHeight: widget.laneHeight,
|
||||
color: widget.colorBuilder(dragState.originalEntry),
|
||||
label: widget.labelBuilder(dragState.originalEntry),
|
||||
hasEnd: dragState.originalEntry.hasEnd,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ export 'src/state/timeline_viewport_notifier.dart';
|
||||
export 'src/widgets/breadcrumb_segment_chip.dart';
|
||||
export 'src/widgets/entry_popover_overlay.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/timeline_breadcrumb.dart';
|
||||
export 'src/widgets/timeline_interactor.dart';
|
||||
|
||||
Reference in New Issue
Block a user