pill design and performance
This commit is contained in:
@@ -4,8 +4,20 @@ import 'package:flutter/material.dart';
|
||||
class ZTimelineConstants {
|
||||
const ZTimelineConstants._();
|
||||
|
||||
// Pill body
|
||||
static const double pillInnerBorderWidth = 1.0;
|
||||
static const double pillContentHeight = 16.0; // labelMedium line height
|
||||
|
||||
// Computed pill height (single source of truth for lane height)
|
||||
// pillPadding.vertical (6.0 * 2 = 12.0) inlined because EdgeInsets
|
||||
// getters are not compile-time constants.
|
||||
static const double pillHeight =
|
||||
pillInnerBorderWidth * 2 +
|
||||
12.0 + // pillPadding.vertical
|
||||
pillContentHeight; // = 30.0
|
||||
|
||||
// Heights
|
||||
static const double laneHeight = 28.0;
|
||||
static const double laneHeight = pillHeight;
|
||||
static const double groupHeaderHeight = 34.0;
|
||||
|
||||
// Spacing
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'timeline_entry.dart';
|
||||
|
||||
/// Represents a projected entry on the timeline.
|
||||
/// This is used to represent an entry on the timeline in a normalized space.
|
||||
/// The startX and endX are normalized in [0, 1] and represent the position of
|
||||
/// the entry on the timeline.
|
||||
/// The widthX is the width of the entry in the normalized space.
|
||||
@immutable
|
||||
class ProjectedEntry {
|
||||
const ProjectedEntry({
|
||||
required this.entry,
|
||||
required this.startX,
|
||||
required this.endX,
|
||||
}) : assert(startX <= endX, 'Projected startX must be <= endX');
|
||||
|
||||
final TimelineEntry entry;
|
||||
final double startX; // normalized in [0, 1]
|
||||
final double endX; // normalized in [0, 1]
|
||||
|
||||
double get widthX => (endX - startX).clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(entry, startX, endX);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProjectedEntry &&
|
||||
other.entry == entry &&
|
||||
other.startX == startX &&
|
||||
other.endX == endX;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ProjectedEntry(entry: ${entry.id}, startX: $startX, endX: $endX)';
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import '../constants.dart';
|
||||
/// The timeline uses two coordinate spaces:
|
||||
///
|
||||
/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain.
|
||||
/// Used by [TimeScaleService] and stored in [ProjectedEntry].
|
||||
/// Computed via [TimeScaleService].
|
||||
///
|
||||
/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline.
|
||||
/// What gets passed to [Positioned] widgets.
|
||||
|
||||
@@ -21,6 +21,7 @@ class _GroupRegistration {
|
||||
required this.lanesCount,
|
||||
required this.laneHeight,
|
||||
required this.contentWidth,
|
||||
this.headerHeight = 0,
|
||||
});
|
||||
|
||||
final GlobalKey key;
|
||||
@@ -28,6 +29,7 @@ class _GroupRegistration {
|
||||
final int lanesCount;
|
||||
final double laneHeight;
|
||||
final double contentWidth;
|
||||
final double headerHeight;
|
||||
}
|
||||
|
||||
/// Registry for timeline group lane areas, enabling cross-group hit detection.
|
||||
@@ -46,6 +48,7 @@ class TimelineGroupRegistry {
|
||||
required int lanesCount,
|
||||
required double laneHeight,
|
||||
required double contentWidth,
|
||||
double headerHeight = 0,
|
||||
}) {
|
||||
_groups[groupId] = _GroupRegistration(
|
||||
key: key,
|
||||
@@ -53,6 +56,7 @@ class TimelineGroupRegistry {
|
||||
lanesCount: lanesCount,
|
||||
laneHeight: laneHeight,
|
||||
contentWidth: contentWidth,
|
||||
headerHeight: headerHeight,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,15 +82,20 @@ class TimelineGroupRegistry {
|
||||
// Check horizontal bounds
|
||||
if (local.dx < 0 || local.dx > reg.contentWidth) continue;
|
||||
|
||||
// Check vertical bounds: allow one extra lane for expansion
|
||||
// Check vertical bounds: extend upward by headerHeight to cover
|
||||
// the group header dead zone, and allow one extra lane for expansion.
|
||||
if (adjustedY < -reg.headerHeight) continue;
|
||||
final maxLanes = (reg.lanesCount <= 0 ? 1 : reg.lanesCount) + 1;
|
||||
final maxY =
|
||||
maxLanes * (reg.laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||
if (adjustedY < 0 || adjustedY > maxY) continue;
|
||||
if (adjustedY > maxY) continue;
|
||||
|
||||
// Clamp negative values (pointer over header) to top of lanes area.
|
||||
final clampedY = adjustedY < 0 ? 0.0 : adjustedY;
|
||||
|
||||
return GroupHitResult(
|
||||
groupId: entry.key,
|
||||
localPosition: Offset(local.dx, adjustedY),
|
||||
localPosition: Offset(local.dx, clampedY),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../models/projected_entry.dart';
|
||||
import 'time_scale_service.dart';
|
||||
|
||||
class TimelineProjectionService {
|
||||
const TimelineProjectionService();
|
||||
|
||||
Map<String, List<ProjectedEntry>> project({
|
||||
required Iterable<TimelineEntry> entries,
|
||||
required DateTime domainStart,
|
||||
required DateTime domainEnd,
|
||||
}) {
|
||||
final byGroup = <String, List<ProjectedEntry>>{};
|
||||
for (final e in entries) {
|
||||
if (e.overlaps(domainStart, domainEnd)) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
e.start.isBefore(domainStart) ? domainStart : e.start,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
).clamp(0.0, 1.0);
|
||||
final endX = TimeScaleService.mapTimeToPosition(
|
||||
e.end.isAfter(domainEnd) ? domainEnd : e.end,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
).clamp(0.0, 1.0);
|
||||
|
||||
final pe = ProjectedEntry(entry: e, startX: startX, endX: endX);
|
||||
(byGroup[e.groupId] ??= <ProjectedEntry>[]).add(pe);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep original order stable by lane then startX
|
||||
for (final list in byGroup.values) {
|
||||
list.sort((a, b) {
|
||||
final laneCmp = a.entry.lane.compareTo(b.entry.lane);
|
||||
if (laneCmp != 0) return laneCmp;
|
||||
return a.startX.compareTo(b.startX);
|
||||
});
|
||||
}
|
||||
return byGroup;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter/widget_previews.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 {
|
||||
/// A standalone event pill widget with a tinted gradient background,
|
||||
/// colored border, indicator dot, and an outer selection ring.
|
||||
class EventPill extends StatefulWidget {
|
||||
const EventPill({
|
||||
required this.color,
|
||||
required this.label,
|
||||
@@ -16,36 +16,101 @@ class EventPill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
State<EventPill> createState() => _EventPillState();
|
||||
}
|
||||
|
||||
class _EventPillState extends State<EventPill> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onColor =
|
||||
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
final color = widget.color;
|
||||
final isSelected = widget.isSelected;
|
||||
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
final double bgAlphaStart =
|
||||
isSelected ? 0.24 : _isHovered ? 0.18 : 0.08;
|
||||
final double bgAlphaEnd =
|
||||
isSelected ? 0.34 : _isHovered ? 0.28 : 0.16;
|
||||
|
||||
return Container(
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
padding: ZTimelineConstants.pillPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
color.withValues(alpha: bgAlphaStart),
|
||||
color.withValues(alpha: bgAlphaEnd),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: ZTimelineConstants.pillInnerBorderWidth,
|
||||
),
|
||||
border: isSelected
|
||||
? Border.all(color: primaryColor, width: 2)
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 20) return const SizedBox.shrink();
|
||||
return Row(
|
||||
children: [
|
||||
if (isSelected)
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color, shape: BoxShape.circle),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
widget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: onColor,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name: 'Event pill', brightness: Brightness.light)
|
||||
@Preview(name: 'Event pill', brightness: Brightness.dark)
|
||||
Widget eventPillPreview() =>
|
||||
EventPill(color: Colors.blue, label: 'Sample Event', isSelected: false);
|
||||
|
||||
@Preview(name: 'Event pill - selected', brightness: Brightness.light)
|
||||
@Preview(name: 'Event pill - selected', brightness: Brightness.dark)
|
||||
Widget eventPillSelectedPreview() =>
|
||||
EventPill(color: Colors.blue, label: 'Sample Event', isSelected: true);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -19,6 +18,11 @@ enum _InteractionMode { resizeStart, resizeEnd, drag }
|
||||
|
||||
/// An interactive event pill that handles both resize and drag-move
|
||||
/// with a single [GestureDetector] using pan gestures.
|
||||
///
|
||||
/// Each pill listens to the [viewport] internally via [AnimatedBuilder],
|
||||
/// so only the [Positioned] wrapper updates during pan — the visual
|
||||
/// content ([EventPill] / [EventPoint]) is in the `child:` slot and
|
||||
/// is NOT rebuilt on every frame.
|
||||
class InteractiveEventPill extends StatefulWidget {
|
||||
const InteractiveEventPill({
|
||||
required this.entry,
|
||||
@@ -34,11 +38,11 @@ class InteractiveEventPill extends StatefulWidget {
|
||||
this.onEntrySelected,
|
||||
this.selectedEntryId,
|
||||
this.groupRegistry,
|
||||
this.nextEntryStartX,
|
||||
this.nextEntryInLane,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProjectedEntry entry;
|
||||
final TimelineEntry entry;
|
||||
final double laneHeight;
|
||||
final EntryLabelBuilder labelBuilder;
|
||||
final EntryColorBuilder colorBuilder;
|
||||
@@ -52,9 +56,9 @@ class InteractiveEventPill extends StatefulWidget {
|
||||
final String? selectedEntryId;
|
||||
final TimelineGroupRegistry? groupRegistry;
|
||||
|
||||
/// Normalized startX of the next entry in the same lane, used to compute
|
||||
/// available width for point events.
|
||||
final double? nextEntryStartX;
|
||||
/// Next entry in the same lane, used to compute available width for
|
||||
/// point events.
|
||||
final TimelineEntry? nextEntryInLane;
|
||||
|
||||
@override
|
||||
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
|
||||
@@ -69,17 +73,33 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
/// so dragging doesn't snap the entry start to the cursor.
|
||||
double _grabOffsetNormalized = 0;
|
||||
|
||||
double get _pillWidth {
|
||||
/// Compute pill width from an entry's start/end mapped through the current
|
||||
/// viewport. Shared by [_currentPillWidth] and [_buildRangeEntry] to avoid
|
||||
/// drift between the two code paths.
|
||||
double _pillWidthFromEntry(TimelineEntry entry) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
entry.start,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
final endX = TimeScaleService.mapTimeToPosition(
|
||||
entry.end,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
return LayoutCoordinateService.calculateItemWidth(
|
||||
normalizedWidth: widget.entry.widthX,
|
||||
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
|
||||
contentWidth: widget.contentWidth,
|
||||
).clamp(0.0, double.infinity);
|
||||
);
|
||||
}
|
||||
|
||||
_InteractionMode _determineMode(Offset localPosition) {
|
||||
if (!widget.entry.entry.hasEnd) return _InteractionMode.drag;
|
||||
/// Compute current pill width from the raw entry + viewport.
|
||||
double get _currentPillWidth => _pillWidthFromEntry(widget.entry);
|
||||
|
||||
final pillWidth = _pillWidth;
|
||||
_InteractionMode _determineMode(Offset localPosition) {
|
||||
if (!widget.entry.hasEnd) return _InteractionMode.drag;
|
||||
|
||||
final pillWidth = _currentPillWidth;
|
||||
const handleWidth = ZTimelineConstants.resizeHandleWidth;
|
||||
|
||||
if (widget.onEntryResized != null && pillWidth >= 16) {
|
||||
@@ -100,7 +120,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final entry = widget.entry.entry;
|
||||
final entry = widget.entry;
|
||||
|
||||
_cumulativeDx = 0;
|
||||
|
||||
@@ -133,7 +153,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
_cumulativeDx += details.delta.dx;
|
||||
|
||||
final deltaNormalized = _cumulativeDx / widget.contentWidth;
|
||||
final originalEntry = widget.entry.entry;
|
||||
final originalEntry = widget.entry;
|
||||
final edge = _mode == _InteractionMode.resizeStart
|
||||
? ResizeEdge.start
|
||||
: ResizeEdge.end;
|
||||
@@ -222,7 +242,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
||||
|
||||
final resolved = EntryPlacementService.resolvePlacement(
|
||||
entry: widget.entry.entry,
|
||||
entry: widget.entry,
|
||||
targetGroupId: hit.groupId,
|
||||
targetLane: targetLane,
|
||||
targetStart: targetStart,
|
||||
@@ -286,27 +306,130 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPoint = !widget.entry.entry.hasEnd;
|
||||
final color = widget.colorBuilder(widget.entry.entry);
|
||||
final label = widget.labelBuilder(widget.entry.entry);
|
||||
final isSelected = widget.entry.entry.id == widget.selectedEntryId;
|
||||
final entry = widget.entry;
|
||||
final isPoint = !entry.hasEnd;
|
||||
final color = widget.colorBuilder(entry);
|
||||
final label = widget.labelBuilder(entry);
|
||||
final isSelected = entry.id == widget.selectedEntryId;
|
||||
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: widget.entry.entry.lane,
|
||||
lane: entry.lane,
|
||||
laneHeight: widget.laneHeight,
|
||||
);
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
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;
|
||||
return _buildPointEntry(entry, color, label, isSelected, top);
|
||||
}
|
||||
|
||||
return _buildRangeEntry(entry, color, label, isSelected, top);
|
||||
}
|
||||
|
||||
/// Range event: EventPill goes in the AnimatedBuilder child: slot
|
||||
/// and is NOT rebuilt during pan — only the Positioned wrapper updates.
|
||||
Widget _buildRangeEntry(
|
||||
TimelineEntry entry,
|
||||
Color color,
|
||||
String label,
|
||||
bool isSelected,
|
||||
double top,
|
||||
) {
|
||||
final pill = EventPill(color: color, label: label, isSelected: isSelected);
|
||||
final hasResizeHandles =
|
||||
entry.hasEnd && widget.onEntryResized != null;
|
||||
|
||||
Widget content;
|
||||
if (!widget.enableDrag) {
|
||||
content = pill;
|
||||
} else {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
content = ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, child) {
|
||||
final entryId = 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),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.viewport,
|
||||
builder: (context, child) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
entry.start,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: startX,
|
||||
contentWidth: widget.contentWidth,
|
||||
);
|
||||
final width = _pillWidthFromEntry(entry);
|
||||
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
width: width,
|
||||
height: widget.laneHeight,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
/// Point event: maxTextWidth depends on viewport, so EventPoint is
|
||||
/// built inside the AnimatedBuilder. Point events are typically few,
|
||||
/// so this has negligible cost.
|
||||
Widget _buildPointEntry(
|
||||
TimelineEntry entry,
|
||||
Color color,
|
||||
String label,
|
||||
bool isSelected,
|
||||
double top,
|
||||
) {
|
||||
// For point events we need to rebuild EventPoint in the builder because
|
||||
// maxTextWidth depends on the viewport position of the next entry.
|
||||
//
|
||||
// Hoist scope lookup before AnimatedBuilder to avoid re-looking it up on
|
||||
// every animation frame (InheritedWidget lookup is cheap but unnecessary).
|
||||
final scope = widget.enableDrag ? ZTimelineScope.of(context) : null;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.viewport,
|
||||
builder: (context, _) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
entry.start,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
double? nextStartX;
|
||||
if (widget.nextEntryInLane != null) {
|
||||
nextStartX = TimeScaleService.mapTimeToPosition(
|
||||
widget.nextEntryInLane!.start,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
}
|
||||
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: startX,
|
||||
contentWidth: widget.contentWidth,
|
||||
);
|
||||
|
||||
final availableWidth = nextStartX != null
|
||||
? (nextStartX - startX) * widget.contentWidth
|
||||
: (1.0 - startX) * widget.contentWidth;
|
||||
|
||||
final maxTextWidth = availableWidth -
|
||||
ZTimelineConstants.pointEventCircleDiameter -
|
||||
@@ -323,32 +446,33 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
if (!widget.enableDrag) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
left: left,
|
||||
width: availableWidth.clamp(
|
||||
ZTimelineConstants.pointEventCircleDiameter,
|
||||
double.infinity,
|
||||
),
|
||||
height: widget.laneHeight,
|
||||
child: Align(alignment: Alignment.centerLeft, child: pointWidget),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: pointWidget,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
left: left,
|
||||
width: availableWidth.clamp(
|
||||
ZTimelineConstants.pointEventCircleDiameter,
|
||||
double.infinity,
|
||||
),
|
||||
height: widget.laneHeight,
|
||||
child: ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
listenable: scope!.interaction,
|
||||
builder: (context, child) {
|
||||
final entryId = widget.entry.entry.id;
|
||||
final isDragging = scope.interaction.isDraggingEntry &&
|
||||
scope.interaction.dragState?.entryId == entryId;
|
||||
final entryId = entry.id;
|
||||
final isDragging = scope!.interaction.isDraggingEntry &&
|
||||
scope!.interaction.dragState?.entryId == entryId;
|
||||
|
||||
return Opacity(
|
||||
opacity: isDragging ? 0.3 : 1.0,
|
||||
@@ -358,49 +482,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
child: _buildInteractivePoint(pointWidget),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Range event (hasEnd == true)
|
||||
final pill = EventPill(color: color, label: label, isSelected: isSelected);
|
||||
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.entry.entry.hasEnd && 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -410,7 +492,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
final topLeft = box.localToGlobal(Offset.zero);
|
||||
final rect = topLeft & box.size;
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
|
||||
scope.interaction.setHoveredEntry(widget.entry.id, rect);
|
||||
}
|
||||
|
||||
void _onHoverExit() {
|
||||
@@ -425,7 +507,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
final topLeft = box.localToGlobal(Offset.zero);
|
||||
final rect = topLeft & box.size;
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
|
||||
scope.interaction.setHoveredEntry(widget.entry.id, rect);
|
||||
}
|
||||
|
||||
Widget _buildInteractivePoint(Widget pointWidget) {
|
||||
@@ -440,7 +522,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
onExit: (_) => _onHoverExit(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
|
||||
onTap: () => widget.onEntrySelected?.call(widget.entry),
|
||||
onPanDown: _onPanDown,
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
@@ -460,7 +542,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
onExit: (_) => _onHoverExit(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
|
||||
onTap: () => widget.onEntrySelected?.call(widget.entry),
|
||||
onPanDown: _onPanDown,
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
@@ -476,7 +558,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: ZTimelineConstants.resizeHandleWidth,
|
||||
child: MouseRegion(
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
opaque: false,
|
||||
),
|
||||
@@ -488,7 +570,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: ZTimelineConstants.resizeHandleWidth,
|
||||
child: MouseRegion(
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
opaque: false,
|
||||
),
|
||||
@@ -500,14 +582,14 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
right: ZTimelineConstants.resizeHandleWidth,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: MouseRegion(
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: false,
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
child: MouseRegion(
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: false,
|
||||
),
|
||||
|
||||
@@ -3,10 +3,8 @@ 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 'drag_preview.dart';
|
||||
import 'interactive_event_pill.dart';
|
||||
@@ -38,6 +36,10 @@ typedef OnEntrySelected = void Function(TimelineEntry entry);
|
||||
|
||||
/// Base timeline view: renders groups with between-group headers and
|
||||
/// lane rows containing event pills.
|
||||
///
|
||||
/// Viewport changes (pan/zoom) are handled by each pill's internal
|
||||
/// [AnimatedBuilder] — the parent tree only rebuilds when [entries]
|
||||
/// or [groups] change.
|
||||
class ZTimelineView extends StatelessWidget {
|
||||
const ZTimelineView({
|
||||
super.key,
|
||||
@@ -83,14 +85,31 @@ class ZTimelineView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: viewport,
|
||||
builder: (context, _) {
|
||||
final projected = const TimelineProjectionService().project(
|
||||
entries: entries,
|
||||
domainStart: viewport.start,
|
||||
domainEnd: viewport.end,
|
||||
);
|
||||
// Group entries by groupId and sort by lane+start.
|
||||
// Runs once per entries/groups change, NOT on every pan frame.
|
||||
final grouped = <String, List<TimelineEntry>>{};
|
||||
for (final e in entries) {
|
||||
(grouped[e.groupId] ??= []).add(e);
|
||||
}
|
||||
for (final list in grouped.values) {
|
||||
list.sort((a, b) {
|
||||
final cmp = a.lane.compareTo(b.lane);
|
||||
return cmp != 0 ? cmp : a.start.compareTo(b.start);
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-compute next-entry-in-lane lookup in O(n).
|
||||
final nextEntryInLane = <String, TimelineEntry>{};
|
||||
for (final list in grouped.values) {
|
||||
final lastInLane = <int, TimelineEntry>{};
|
||||
for (final entry in list) {
|
||||
final prev = lastInLane[entry.lane];
|
||||
if (prev != null) {
|
||||
nextEntryInLane[prev.id] = entry;
|
||||
}
|
||||
lastInLane[entry.lane] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -103,14 +122,15 @@ class ZTimelineView extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final group = groups[index];
|
||||
final groupEntries =
|
||||
projected[group.id] ?? const <ProjectedEntry>[];
|
||||
grouped[group.id] ?? const <TimelineEntry>[];
|
||||
final lanesCount = _countLanes(groupEntries);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||
_GroupLanes(
|
||||
RepaintBoundary(
|
||||
child: _GroupLanes(
|
||||
group: group,
|
||||
entries: groupEntries,
|
||||
allEntries: entries,
|
||||
@@ -125,6 +145,9 @@ class ZTimelineView extends StatelessWidget {
|
||||
onEntryMoved: onEntryMoved,
|
||||
onEntrySelected: onEntrySelected,
|
||||
selectedEntryId: selectedEntryId,
|
||||
nextEntryInLane: nextEntryInLane,
|
||||
groupHeaderHeight: groupHeaderHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -132,14 +155,12 @@ class ZTimelineView extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _countLanes(List<ProjectedEntry> entries) {
|
||||
int _countLanes(List<TimelineEntry> entries) {
|
||||
var maxLane = 0;
|
||||
for (final e in entries) {
|
||||
if (e.entry.lane > maxLane) maxLane = e.entry.lane;
|
||||
if (e.lane > maxLane) maxLane = e.lane;
|
||||
}
|
||||
return maxLane.clamp(0, 1000); // basic guard
|
||||
}
|
||||
@@ -182,6 +203,8 @@ class _GroupLanes extends StatefulWidget {
|
||||
required this.colorBuilder,
|
||||
required this.contentWidth,
|
||||
required this.enableDrag,
|
||||
required this.nextEntryInLane,
|
||||
required this.groupHeaderHeight,
|
||||
this.onEntryResized,
|
||||
this.onEntryMoved,
|
||||
this.onEntrySelected,
|
||||
@@ -189,7 +212,7 @@ class _GroupLanes extends StatefulWidget {
|
||||
});
|
||||
|
||||
final TimelineGroup group;
|
||||
final List<ProjectedEntry> entries;
|
||||
final List<TimelineEntry> entries;
|
||||
final List<TimelineEntry> allEntries;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final int lanesCount;
|
||||
@@ -202,6 +225,8 @@ class _GroupLanes extends StatefulWidget {
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
final OnEntrySelected? onEntrySelected;
|
||||
final String? selectedEntryId;
|
||||
final Map<String, TimelineEntry> nextEntryInLane;
|
||||
final double groupHeaderHeight;
|
||||
|
||||
@override
|
||||
State<_GroupLanes> createState() => _GroupLanesState();
|
||||
@@ -249,6 +274,7 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
lanesCount: widget.lanesCount,
|
||||
laneHeight: widget.laneHeight,
|
||||
contentWidth: widget.contentWidth,
|
||||
headerHeight: widget.groupHeaderHeight,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,12 +282,36 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
Widget build(BuildContext context) {
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
|
||||
// Build pill widgets once per build() call (stable between interaction
|
||||
// changes). When the ListenableBuilder fires, the same widget objects
|
||||
// are reused — Flutter matches by identity and skips rebuilding.
|
||||
final pillWidgets = <Widget>[
|
||||
for (final entry in widget.entries)
|
||||
InteractiveEventPill(
|
||||
key: ValueKey(entry.id),
|
||||
entry: entry,
|
||||
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,
|
||||
onEntrySelected: widget.onEntrySelected,
|
||||
selectedEntryId: widget.selectedEntryId,
|
||||
groupRegistry: scope?.groupRegistry,
|
||||
nextEntryInLane: widget.nextEntryInLane[entry.id],
|
||||
),
|
||||
];
|
||||
|
||||
// If no scope (drag not enabled), use static height
|
||||
if (scope == null || !widget.enableDrag) {
|
||||
return _buildContent(context, widget.lanesCount);
|
||||
return _wrapContent(pillWidgets, widget.lanesCount, null);
|
||||
}
|
||||
|
||||
// Listen to interaction notifier for drag/resize state changes
|
||||
// Listen to interaction only for height changes + drag preview.
|
||||
return ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
@@ -272,7 +322,7 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
groupId: widget.group.id,
|
||||
);
|
||||
|
||||
return _buildContent(context, effectiveLanesCount);
|
||||
return _wrapContent(pillWidgets, effectiveLanesCount, scope);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -302,65 +352,44 @@ 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) {
|
||||
Widget _wrapContent(
|
||||
List<Widget> pillWidgets,
|
||||
int effectiveLanesCount,
|
||||
ZTimelineScopeData? scope,
|
||||
) {
|
||||
final totalHeight =
|
||||
effectiveLanesCount * widget.laneHeight +
|
||||
(effectiveLanesCount > 0
|
||||
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
||||
: 0);
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
|
||||
// The inner Stack with pills and ghost overlay
|
||||
Widget innerStack = SizedBox(
|
||||
final innerStack = SizedBox(
|
||||
height: totalHeight,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Event pills
|
||||
for (var i = 0; i < widget.entries.length; i++)
|
||||
InteractiveEventPill(
|
||||
entry: widget.entries[i],
|
||||
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,
|
||||
onEntrySelected: widget.onEntrySelected,
|
||||
selectedEntryId: widget.selectedEntryId,
|
||||
groupRegistry: scope?.groupRegistry,
|
||||
nextEntryStartX: _findNextEntryStartX(
|
||||
widget.entries,
|
||||
i,
|
||||
),
|
||||
),
|
||||
|
||||
...pillWidgets,
|
||||
// Ghost overlay for drag operations
|
||||
if (widget.enableDrag && scope != null)
|
||||
ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
if (widget.enableDrag && scope != null) _buildDragPreview(scope),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
key: _lanesKey,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: ZTimelineConstants.verticalOuterPadding,
|
||||
),
|
||||
child: innerStack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragPreview(ZTimelineScopeData scope) {
|
||||
final dragState = scope.interaction.dragState;
|
||||
final resizeState = scope.interaction.resizeState;
|
||||
|
||||
@@ -381,8 +410,7 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
}
|
||||
|
||||
// Show ghost for drag-move
|
||||
if (dragState != null &&
|
||||
dragState.targetGroupId == widget.group.id) {
|
||||
if (dragState != null && dragState.targetGroupId == widget.group.id) {
|
||||
return DragPreview(
|
||||
targetStart: dragState.targetStart,
|
||||
targetEnd: dragState.targetEnd,
|
||||
@@ -397,23 +425,5 @@ class _GroupLanesState extends State<_GroupLanes> {
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
key: _lanesKey,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: ZTimelineConstants.verticalOuterPadding,
|
||||
),
|
||||
child: innerStack,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
export 'src/models/tier_config.dart';
|
||||
export 'src/models/tier_section.dart';
|
||||
export 'src/models/tiered_tick_data.dart';
|
||||
@@ -25,8 +24,6 @@ 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
|
||||
export 'src/state/timeline_interaction_notifier.dart';
|
||||
export 'src/state/timeline_viewport_notifier.dart';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:z_timeline/src/models/entry_drag_state.dart';
|
||||
import 'package:z_timeline/src/models/entry_resize_state.dart';
|
||||
import 'package:z_timeline/src/models/projected_entry.dart';
|
||||
import 'package:z_timeline/src/models/timeline_entry.dart';
|
||||
import 'package:z_timeline/src/models/timeline_group.dart';
|
||||
|
||||
@@ -30,19 +29,6 @@ TimelineGroup makeGroup({String? id, String? title}) {
|
||||
return TimelineGroup(id: id ?? 'group-1', title: title ?? 'Group 1');
|
||||
}
|
||||
|
||||
/// Creates a [ProjectedEntry] with entry + normalized positions.
|
||||
ProjectedEntry makeProjectedEntry({
|
||||
TimelineEntry? entry,
|
||||
double startX = 0.0,
|
||||
double endX = 0.1,
|
||||
}) {
|
||||
return ProjectedEntry(
|
||||
entry: entry ?? makeEntry(),
|
||||
startX: startX,
|
||||
endX: endX,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an [EntryDragState] with sensible defaults.
|
||||
EntryDragState makeDragState({
|
||||
String? entryId,
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:z_timeline/src/constants.dart';
|
||||
import 'package:z_timeline/src/models/entry_resize_state.dart';
|
||||
import 'package:z_timeline/src/models/projected_entry.dart';
|
||||
import 'package:z_timeline/src/models/timeline_entry.dart';
|
||||
import 'package:z_timeline/src/services/layout_coordinate_service.dart';
|
||||
import 'package:z_timeline/src/services/time_scale_service.dart';
|
||||
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
|
||||
import 'package:z_timeline/src/widgets/event_pill.dart';
|
||||
import 'package:z_timeline/src/widgets/interactive_event_pill.dart';
|
||||
@@ -23,10 +23,7 @@ void main() {
|
||||
late TimelineViewportNotifier viewport;
|
||||
|
||||
setUp(() {
|
||||
viewport = TimelineViewportNotifier(
|
||||
start: kDomainStart,
|
||||
end: kDomainEnd,
|
||||
);
|
||||
viewport = TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -36,18 +33,13 @@ void main() {
|
||||
/// Helper to pump an InteractiveEventPill in a test harness.
|
||||
Future<void> pumpPill(
|
||||
WidgetTester tester, {
|
||||
ProjectedEntry? entry,
|
||||
TimelineEntry? entry,
|
||||
bool enableDrag = true,
|
||||
List<TimelineEntry> allEntries = const [],
|
||||
void Function(TimelineEntry, DateTime, String, int)? onEntryMoved,
|
||||
void Function(TimelineEntry, DateTime, DateTime, int)? onEntryResized,
|
||||
}) async {
|
||||
final projectedEntry = entry ??
|
||||
makeProjectedEntry(
|
||||
entry: makeEntry(),
|
||||
startX: 0.1,
|
||||
endX: 0.3,
|
||||
);
|
||||
final testEntry = entry ?? makeEntry();
|
||||
|
||||
await tester.pumpTimeline(
|
||||
SizedBox(
|
||||
@@ -56,7 +48,7 @@ void main() {
|
||||
child: Stack(
|
||||
children: [
|
||||
InteractiveEventPill(
|
||||
entry: projectedEntry,
|
||||
entry: testEntry,
|
||||
laneHeight: laneHeight,
|
||||
labelBuilder: (e) => e.id,
|
||||
colorBuilder: (_) => Colors.blue,
|
||||
@@ -76,31 +68,39 @@ void main() {
|
||||
|
||||
group('rendering', () {
|
||||
testWidgets('pill appears at correct position', (tester) async {
|
||||
final projEntry = makeProjectedEntry(
|
||||
entry: makeEntry(lane: 2),
|
||||
startX: 0.2,
|
||||
endX: 0.5,
|
||||
);
|
||||
final testEntry = makeEntry(lane: 2);
|
||||
|
||||
await pumpPill(tester, entry: projEntry);
|
||||
await pumpPill(tester, entry: testEntry);
|
||||
|
||||
final positioned = tester.widget<Positioned>(
|
||||
find.ancestor(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byType(EventPill),
|
||||
matching: find.byType(Positioned),
|
||||
).first,
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
final expectedTop = LayoutCoordinateService.laneToY(
|
||||
lane: 2,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
testEntry.start,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
final endX = TimeScaleService.mapTimeToPosition(
|
||||
testEntry.end,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
final expectedLeft = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: 0.2,
|
||||
normalizedX: startX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
final expectedWidth = LayoutCoordinateService.calculateItemWidth(
|
||||
normalizedWidth: 0.3, // 0.5 - 0.2
|
||||
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
|
||||
@@ -174,14 +174,9 @@ void main() {
|
||||
|
||||
group('drag flow', () {
|
||||
testWidgets('pan from center triggers beginDrag', (tester) async {
|
||||
// Use a wide pill so center is clearly in drag zone
|
||||
final projEntry = makeProjectedEntry(
|
||||
entry: makeEntry(),
|
||||
startX: 0.1,
|
||||
endX: 0.4,
|
||||
);
|
||||
final testEntry = makeEntry();
|
||||
|
||||
await pumpPill(tester, entry: projEntry);
|
||||
await pumpPill(tester, entry: testEntry);
|
||||
|
||||
final pillCenter = tester.getCenter(find.byType(EventPill));
|
||||
|
||||
@@ -202,17 +197,21 @@ void main() {
|
||||
});
|
||||
|
||||
group('resize', () {
|
||||
testWidgets('pan from left edge triggers resize start', (tester) async {
|
||||
final projEntry = makeProjectedEntry(
|
||||
entry: makeEntry(),
|
||||
startX: 0.1,
|
||||
endX: 0.4,
|
||||
// Resize tests need a pill wide enough for resize handles (>= 16px).
|
||||
// With a 30-day viewport and 800px contentWidth, a 7-day entry gives
|
||||
// ~186px — well above the 16px threshold.
|
||||
TimelineEntry makeWideEntry() => makeEntry(
|
||||
start: kAnchor,
|
||||
end: kAnchor.add(const Duration(days: 7)),
|
||||
);
|
||||
|
||||
testWidgets('pan from left edge triggers resize start', (tester) async {
|
||||
final testEntry = makeWideEntry();
|
||||
|
||||
TimelineEntry? resizedEntry;
|
||||
await pumpPill(
|
||||
tester,
|
||||
entry: projEntry,
|
||||
entry: testEntry,
|
||||
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||
resizedEntry = entry;
|
||||
},
|
||||
@@ -233,16 +232,12 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('pan from right edge triggers resize end', (tester) async {
|
||||
final projEntry = makeProjectedEntry(
|
||||
entry: makeEntry(),
|
||||
startX: 0.1,
|
||||
endX: 0.4,
|
||||
);
|
||||
final testEntry = makeWideEntry();
|
||||
|
||||
TimelineEntry? resizedEntry;
|
||||
await pumpPill(
|
||||
tester,
|
||||
entry: projEntry,
|
||||
entry: testEntry,
|
||||
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||
resizedEntry = entry;
|
||||
},
|
||||
@@ -261,20 +256,22 @@ void main() {
|
||||
expect(resizedEntry, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('narrow pill (< 16px) always uses drag mode', (
|
||||
tester,
|
||||
) async {
|
||||
// Make a very narrow pill (< 16px)
|
||||
final projEntry = makeProjectedEntry(
|
||||
entry: makeEntry(),
|
||||
startX: 0.1,
|
||||
endX: 0.1 + (15 / contentWidth), // ~15px wide
|
||||
testWidgets('narrow pill (< 16px) always uses drag mode', (tester) async {
|
||||
// Make a very narrow pill (< 16px) — start close to end
|
||||
final narrowStart = kAnchor;
|
||||
final viewDuration =
|
||||
kDomainEnd.difference(kDomainStart).inMilliseconds;
|
||||
// 15px out of 800px contentWidth → 15/800 = 0.01875 of normalized
|
||||
final narrowDurationMs = (0.01875 * viewDuration).round();
|
||||
final narrowEnd = narrowStart.add(
|
||||
Duration(milliseconds: narrowDurationMs),
|
||||
);
|
||||
final testEntry = makeEntry(start: narrowStart, end: narrowEnd);
|
||||
|
||||
TimelineEntry? resizedEntry;
|
||||
await pumpPill(
|
||||
tester,
|
||||
entry: projEntry,
|
||||
entry: testEntry,
|
||||
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||
resizedEntry = entry;
|
||||
},
|
||||
@@ -302,18 +299,14 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('pill at 0.3 opacity during drag', (tester) async {
|
||||
final projEntry = makeProjectedEntry(
|
||||
entry: makeEntry(),
|
||||
startX: 0.1,
|
||||
endX: 0.4,
|
||||
);
|
||||
await pumpPill(tester, entry: projEntry);
|
||||
final testEntry = makeEntry();
|
||||
await pumpPill(tester, entry: testEntry);
|
||||
|
||||
// Start a drag by triggering beginDrag on the notifier directly
|
||||
final scope = ZTimelineScope.of(
|
||||
tester.element(find.byType(InteractiveEventPill)),
|
||||
);
|
||||
scope.interaction.beginDrag(projEntry.entry);
|
||||
scope.interaction.beginDrag(testEntry);
|
||||
await tester.pump();
|
||||
|
||||
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||
@@ -321,24 +314,17 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('pill at 0.3 opacity during resize', (tester) async {
|
||||
final projEntry = makeProjectedEntry(
|
||||
entry: makeEntry(),
|
||||
startX: 0.1,
|
||||
endX: 0.4,
|
||||
);
|
||||
final testEntry = makeEntry();
|
||||
await pumpPill(
|
||||
tester,
|
||||
entry: projEntry,
|
||||
onEntryResized: (_, __, ___, ____) {},
|
||||
entry: testEntry,
|
||||
onEntryResized: (_, _, _, _) {},
|
||||
);
|
||||
|
||||
final scope = ZTimelineScope.of(
|
||||
tester.element(find.byType(InteractiveEventPill)),
|
||||
);
|
||||
scope.interaction.beginResize(
|
||||
projEntry.entry,
|
||||
ResizeEdge.end,
|
||||
);
|
||||
scope.interaction.beginResize(testEntry, ResizeEdge.end);
|
||||
await tester.pump();
|
||||
|
||||
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||
|
||||
Reference in New Issue
Block a user