pill design and performance
This commit is contained in:
@@ -4,8 +4,20 @@ import 'package:flutter/material.dart';
|
|||||||
class ZTimelineConstants {
|
class ZTimelineConstants {
|
||||||
const 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
|
// Heights
|
||||||
static const double laneHeight = 28.0;
|
static const double laneHeight = pillHeight;
|
||||||
static const double groupHeaderHeight = 34.0;
|
static const double groupHeaderHeight = 34.0;
|
||||||
|
|
||||||
// Spacing
|
// 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:
|
/// The timeline uses two coordinate spaces:
|
||||||
///
|
///
|
||||||
/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain.
|
/// 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.
|
/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline.
|
||||||
/// What gets passed to [Positioned] widgets.
|
/// What gets passed to [Positioned] widgets.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class _GroupRegistration {
|
|||||||
required this.lanesCount,
|
required this.lanesCount,
|
||||||
required this.laneHeight,
|
required this.laneHeight,
|
||||||
required this.contentWidth,
|
required this.contentWidth,
|
||||||
|
this.headerHeight = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GlobalKey key;
|
final GlobalKey key;
|
||||||
@@ -28,6 +29,7 @@ class _GroupRegistration {
|
|||||||
final int lanesCount;
|
final int lanesCount;
|
||||||
final double laneHeight;
|
final double laneHeight;
|
||||||
final double contentWidth;
|
final double contentWidth;
|
||||||
|
final double headerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registry for timeline group lane areas, enabling cross-group hit detection.
|
/// Registry for timeline group lane areas, enabling cross-group hit detection.
|
||||||
@@ -46,6 +48,7 @@ class TimelineGroupRegistry {
|
|||||||
required int lanesCount,
|
required int lanesCount,
|
||||||
required double laneHeight,
|
required double laneHeight,
|
||||||
required double contentWidth,
|
required double contentWidth,
|
||||||
|
double headerHeight = 0,
|
||||||
}) {
|
}) {
|
||||||
_groups[groupId] = _GroupRegistration(
|
_groups[groupId] = _GroupRegistration(
|
||||||
key: key,
|
key: key,
|
||||||
@@ -53,6 +56,7 @@ class TimelineGroupRegistry {
|
|||||||
lanesCount: lanesCount,
|
lanesCount: lanesCount,
|
||||||
laneHeight: laneHeight,
|
laneHeight: laneHeight,
|
||||||
contentWidth: contentWidth,
|
contentWidth: contentWidth,
|
||||||
|
headerHeight: headerHeight,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,15 +82,20 @@ class TimelineGroupRegistry {
|
|||||||
// Check horizontal bounds
|
// Check horizontal bounds
|
||||||
if (local.dx < 0 || local.dx > reg.contentWidth) continue;
|
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 maxLanes = (reg.lanesCount <= 0 ? 1 : reg.lanesCount) + 1;
|
||||||
final maxY =
|
final maxY =
|
||||||
maxLanes * (reg.laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
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(
|
return GroupHitResult(
|
||||||
groupId: entry.key,
|
groupId: entry.key,
|
||||||
localPosition: Offset(local.dx, adjustedY),
|
localPosition: Offset(local.dx, clampedY),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
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/material.dart';
|
||||||
|
import 'package:flutter/widget_previews.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
|
|
||||||
/// A standalone event pill widget with entry color, rounded corners,
|
/// A standalone event pill widget with a tinted gradient background,
|
||||||
/// and auto text color based on brightness.
|
/// colored border, indicator dot, and an outer selection ring.
|
||||||
class EventPill extends StatelessWidget {
|
class EventPill extends StatefulWidget {
|
||||||
const EventPill({
|
const EventPill({
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -16,36 +16,101 @@ class EventPill extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventPill> createState() => _EventPillState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventPillState extends State<EventPill> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final onColor =
|
final color = widget.color;
|
||||||
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
final isSelected = widget.isSelected;
|
||||||
? Colors.white
|
|
||||||
: Colors.black87;
|
|
||||||
|
|
||||||
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,
|
padding: ZTimelineConstants.pillPadding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
gradient: LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(
|
begin: Alignment.topLeft,
|
||||||
ZTimelineConstants.pillBorderRadius,
|
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,
|
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(
|
child: Text(
|
||||||
label,
|
widget.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
color: onColor,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w400,
|
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 '../constants.dart';
|
||||||
import '../models/entry_resize_state.dart';
|
import '../models/entry_resize_state.dart';
|
||||||
import '../models/projected_entry.dart';
|
|
||||||
import '../models/timeline_entry.dart';
|
import '../models/timeline_entry.dart';
|
||||||
import '../services/entry_placement_service.dart';
|
import '../services/entry_placement_service.dart';
|
||||||
import '../services/layout_coordinate_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
|
/// An interactive event pill that handles both resize and drag-move
|
||||||
/// with a single [GestureDetector] using pan gestures.
|
/// 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 {
|
class InteractiveEventPill extends StatefulWidget {
|
||||||
const InteractiveEventPill({
|
const InteractiveEventPill({
|
||||||
required this.entry,
|
required this.entry,
|
||||||
@@ -34,11 +38,11 @@ class InteractiveEventPill extends StatefulWidget {
|
|||||||
this.onEntrySelected,
|
this.onEntrySelected,
|
||||||
this.selectedEntryId,
|
this.selectedEntryId,
|
||||||
this.groupRegistry,
|
this.groupRegistry,
|
||||||
this.nextEntryStartX,
|
this.nextEntryInLane,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ProjectedEntry entry;
|
final TimelineEntry entry;
|
||||||
final double laneHeight;
|
final double laneHeight;
|
||||||
final EntryLabelBuilder labelBuilder;
|
final EntryLabelBuilder labelBuilder;
|
||||||
final EntryColorBuilder colorBuilder;
|
final EntryColorBuilder colorBuilder;
|
||||||
@@ -52,9 +56,9 @@ class InteractiveEventPill extends StatefulWidget {
|
|||||||
final String? selectedEntryId;
|
final String? selectedEntryId;
|
||||||
final TimelineGroupRegistry? groupRegistry;
|
final TimelineGroupRegistry? groupRegistry;
|
||||||
|
|
||||||
/// Normalized startX of the next entry in the same lane, used to compute
|
/// Next entry in the same lane, used to compute available width for
|
||||||
/// available width for point events.
|
/// point events.
|
||||||
final double? nextEntryStartX;
|
final TimelineEntry? nextEntryInLane;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
|
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
|
||||||
@@ -69,17 +73,33 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
/// so dragging doesn't snap the entry start to the cursor.
|
/// so dragging doesn't snap the entry start to the cursor.
|
||||||
double _grabOffsetNormalized = 0;
|
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(
|
return LayoutCoordinateService.calculateItemWidth(
|
||||||
normalizedWidth: widget.entry.widthX,
|
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
|
||||||
contentWidth: widget.contentWidth,
|
contentWidth: widget.contentWidth,
|
||||||
).clamp(0.0, double.infinity);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_InteractionMode _determineMode(Offset localPosition) {
|
/// Compute current pill width from the raw entry + viewport.
|
||||||
if (!widget.entry.entry.hasEnd) return _InteractionMode.drag;
|
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;
|
const handleWidth = ZTimelineConstants.resizeHandleWidth;
|
||||||
|
|
||||||
if (widget.onEntryResized != null && pillWidth >= 16) {
|
if (widget.onEntryResized != null && pillWidth >= 16) {
|
||||||
@@ -100,7 +120,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
|
|
||||||
void _onPanStart(DragStartDetails details) {
|
void _onPanStart(DragStartDetails details) {
|
||||||
final scope = ZTimelineScope.of(context);
|
final scope = ZTimelineScope.of(context);
|
||||||
final entry = widget.entry.entry;
|
final entry = widget.entry;
|
||||||
|
|
||||||
_cumulativeDx = 0;
|
_cumulativeDx = 0;
|
||||||
|
|
||||||
@@ -133,7 +153,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
_cumulativeDx += details.delta.dx;
|
_cumulativeDx += details.delta.dx;
|
||||||
|
|
||||||
final deltaNormalized = _cumulativeDx / widget.contentWidth;
|
final deltaNormalized = _cumulativeDx / widget.contentWidth;
|
||||||
final originalEntry = widget.entry.entry;
|
final originalEntry = widget.entry;
|
||||||
final edge = _mode == _InteractionMode.resizeStart
|
final edge = _mode == _InteractionMode.resizeStart
|
||||||
? ResizeEdge.start
|
? ResizeEdge.start
|
||||||
: ResizeEdge.end;
|
: ResizeEdge.end;
|
||||||
@@ -222,7 +242,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
||||||
|
|
||||||
final resolved = EntryPlacementService.resolvePlacement(
|
final resolved = EntryPlacementService.resolvePlacement(
|
||||||
entry: widget.entry.entry,
|
entry: widget.entry,
|
||||||
targetGroupId: hit.groupId,
|
targetGroupId: hit.groupId,
|
||||||
targetLane: targetLane,
|
targetLane: targetLane,
|
||||||
targetStart: targetStart,
|
targetStart: targetStart,
|
||||||
@@ -286,27 +306,130 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isPoint = !widget.entry.entry.hasEnd;
|
final entry = widget.entry;
|
||||||
final color = widget.colorBuilder(widget.entry.entry);
|
final isPoint = !entry.hasEnd;
|
||||||
final label = widget.labelBuilder(widget.entry.entry);
|
final color = widget.colorBuilder(entry);
|
||||||
final isSelected = widget.entry.entry.id == widget.selectedEntryId;
|
final label = widget.labelBuilder(entry);
|
||||||
|
final isSelected = entry.id == widget.selectedEntryId;
|
||||||
|
|
||||||
final top = LayoutCoordinateService.laneToY(
|
final top = LayoutCoordinateService.laneToY(
|
||||||
lane: widget.entry.entry.lane,
|
lane: entry.lane,
|
||||||
laneHeight: widget.laneHeight,
|
laneHeight: widget.laneHeight,
|
||||||
);
|
);
|
||||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
|
||||||
normalizedX: widget.entry.startX,
|
|
||||||
contentWidth: widget.contentWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
// Available width from this entry to the next in the same lane
|
return _buildPointEntry(entry, color, label, isSelected, top);
|
||||||
// (or to the right edge of the content area).
|
}
|
||||||
final availableWidth = widget.nextEntryStartX != null
|
|
||||||
? (widget.nextEntryStartX! - widget.entry.startX) *
|
return _buildRangeEntry(entry, color, label, isSelected, top);
|
||||||
widget.contentWidth
|
}
|
||||||
: (1.0 - widget.entry.startX) * widget.contentWidth;
|
|
||||||
|
/// 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 -
|
final maxTextWidth = availableWidth -
|
||||||
ZTimelineConstants.pointEventCircleDiameter -
|
ZTimelineConstants.pointEventCircleDiameter -
|
||||||
@@ -323,32 +446,33 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
if (!widget.enableDrag) {
|
if (!widget.enableDrag) {
|
||||||
return Positioned(
|
return Positioned(
|
||||||
top: top,
|
top: top,
|
||||||
left: left.clamp(0.0, double.infinity),
|
left: left,
|
||||||
width: availableWidth.clamp(
|
width: availableWidth.clamp(
|
||||||
ZTimelineConstants.pointEventCircleDiameter,
|
ZTimelineConstants.pointEventCircleDiameter,
|
||||||
double.infinity,
|
double.infinity,
|
||||||
),
|
),
|
||||||
height: widget.laneHeight,
|
height: widget.laneHeight,
|
||||||
child: Align(alignment: Alignment.centerLeft, child: pointWidget),
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: pointWidget,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final scope = ZTimelineScope.of(context);
|
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
top: top,
|
top: top,
|
||||||
left: left.clamp(0.0, double.infinity),
|
left: left,
|
||||||
width: availableWidth.clamp(
|
width: availableWidth.clamp(
|
||||||
ZTimelineConstants.pointEventCircleDiameter,
|
ZTimelineConstants.pointEventCircleDiameter,
|
||||||
double.infinity,
|
double.infinity,
|
||||||
),
|
),
|
||||||
height: widget.laneHeight,
|
height: widget.laneHeight,
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: scope.interaction,
|
listenable: scope!.interaction,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final entryId = widget.entry.entry.id;
|
final entryId = entry.id;
|
||||||
final isDragging = scope.interaction.isDraggingEntry &&
|
final isDragging = scope!.interaction.isDraggingEntry &&
|
||||||
scope.interaction.dragState?.entryId == entryId;
|
scope!.interaction.dragState?.entryId == entryId;
|
||||||
|
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: isDragging ? 0.3 : 1.0,
|
opacity: isDragging ? 0.3 : 1.0,
|
||||||
@@ -358,49 +482,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
child: _buildInteractivePoint(pointWidget),
|
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 topLeft = box.localToGlobal(Offset.zero);
|
||||||
final rect = topLeft & box.size;
|
final rect = topLeft & box.size;
|
||||||
final scope = ZTimelineScope.of(context);
|
final scope = ZTimelineScope.of(context);
|
||||||
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
|
scope.interaction.setHoveredEntry(widget.entry.id, rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onHoverExit() {
|
void _onHoverExit() {
|
||||||
@@ -425,7 +507,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
final topLeft = box.localToGlobal(Offset.zero);
|
final topLeft = box.localToGlobal(Offset.zero);
|
||||||
final rect = topLeft & box.size;
|
final rect = topLeft & box.size;
|
||||||
final scope = ZTimelineScope.of(context);
|
final scope = ZTimelineScope.of(context);
|
||||||
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
|
scope.interaction.setHoveredEntry(widget.entry.id, rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInteractivePoint(Widget pointWidget) {
|
Widget _buildInteractivePoint(Widget pointWidget) {
|
||||||
@@ -440,7 +522,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
onExit: (_) => _onHoverExit(),
|
onExit: (_) => _onHoverExit(),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
|
onTap: () => widget.onEntrySelected?.call(widget.entry),
|
||||||
onPanDown: _onPanDown,
|
onPanDown: _onPanDown,
|
||||||
onPanStart: _onPanStart,
|
onPanStart: _onPanStart,
|
||||||
onPanUpdate: _onPanUpdate,
|
onPanUpdate: _onPanUpdate,
|
||||||
@@ -460,7 +542,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
onExit: (_) => _onHoverExit(),
|
onExit: (_) => _onHoverExit(),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
|
onTap: () => widget.onEntrySelected?.call(widget.entry),
|
||||||
onPanDown: _onPanDown,
|
onPanDown: _onPanDown,
|
||||||
onPanStart: _onPanStart,
|
onPanStart: _onPanStart,
|
||||||
onPanUpdate: _onPanUpdate,
|
onPanUpdate: _onPanUpdate,
|
||||||
@@ -476,7 +558,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: ZTimelineConstants.resizeHandleWidth,
|
width: ZTimelineConstants.resizeHandleWidth,
|
||||||
child: MouseRegion(
|
child: const MouseRegion(
|
||||||
cursor: SystemMouseCursors.resizeColumn,
|
cursor: SystemMouseCursors.resizeColumn,
|
||||||
opaque: false,
|
opaque: false,
|
||||||
),
|
),
|
||||||
@@ -488,7 +570,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: ZTimelineConstants.resizeHandleWidth,
|
width: ZTimelineConstants.resizeHandleWidth,
|
||||||
child: MouseRegion(
|
child: const MouseRegion(
|
||||||
cursor: SystemMouseCursors.resizeColumn,
|
cursor: SystemMouseCursors.resizeColumn,
|
||||||
opaque: false,
|
opaque: false,
|
||||||
),
|
),
|
||||||
@@ -500,14 +582,14 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
right: ZTimelineConstants.resizeHandleWidth,
|
right: ZTimelineConstants.resizeHandleWidth,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: MouseRegion(
|
child: const MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
opaque: false,
|
opaque: false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: MouseRegion(
|
child: const MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
opaque: false,
|
opaque: false,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import 'package:flutter/material.dart';
|
|||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
import '../models/entry_drag_state.dart';
|
import '../models/entry_drag_state.dart';
|
||||||
import '../models/entry_resize_state.dart';
|
import '../models/entry_resize_state.dart';
|
||||||
import '../models/projected_entry.dart';
|
|
||||||
import '../models/timeline_entry.dart';
|
import '../models/timeline_entry.dart';
|
||||||
import '../models/timeline_group.dart';
|
import '../models/timeline_group.dart';
|
||||||
import '../services/timeline_projection_service.dart';
|
|
||||||
import '../state/timeline_viewport_notifier.dart';
|
import '../state/timeline_viewport_notifier.dart';
|
||||||
import 'drag_preview.dart';
|
import 'drag_preview.dart';
|
||||||
import 'interactive_event_pill.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
|
/// Base timeline view: renders groups with between-group headers and
|
||||||
/// lane rows containing event pills.
|
/// 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 {
|
class ZTimelineView extends StatelessWidget {
|
||||||
const ZTimelineView({
|
const ZTimelineView({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -83,14 +85,31 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
// Group entries by groupId and sort by lane+start.
|
||||||
animation: viewport,
|
// Runs once per entries/groups change, NOT on every pan frame.
|
||||||
builder: (context, _) {
|
final grouped = <String, List<TimelineEntry>>{};
|
||||||
final projected = const TimelineProjectionService().project(
|
for (final e in entries) {
|
||||||
entries: entries,
|
(grouped[e.groupId] ??= []).add(e);
|
||||||
domainStart: viewport.start,
|
}
|
||||||
domainEnd: viewport.end,
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@@ -103,14 +122,15 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final group = groups[index];
|
final group = groups[index];
|
||||||
final groupEntries =
|
final groupEntries =
|
||||||
projected[group.id] ?? const <ProjectedEntry>[];
|
grouped[group.id] ?? const <TimelineEntry>[];
|
||||||
final lanesCount = _countLanes(groupEntries);
|
final lanesCount = _countLanes(groupEntries);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||||
_GroupLanes(
|
RepaintBoundary(
|
||||||
|
child: _GroupLanes(
|
||||||
group: group,
|
group: group,
|
||||||
entries: groupEntries,
|
entries: groupEntries,
|
||||||
allEntries: entries,
|
allEntries: entries,
|
||||||
@@ -125,6 +145,9 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
onEntryMoved: onEntryMoved,
|
onEntryMoved: onEntryMoved,
|
||||||
onEntrySelected: onEntrySelected,
|
onEntrySelected: onEntrySelected,
|
||||||
selectedEntryId: selectedEntryId,
|
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;
|
var maxLane = 0;
|
||||||
for (final e in entries) {
|
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
|
return maxLane.clamp(0, 1000); // basic guard
|
||||||
}
|
}
|
||||||
@@ -182,6 +203,8 @@ class _GroupLanes extends StatefulWidget {
|
|||||||
required this.colorBuilder,
|
required this.colorBuilder,
|
||||||
required this.contentWidth,
|
required this.contentWidth,
|
||||||
required this.enableDrag,
|
required this.enableDrag,
|
||||||
|
required this.nextEntryInLane,
|
||||||
|
required this.groupHeaderHeight,
|
||||||
this.onEntryResized,
|
this.onEntryResized,
|
||||||
this.onEntryMoved,
|
this.onEntryMoved,
|
||||||
this.onEntrySelected,
|
this.onEntrySelected,
|
||||||
@@ -189,7 +212,7 @@ class _GroupLanes extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final TimelineGroup group;
|
final TimelineGroup group;
|
||||||
final List<ProjectedEntry> entries;
|
final List<TimelineEntry> entries;
|
||||||
final List<TimelineEntry> allEntries;
|
final List<TimelineEntry> allEntries;
|
||||||
final TimelineViewportNotifier viewport;
|
final TimelineViewportNotifier viewport;
|
||||||
final int lanesCount;
|
final int lanesCount;
|
||||||
@@ -202,6 +225,8 @@ class _GroupLanes extends StatefulWidget {
|
|||||||
final OnEntryMoved? onEntryMoved;
|
final OnEntryMoved? onEntryMoved;
|
||||||
final OnEntrySelected? onEntrySelected;
|
final OnEntrySelected? onEntrySelected;
|
||||||
final String? selectedEntryId;
|
final String? selectedEntryId;
|
||||||
|
final Map<String, TimelineEntry> nextEntryInLane;
|
||||||
|
final double groupHeaderHeight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_GroupLanes> createState() => _GroupLanesState();
|
State<_GroupLanes> createState() => _GroupLanesState();
|
||||||
@@ -249,6 +274,7 @@ class _GroupLanesState extends State<_GroupLanes> {
|
|||||||
lanesCount: widget.lanesCount,
|
lanesCount: widget.lanesCount,
|
||||||
laneHeight: widget.laneHeight,
|
laneHeight: widget.laneHeight,
|
||||||
contentWidth: widget.contentWidth,
|
contentWidth: widget.contentWidth,
|
||||||
|
headerHeight: widget.groupHeaderHeight,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,12 +282,36 @@ class _GroupLanesState extends State<_GroupLanes> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scope = ZTimelineScope.maybeOf(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 no scope (drag not enabled), use static height
|
||||||
if (scope == null || !widget.enableDrag) {
|
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(
|
return ListenableBuilder(
|
||||||
listenable: scope.interaction,
|
listenable: scope.interaction,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -272,7 +322,7 @@ class _GroupLanesState extends State<_GroupLanes> {
|
|||||||
groupId: widget.group.id,
|
groupId: widget.group.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return _buildContent(context, effectiveLanesCount);
|
return _wrapContent(pillWidgets, effectiveLanesCount, scope);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,65 +352,44 @@ class _GroupLanesState extends State<_GroupLanes> {
|
|||||||
return effective;
|
return effective;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds the startX of the next entry in the same lane, or null if none.
|
Widget _wrapContent(
|
||||||
double? _findNextEntryStartX(List<ProjectedEntry> entries, int index) {
|
List<Widget> pillWidgets,
|
||||||
final current = entries[index];
|
int effectiveLanesCount,
|
||||||
final lane = current.entry.lane;
|
ZTimelineScopeData? scope,
|
||||||
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 =
|
final totalHeight =
|
||||||
effectiveLanesCount * widget.laneHeight +
|
effectiveLanesCount * widget.laneHeight +
|
||||||
(effectiveLanesCount > 0
|
(effectiveLanesCount > 0
|
||||||
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
||||||
: 0);
|
: 0);
|
||||||
final scope = ZTimelineScope.maybeOf(context);
|
|
||||||
|
|
||||||
// The inner Stack with pills and ghost overlay
|
final innerStack = SizedBox(
|
||||||
Widget innerStack = SizedBox(
|
|
||||||
height: totalHeight,
|
height: totalHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Event pills
|
...pillWidgets,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Ghost overlay for drag operations
|
// Ghost overlay for drag operations
|
||||||
if (widget.enableDrag && scope != null)
|
if (widget.enableDrag && scope != null) _buildDragPreview(scope),
|
||||||
ListenableBuilder(
|
],
|
||||||
listenable: scope.interaction,
|
),
|
||||||
builder: (context, _) {
|
);
|
||||||
|
|
||||||
|
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 dragState = scope.interaction.dragState;
|
||||||
final resizeState = scope.interaction.resizeState;
|
final resizeState = scope.interaction.resizeState;
|
||||||
|
|
||||||
@@ -381,8 +410,7 @@ class _GroupLanesState extends State<_GroupLanes> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 DragPreview(
|
return DragPreview(
|
||||||
targetStart: dragState.targetStart,
|
targetStart: dragState.targetStart,
|
||||||
targetEnd: dragState.targetEnd,
|
targetEnd: dragState.targetEnd,
|
||||||
@@ -397,23 +425,5 @@ class _GroupLanesState extends State<_GroupLanes> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return const SizedBox.shrink();
|
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/entry_resize_state.dart';
|
||||||
export 'src/models/interaction_config.dart';
|
export 'src/models/interaction_config.dart';
|
||||||
export 'src/models/interaction_state.dart';
|
export 'src/models/interaction_state.dart';
|
||||||
export 'src/models/projected_entry.dart';
|
|
||||||
export 'src/models/tier_config.dart';
|
export 'src/models/tier_config.dart';
|
||||||
export 'src/models/tier_section.dart';
|
export 'src/models/tier_section.dart';
|
||||||
export 'src/models/tiered_tick_data.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/tiered_tick_service.dart';
|
||||||
export 'src/services/time_scale_service.dart';
|
export 'src/services/time_scale_service.dart';
|
||||||
export 'src/services/timeline_group_registry.dart';
|
export 'src/services/timeline_group_registry.dart';
|
||||||
export 'src/services/timeline_projection_service.dart';
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
export 'src/state/timeline_interaction_notifier.dart';
|
export 'src/state/timeline_interaction_notifier.dart';
|
||||||
export 'src/state/timeline_viewport_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_drag_state.dart';
|
||||||
import 'package:z_timeline/src/models/entry_resize_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_entry.dart';
|
||||||
import 'package:z_timeline/src/models/timeline_group.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');
|
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.
|
/// Creates an [EntryDragState] with sensible defaults.
|
||||||
EntryDragState makeDragState({
|
EntryDragState makeDragState({
|
||||||
String? entryId,
|
String? entryId,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:z_timeline/src/constants.dart';
|
import 'package:z_timeline/src/constants.dart';
|
||||||
import 'package:z_timeline/src/models/entry_resize_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_entry.dart';
|
||||||
import 'package:z_timeline/src/services/layout_coordinate_service.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/state/timeline_viewport_notifier.dart';
|
||||||
import 'package:z_timeline/src/widgets/event_pill.dart';
|
import 'package:z_timeline/src/widgets/event_pill.dart';
|
||||||
import 'package:z_timeline/src/widgets/interactive_event_pill.dart';
|
import 'package:z_timeline/src/widgets/interactive_event_pill.dart';
|
||||||
@@ -23,10 +23,7 @@ void main() {
|
|||||||
late TimelineViewportNotifier viewport;
|
late TimelineViewportNotifier viewport;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
viewport = TimelineViewportNotifier(
|
viewport = TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
|
||||||
start: kDomainStart,
|
|
||||||
end: kDomainEnd,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -36,18 +33,13 @@ void main() {
|
|||||||
/// Helper to pump an InteractiveEventPill in a test harness.
|
/// Helper to pump an InteractiveEventPill in a test harness.
|
||||||
Future<void> pumpPill(
|
Future<void> pumpPill(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
ProjectedEntry? entry,
|
TimelineEntry? entry,
|
||||||
bool enableDrag = true,
|
bool enableDrag = true,
|
||||||
List<TimelineEntry> allEntries = const [],
|
List<TimelineEntry> allEntries = const [],
|
||||||
void Function(TimelineEntry, DateTime, String, int)? onEntryMoved,
|
void Function(TimelineEntry, DateTime, String, int)? onEntryMoved,
|
||||||
void Function(TimelineEntry, DateTime, DateTime, int)? onEntryResized,
|
void Function(TimelineEntry, DateTime, DateTime, int)? onEntryResized,
|
||||||
}) async {
|
}) async {
|
||||||
final projectedEntry = entry ??
|
final testEntry = entry ?? makeEntry();
|
||||||
makeProjectedEntry(
|
|
||||||
entry: makeEntry(),
|
|
||||||
startX: 0.1,
|
|
||||||
endX: 0.3,
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpTimeline(
|
await tester.pumpTimeline(
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -56,7 +48,7 @@ void main() {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
InteractiveEventPill(
|
InteractiveEventPill(
|
||||||
entry: projectedEntry,
|
entry: testEntry,
|
||||||
laneHeight: laneHeight,
|
laneHeight: laneHeight,
|
||||||
labelBuilder: (e) => e.id,
|
labelBuilder: (e) => e.id,
|
||||||
colorBuilder: (_) => Colors.blue,
|
colorBuilder: (_) => Colors.blue,
|
||||||
@@ -76,31 +68,39 @@ void main() {
|
|||||||
|
|
||||||
group('rendering', () {
|
group('rendering', () {
|
||||||
testWidgets('pill appears at correct position', (tester) async {
|
testWidgets('pill appears at correct position', (tester) async {
|
||||||
final projEntry = makeProjectedEntry(
|
final testEntry = makeEntry(lane: 2);
|
||||||
entry: makeEntry(lane: 2),
|
|
||||||
startX: 0.2,
|
|
||||||
endX: 0.5,
|
|
||||||
);
|
|
||||||
|
|
||||||
await pumpPill(tester, entry: projEntry);
|
await pumpPill(tester, entry: testEntry);
|
||||||
|
|
||||||
final positioned = tester.widget<Positioned>(
|
final positioned = tester.widget<Positioned>(
|
||||||
find.ancestor(
|
find
|
||||||
|
.ancestor(
|
||||||
of: find.byType(EventPill),
|
of: find.byType(EventPill),
|
||||||
matching: find.byType(Positioned),
|
matching: find.byType(Positioned),
|
||||||
).first,
|
)
|
||||||
|
.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
final expectedTop = LayoutCoordinateService.laneToY(
|
final expectedTop = LayoutCoordinateService.laneToY(
|
||||||
lane: 2,
|
lane: 2,
|
||||||
laneHeight: laneHeight,
|
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(
|
final expectedLeft = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
normalizedX: 0.2,
|
normalizedX: startX,
|
||||||
contentWidth: contentWidth,
|
contentWidth: contentWidth,
|
||||||
);
|
);
|
||||||
final expectedWidth = LayoutCoordinateService.calculateItemWidth(
|
final expectedWidth = LayoutCoordinateService.calculateItemWidth(
|
||||||
normalizedWidth: 0.3, // 0.5 - 0.2
|
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
|
||||||
contentWidth: contentWidth,
|
contentWidth: contentWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -174,14 +174,9 @@ void main() {
|
|||||||
|
|
||||||
group('drag flow', () {
|
group('drag flow', () {
|
||||||
testWidgets('pan from center triggers beginDrag', (tester) async {
|
testWidgets('pan from center triggers beginDrag', (tester) async {
|
||||||
// Use a wide pill so center is clearly in drag zone
|
final testEntry = makeEntry();
|
||||||
final projEntry = makeProjectedEntry(
|
|
||||||
entry: makeEntry(),
|
|
||||||
startX: 0.1,
|
|
||||||
endX: 0.4,
|
|
||||||
);
|
|
||||||
|
|
||||||
await pumpPill(tester, entry: projEntry);
|
await pumpPill(tester, entry: testEntry);
|
||||||
|
|
||||||
final pillCenter = tester.getCenter(find.byType(EventPill));
|
final pillCenter = tester.getCenter(find.byType(EventPill));
|
||||||
|
|
||||||
@@ -202,17 +197,21 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('resize', () {
|
group('resize', () {
|
||||||
testWidgets('pan from left edge triggers resize start', (tester) async {
|
// Resize tests need a pill wide enough for resize handles (>= 16px).
|
||||||
final projEntry = makeProjectedEntry(
|
// With a 30-day viewport and 800px contentWidth, a 7-day entry gives
|
||||||
entry: makeEntry(),
|
// ~186px — well above the 16px threshold.
|
||||||
startX: 0.1,
|
TimelineEntry makeWideEntry() => makeEntry(
|
||||||
endX: 0.4,
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(days: 7)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('pan from left edge triggers resize start', (tester) async {
|
||||||
|
final testEntry = makeWideEntry();
|
||||||
|
|
||||||
TimelineEntry? resizedEntry;
|
TimelineEntry? resizedEntry;
|
||||||
await pumpPill(
|
await pumpPill(
|
||||||
tester,
|
tester,
|
||||||
entry: projEntry,
|
entry: testEntry,
|
||||||
onEntryResized: (entry, newStart, newEnd, newLane) {
|
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||||
resizedEntry = entry;
|
resizedEntry = entry;
|
||||||
},
|
},
|
||||||
@@ -233,16 +232,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('pan from right edge triggers resize end', (tester) async {
|
testWidgets('pan from right edge triggers resize end', (tester) async {
|
||||||
final projEntry = makeProjectedEntry(
|
final testEntry = makeWideEntry();
|
||||||
entry: makeEntry(),
|
|
||||||
startX: 0.1,
|
|
||||||
endX: 0.4,
|
|
||||||
);
|
|
||||||
|
|
||||||
TimelineEntry? resizedEntry;
|
TimelineEntry? resizedEntry;
|
||||||
await pumpPill(
|
await pumpPill(
|
||||||
tester,
|
tester,
|
||||||
entry: projEntry,
|
entry: testEntry,
|
||||||
onEntryResized: (entry, newStart, newEnd, newLane) {
|
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||||
resizedEntry = entry;
|
resizedEntry = entry;
|
||||||
},
|
},
|
||||||
@@ -261,20 +256,22 @@ void main() {
|
|||||||
expect(resizedEntry, isNotNull);
|
expect(resizedEntry, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('narrow pill (< 16px) always uses drag mode', (
|
testWidgets('narrow pill (< 16px) always uses drag mode', (tester) async {
|
||||||
tester,
|
// Make a very narrow pill (< 16px) — start close to end
|
||||||
) async {
|
final narrowStart = kAnchor;
|
||||||
// Make a very narrow pill (< 16px)
|
final viewDuration =
|
||||||
final projEntry = makeProjectedEntry(
|
kDomainEnd.difference(kDomainStart).inMilliseconds;
|
||||||
entry: makeEntry(),
|
// 15px out of 800px contentWidth → 15/800 = 0.01875 of normalized
|
||||||
startX: 0.1,
|
final narrowDurationMs = (0.01875 * viewDuration).round();
|
||||||
endX: 0.1 + (15 / contentWidth), // ~15px wide
|
final narrowEnd = narrowStart.add(
|
||||||
|
Duration(milliseconds: narrowDurationMs),
|
||||||
);
|
);
|
||||||
|
final testEntry = makeEntry(start: narrowStart, end: narrowEnd);
|
||||||
|
|
||||||
TimelineEntry? resizedEntry;
|
TimelineEntry? resizedEntry;
|
||||||
await pumpPill(
|
await pumpPill(
|
||||||
tester,
|
tester,
|
||||||
entry: projEntry,
|
entry: testEntry,
|
||||||
onEntryResized: (entry, newStart, newEnd, newLane) {
|
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||||
resizedEntry = entry;
|
resizedEntry = entry;
|
||||||
},
|
},
|
||||||
@@ -302,18 +299,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('pill at 0.3 opacity during drag', (tester) async {
|
testWidgets('pill at 0.3 opacity during drag', (tester) async {
|
||||||
final projEntry = makeProjectedEntry(
|
final testEntry = makeEntry();
|
||||||
entry: makeEntry(),
|
await pumpPill(tester, entry: testEntry);
|
||||||
startX: 0.1,
|
|
||||||
endX: 0.4,
|
|
||||||
);
|
|
||||||
await pumpPill(tester, entry: projEntry);
|
|
||||||
|
|
||||||
// Start a drag by triggering beginDrag on the notifier directly
|
// Start a drag by triggering beginDrag on the notifier directly
|
||||||
final scope = ZTimelineScope.of(
|
final scope = ZTimelineScope.of(
|
||||||
tester.element(find.byType(InteractiveEventPill)),
|
tester.element(find.byType(InteractiveEventPill)),
|
||||||
);
|
);
|
||||||
scope.interaction.beginDrag(projEntry.entry);
|
scope.interaction.beginDrag(testEntry);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||||
@@ -321,24 +314,17 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('pill at 0.3 opacity during resize', (tester) async {
|
testWidgets('pill at 0.3 opacity during resize', (tester) async {
|
||||||
final projEntry = makeProjectedEntry(
|
final testEntry = makeEntry();
|
||||||
entry: makeEntry(),
|
|
||||||
startX: 0.1,
|
|
||||||
endX: 0.4,
|
|
||||||
);
|
|
||||||
await pumpPill(
|
await pumpPill(
|
||||||
tester,
|
tester,
|
||||||
entry: projEntry,
|
entry: testEntry,
|
||||||
onEntryResized: (_, __, ___, ____) {},
|
onEntryResized: (_, _, _, _) {},
|
||||||
);
|
);
|
||||||
|
|
||||||
final scope = ZTimelineScope.of(
|
final scope = ZTimelineScope.of(
|
||||||
tester.element(find.byType(InteractiveEventPill)),
|
tester.element(find.byType(InteractiveEventPill)),
|
||||||
);
|
);
|
||||||
scope.interaction.beginResize(
|
scope.interaction.beginResize(testEntry, ResizeEdge.end);
|
||||||
projEntry.entry,
|
|
||||||
ResizeEdge.end,
|
|
||||||
);
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||||
|
|||||||
Reference in New Issue
Block a user