resize
This commit is contained in:
@@ -169,6 +169,47 @@ class _MainAppState extends State<MainApp> {
|
||||
});
|
||||
}
|
||||
|
||||
void _onEntryResized(
|
||||
TimelineEntry entry,
|
||||
DateTime newStart,
|
||||
DateTime newEnd,
|
||||
int newLane,
|
||||
) {
|
||||
// Optimistic update -- apply locally before the host round-trips.
|
||||
if (_state case final state?) {
|
||||
final oldItem = state.items[entry.id];
|
||||
if (oldItem != null) {
|
||||
final updatedItems = Map<String, TimelineItemData>.of(state.items);
|
||||
updatedItems[entry.id] = TimelineItemData(
|
||||
id: oldItem.id,
|
||||
groupId: oldItem.groupId,
|
||||
title: oldItem.title,
|
||||
description: oldItem.description,
|
||||
start: newStart.toIso8601String(),
|
||||
end: entry.hasEnd ? newEnd.toIso8601String() : null,
|
||||
lane: newLane,
|
||||
);
|
||||
final updatedState = TimelineState(
|
||||
timeline: state.timeline,
|
||||
groups: state.groups,
|
||||
items: updatedItems,
|
||||
groupOrder: state.groupOrder,
|
||||
selectedItemId: state.selectedItemId,
|
||||
darkMode: state.darkMode,
|
||||
);
|
||||
_applyState(updatedState);
|
||||
}
|
||||
}
|
||||
|
||||
emitEvent('entry_resized', <String, Object?>{
|
||||
'entryId': entry.id,
|
||||
'newStart': newStart.toIso8601String(),
|
||||
'newEnd': entry.hasEnd ? newEnd.toIso8601String() : null,
|
||||
'groupId': entry.groupId,
|
||||
'lane': newLane,
|
||||
});
|
||||
}
|
||||
|
||||
void _emitContentHeight() {
|
||||
// Start with the fixed chrome heights.
|
||||
var totalHeight = _tieredHeaderHeight + _breadcrumbHeight;
|
||||
@@ -242,6 +283,7 @@ class _MainAppState extends State<MainApp> {
|
||||
colorBuilder: _colorForEntry,
|
||||
enableDrag: true,
|
||||
onEntryMoved: _onEntryMoved,
|
||||
onEntryResized: _onEntryResized,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,6 +19,10 @@ class ZTimelineConstants {
|
||||
vertical: 6.0,
|
||||
);
|
||||
|
||||
// Resize handles
|
||||
static const double resizeHandleWidth = 6.0;
|
||||
static const Duration minResizeDuration = Duration(hours: 1);
|
||||
|
||||
// Content width
|
||||
static const double minContentWidth = 1200.0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'timeline_entry.dart';
|
||||
|
||||
/// Which edge of the pill is being resized.
|
||||
enum ResizeEdge { start, end }
|
||||
|
||||
/// Immutable state tracking during resize operations.
|
||||
///
|
||||
/// Captures the entry being resized, which edge, and the target bounds.
|
||||
@immutable
|
||||
class EntryResizeState {
|
||||
const EntryResizeState({
|
||||
required this.entryId,
|
||||
required this.originalEntry,
|
||||
required this.edge,
|
||||
required this.targetStart,
|
||||
required this.targetEnd,
|
||||
required this.targetLane,
|
||||
});
|
||||
|
||||
/// The ID of the entry being resized.
|
||||
final String entryId;
|
||||
|
||||
/// The original entry (for reverting / reference).
|
||||
final TimelineEntry originalEntry;
|
||||
|
||||
/// Which edge the user is dragging.
|
||||
final ResizeEdge edge;
|
||||
|
||||
/// The current target start time.
|
||||
final DateTime targetStart;
|
||||
|
||||
/// The current target end time.
|
||||
final DateTime targetEnd;
|
||||
|
||||
/// The resolved lane (collision-aware).
|
||||
final int targetLane;
|
||||
|
||||
EntryResizeState copyWith({
|
||||
String? entryId,
|
||||
TimelineEntry? originalEntry,
|
||||
ResizeEdge? edge,
|
||||
DateTime? targetStart,
|
||||
DateTime? targetEnd,
|
||||
int? targetLane,
|
||||
}) {
|
||||
return EntryResizeState(
|
||||
entryId: entryId ?? this.entryId,
|
||||
originalEntry: originalEntry ?? this.originalEntry,
|
||||
edge: edge ?? this.edge,
|
||||
targetStart: targetStart ?? this.targetStart,
|
||||
targetEnd: targetEnd ?? this.targetEnd,
|
||||
targetLane: targetLane ?? this.targetLane,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is EntryResizeState &&
|
||||
other.entryId == entryId &&
|
||||
other.originalEntry == originalEntry &&
|
||||
other.edge == edge &&
|
||||
other.targetStart == targetStart &&
|
||||
other.targetEnd == targetEnd &&
|
||||
other.targetLane == targetLane;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
entryId,
|
||||
originalEntry,
|
||||
edge,
|
||||
targetStart,
|
||||
targetEnd,
|
||||
targetLane,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'EntryResizeState('
|
||||
'entryId: $entryId, '
|
||||
'edge: $edge, '
|
||||
'targetStart: $targetStart, '
|
||||
'targetEnd: $targetEnd, '
|
||||
'targetLane: $targetLane)';
|
||||
}
|
||||
@@ -9,22 +9,27 @@ class ZTimelineInteractionState {
|
||||
const ZTimelineInteractionState({
|
||||
this.isGrabbing = false,
|
||||
this.isDraggingEntry = false,
|
||||
this.isResizingEntry = false,
|
||||
});
|
||||
|
||||
/// Whether the user is actively panning (for cursor feedback).
|
||||
final bool isGrabbing;
|
||||
|
||||
/// Whether an entry is being dragged (disables pan gesture).
|
||||
/// This will be used by future drag-and-drop functionality.
|
||||
final bool isDraggingEntry;
|
||||
|
||||
/// Whether an entry is being resized (disables pan gesture).
|
||||
final bool isResizingEntry;
|
||||
|
||||
ZTimelineInteractionState copyWith({
|
||||
bool? isGrabbing,
|
||||
bool? isDraggingEntry,
|
||||
bool? isResizingEntry,
|
||||
}) {
|
||||
return ZTimelineInteractionState(
|
||||
isGrabbing: isGrabbing ?? this.isGrabbing,
|
||||
isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry,
|
||||
isResizingEntry: isResizingEntry ?? this.isResizingEntry,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,9 +38,10 @@ class ZTimelineInteractionState {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ZTimelineInteractionState &&
|
||||
other.isGrabbing == isGrabbing &&
|
||||
other.isDraggingEntry == isDraggingEntry;
|
||||
other.isDraggingEntry == isDraggingEntry &&
|
||||
other.isResizingEntry == isResizingEntry;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isGrabbing, isDraggingEntry);
|
||||
int get hashCode => Object.hash(isGrabbing, isDraggingEntry, isResizingEntry);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ class LayoutCoordinateService {
|
||||
required double normalizedWidth,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
return (normalizedWidth * contentWidth)
|
||||
.clamp(0.0, double.infinity);
|
||||
return (normalizedWidth * contentWidth).clamp(0.0, double.infinity);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -62,8 +61,7 @@ class LayoutCoordinateService {
|
||||
required double widgetX,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
final adjustedX =
|
||||
(widgetX).clamp(0.0, contentWidth);
|
||||
final adjustedX = (widgetX).clamp(0.0, contentWidth);
|
||||
return contentWidth == 0 ? 0.0 : adjustedX / contentWidth;
|
||||
}
|
||||
|
||||
@@ -75,10 +73,7 @@ class LayoutCoordinateService {
|
||||
///
|
||||
/// Used by all positioned elements (pills, ghost overlay) within the
|
||||
/// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0.
|
||||
static double laneToY({
|
||||
required int lane,
|
||||
required double laneHeight,
|
||||
}) {
|
||||
static double laneToY({required int lane, required double laneHeight}) {
|
||||
return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||
}
|
||||
|
||||
@@ -86,10 +81,7 @@ class LayoutCoordinateService {
|
||||
///
|
||||
/// Used by drop targets to determine which lane the cursor is over.
|
||||
/// The Y coordinate should be relative to the timeline Stack.
|
||||
static int yToLane({
|
||||
required double y,
|
||||
required double laneHeight,
|
||||
}) {
|
||||
static int yToLane({required double y, required double laneHeight}) {
|
||||
final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing;
|
||||
return (y / laneStep).floor() + 1;
|
||||
}
|
||||
|
||||
@@ -286,8 +286,10 @@ class TieredTickService {
|
||||
|
||||
// Start one period before to ensure we cover partial sections at the edge
|
||||
var current = alignToUnit(startUtc, unit);
|
||||
final oneBeforeStart =
|
||||
alignToUnit(current.subtract(const Duration(milliseconds: 1)), unit);
|
||||
final oneBeforeStart = alignToUnit(
|
||||
current.subtract(const Duration(milliseconds: 1)),
|
||||
unit,
|
||||
);
|
||||
current = oneBeforeStart;
|
||||
|
||||
while (current.isBefore(endUtc)) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
|
||||
/// Result of a hit test against registered groups.
|
||||
class GroupHitResult {
|
||||
const GroupHitResult({required this.groupId, required this.localPosition});
|
||||
|
||||
/// The ID of the group that was hit.
|
||||
final String groupId;
|
||||
|
||||
/// The position relative to the group's lanes area.
|
||||
final Offset localPosition;
|
||||
}
|
||||
|
||||
/// Registration data for a timeline group's lanes area.
|
||||
class _GroupRegistration {
|
||||
const _GroupRegistration({
|
||||
required this.key,
|
||||
required this.verticalOffset,
|
||||
required this.lanesCount,
|
||||
required this.laneHeight,
|
||||
required this.contentWidth,
|
||||
});
|
||||
|
||||
final GlobalKey key;
|
||||
final double verticalOffset;
|
||||
final int lanesCount;
|
||||
final double laneHeight;
|
||||
final double contentWidth;
|
||||
}
|
||||
|
||||
/// Registry for timeline group lane areas, enabling cross-group hit detection.
|
||||
///
|
||||
/// Each group registers its lanes area [GlobalKey] so that during drag
|
||||
/// operations we can determine which group contains a given global position
|
||||
/// without relying on Flutter's [DragTarget] system.
|
||||
class TimelineGroupRegistry {
|
||||
final _groups = <String, _GroupRegistration>{};
|
||||
|
||||
/// Register a group's lanes area.
|
||||
void register(
|
||||
String groupId,
|
||||
GlobalKey key, {
|
||||
required double verticalOffset,
|
||||
required int lanesCount,
|
||||
required double laneHeight,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
_groups[groupId] = _GroupRegistration(
|
||||
key: key,
|
||||
verticalOffset: verticalOffset,
|
||||
lanesCount: lanesCount,
|
||||
laneHeight: laneHeight,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
}
|
||||
|
||||
/// Unregister a group.
|
||||
void unregister(String groupId) {
|
||||
_groups.remove(groupId);
|
||||
}
|
||||
|
||||
/// Hit test against all registered groups.
|
||||
///
|
||||
/// Returns the group whose lanes area contains [globalPosition],
|
||||
/// along with the local position within that lanes area.
|
||||
GroupHitResult? hitTest(Offset globalPosition) {
|
||||
for (final entry in _groups.entries) {
|
||||
final reg = entry.value;
|
||||
final renderBox =
|
||||
reg.key.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null || !renderBox.attached) continue;
|
||||
|
||||
final local = renderBox.globalToLocal(globalPosition);
|
||||
final adjustedY = local.dy - reg.verticalOffset;
|
||||
|
||||
// Check horizontal bounds
|
||||
if (local.dx < 0 || local.dx > reg.contentWidth) continue;
|
||||
|
||||
// Check vertical bounds: allow one extra lane for expansion
|
||||
final maxLanes = (reg.lanesCount <= 0 ? 1 : reg.lanesCount) + 1;
|
||||
final maxY =
|
||||
maxLanes * (reg.laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||
if (adjustedY < 0 || adjustedY > maxY) continue;
|
||||
|
||||
return GroupHitResult(
|
||||
groupId: entry.key,
|
||||
localPosition: Offset(local.dx, adjustedY),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the lane height for a registered group.
|
||||
double? laneHeightFor(String groupId) => _groups[groupId]?.laneHeight;
|
||||
|
||||
/// Get the content width for a registered group.
|
||||
double? contentWidthFor(String groupId) => _groups[groupId]?.contentWidth;
|
||||
|
||||
/// Get the lanes count for a registered group.
|
||||
int? lanesCountFor(String groupId) => _groups[groupId]?.lanesCount;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/entry_drag_state.dart';
|
||||
import '../models/entry_resize_state.dart';
|
||||
import '../models/interaction_state.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
|
||||
@@ -13,6 +14,7 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
|
||||
ZTimelineInteractionState _state = const ZTimelineInteractionState();
|
||||
EntryDragState? _dragState;
|
||||
EntryResizeState? _resizeState;
|
||||
|
||||
/// The current interaction state.
|
||||
ZTimelineInteractionState get state => _state;
|
||||
@@ -20,12 +22,21 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// The current drag state, or null if no drag is active.
|
||||
EntryDragState? get dragState => _dragState;
|
||||
|
||||
/// The current resize state, or null if no resize is active.
|
||||
EntryResizeState? get resizeState => _resizeState;
|
||||
|
||||
/// Whether the user is actively panning (for cursor feedback).
|
||||
bool get isGrabbing => _state.isGrabbing;
|
||||
|
||||
/// Whether an entry is being dragged (disables pan gesture).
|
||||
bool get isDraggingEntry => _state.isDraggingEntry;
|
||||
|
||||
/// Whether an entry is being resized.
|
||||
bool get isResizingEntry => _state.isResizingEntry;
|
||||
|
||||
/// Whether any entry interaction (drag or resize) is active.
|
||||
bool get isInteracting => isDraggingEntry || isResizingEntry;
|
||||
|
||||
/// Update the grabbing state.
|
||||
void setGrabbing(bool value) {
|
||||
if (_state.isGrabbing == value) return;
|
||||
@@ -82,11 +93,44 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// Cancel the drag (alias for [endDrag]).
|
||||
void cancelDrag() => endDrag();
|
||||
|
||||
/// Called by drag-drop system when an entry drag starts.
|
||||
@Deprecated('Use beginDrag instead')
|
||||
void beginEntryDrag() => setDraggingEntry(true);
|
||||
/// Begin resizing an entry edge.
|
||||
void beginResize(TimelineEntry entry, ResizeEdge edge) {
|
||||
_resizeState = EntryResizeState(
|
||||
entryId: entry.id,
|
||||
originalEntry: entry,
|
||||
edge: edge,
|
||||
targetStart: entry.start,
|
||||
targetEnd: entry.end,
|
||||
targetLane: entry.lane,
|
||||
);
|
||||
_state = _state.copyWith(isResizingEntry: true);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called by drag-drop system when an entry drag ends.
|
||||
@Deprecated('Use endDrag instead')
|
||||
void endEntryDrag() => setDraggingEntry(false);
|
||||
/// Update the resize target times and optional lane.
|
||||
void updateResizeTarget({
|
||||
required DateTime targetStart,
|
||||
required DateTime targetEnd,
|
||||
int? targetLane,
|
||||
}) {
|
||||
if (_resizeState == null) return;
|
||||
final newState = _resizeState!.copyWith(
|
||||
targetStart: targetStart,
|
||||
targetEnd: targetEnd,
|
||||
targetLane: targetLane,
|
||||
);
|
||||
if (newState == _resizeState) return;
|
||||
_resizeState = newState;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// End the resize and clear state.
|
||||
void endResize() {
|
||||
_resizeState = null;
|
||||
_state = _state.copyWith(isResizingEntry: false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cancel the resize (alias for [endResize]).
|
||||
void cancelResize() => endResize();
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ class BreadcrumbSegmentChip extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: segment.isVisible ? _buildChip(context) : const SizedBox.shrink(),
|
||||
child: segment.isVisible
|
||||
? _buildChip(context)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -45,14 +47,16 @@ class BreadcrumbSegmentChip extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
segment.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/projected_entry.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import 'timeline_scope.dart';
|
||||
import 'timeline_view.dart';
|
||||
|
||||
/// A draggable event pill widget.
|
||||
///
|
||||
/// Renders a timeline entry as a pill that can be dragged to move it
|
||||
/// to a new position. Uses Flutter's built-in [Draggable] widget.
|
||||
class DraggableEventPill extends StatelessWidget {
|
||||
const DraggableEventPill({
|
||||
required this.entry,
|
||||
required this.laneHeight,
|
||||
required this.labelBuilder,
|
||||
required this.colorBuilder,
|
||||
required this.contentWidth,
|
||||
required this.enableDrag,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProjectedEntry entry;
|
||||
final double laneHeight;
|
||||
final EntryLabelBuilder labelBuilder;
|
||||
final EntryColorBuilder colorBuilder;
|
||||
final double contentWidth;
|
||||
final bool enableDrag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pill = _buildPill(context);
|
||||
|
||||
// Use centralized coordinate service for consistent positioning
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: entry.entry.lane,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: entry.startX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
final width = LayoutCoordinateService.calculateItemWidth(
|
||||
normalizedWidth: entry.widthX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
// Wrap pill in a MouseRegion so hovering shows a pointer cursor.
|
||||
// During an active entry drag, defer to the parent cursor (grabbing).
|
||||
final cursorPill = ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, child) => MouseRegion(
|
||||
cursor: scope.interaction.isDraggingEntry
|
||||
? MouseCursor.defer
|
||||
: SystemMouseCursors.click,
|
||||
child: child,
|
||||
),
|
||||
child: pill,
|
||||
);
|
||||
|
||||
if (!enableDrag) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
height: laneHeight,
|
||||
child: cursorPill,
|
||||
);
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
height: laneHeight,
|
||||
child: Draggable<TimelineEntry>(
|
||||
data: entry.entry,
|
||||
onDragStarted: () {
|
||||
scope.interaction.beginDrag(entry.entry);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
scope.interaction.cancelDrag();
|
||||
},
|
||||
onDragCompleted: () {
|
||||
// Handled by DragTarget
|
||||
},
|
||||
feedback: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.labelMedium ?? const TextStyle(),
|
||||
child: Opacity(
|
||||
opacity: 0.8,
|
||||
child: SizedBox(
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
height: laneHeight,
|
||||
child: pill,
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: Opacity(opacity: 0.3, child: pill),
|
||||
child: cursorPill,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPill(BuildContext context) {
|
||||
final color = colorBuilder(entry.entry);
|
||||
final onColor =
|
||||
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
|
||||
return Container(
|
||||
padding: ZTimelineConstants.pillPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
labelBuilder(entry.entry),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: onColor,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
|
||||
/// A standalone event pill widget with entry color, rounded corners,
|
||||
/// and auto text color based on brightness.
|
||||
class EventPill extends StatelessWidget {
|
||||
const EventPill({required this.color, required this.label, super.key});
|
||||
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onColor =
|
||||
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
|
||||
return Container(
|
||||
padding: ZTimelineConstants.pillPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: onColor,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/entry_drag_state.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'event_pill.dart';
|
||||
|
||||
/// A semi-transparent ghost overlay showing where an entry will land.
|
||||
///
|
||||
/// Displayed during drag operations to give visual feedback about the
|
||||
/// target position.
|
||||
/// Displayed during drag and resize operations to give visual feedback about
|
||||
/// the target position.
|
||||
class GhostOverlay extends StatelessWidget {
|
||||
const GhostOverlay({
|
||||
required this.dragState,
|
||||
required this.targetStart,
|
||||
required this.targetEnd,
|
||||
required this.targetLane,
|
||||
required this.viewport,
|
||||
required this.contentWidth,
|
||||
required this.laneHeight,
|
||||
this.title,
|
||||
this.color,
|
||||
this.label,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final EntryDragState dragState;
|
||||
final DateTime targetStart;
|
||||
final DateTime targetEnd;
|
||||
final int targetLane;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final double contentWidth;
|
||||
final double laneHeight;
|
||||
final String? title;
|
||||
|
||||
/// When provided, renders an [EventPill] with slight transparency instead of
|
||||
/// the generic primary-colored box. Used for resize ghosts.
|
||||
final Color? color;
|
||||
|
||||
/// Label for the pill when [color] is provided.
|
||||
final String? label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
dragState.targetStart,
|
||||
targetStart,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
final endX = TimeScaleService.mapTimeToPosition(
|
||||
dragState.targetEnd,
|
||||
targetEnd,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
@@ -47,31 +62,52 @@ class GhostOverlay extends StatelessWidget {
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: dragState.targetLane,
|
||||
lane: targetLane,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Positioned(
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
top: top,
|
||||
height: laneHeight,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
),
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.6),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: color != null
|
||||
? Opacity(
|
||||
opacity: 0.5,
|
||||
child: EventPill(color: color!, label: label ?? ''),
|
||||
)
|
||||
: _buildGenericGhost(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGenericGhost(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: ZTimelineConstants.pillPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
),
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.6),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: title != null
|
||||
? Text(
|
||||
title!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: scheme.primary.withValues(alpha: 0.8),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/projected_entry.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../models/timeline_group.dart';
|
||||
import '../services/entry_placement_service.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'timeline_scope.dart';
|
||||
import 'timeline_view.dart';
|
||||
|
||||
/// A drop target wrapper for a timeline group.
|
||||
///
|
||||
/// Wraps group lanes content and handles drag-and-drop operations.
|
||||
/// The ghost overlay is rendered by the parent widget in the same Stack.
|
||||
class GroupDropTarget extends StatelessWidget {
|
||||
const GroupDropTarget({
|
||||
required this.group,
|
||||
required this.entries,
|
||||
required this.allEntries,
|
||||
required this.viewport,
|
||||
required this.contentWidth,
|
||||
required this.laneHeight,
|
||||
required this.lanesCount,
|
||||
required this.onEntryMoved,
|
||||
required this.child,
|
||||
this.verticalOffset = 0.0,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineGroup group;
|
||||
final List<ProjectedEntry> entries;
|
||||
final List<TimelineEntry> allEntries;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final double contentWidth;
|
||||
final double laneHeight;
|
||||
final int lanesCount;
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
final Widget child;
|
||||
|
||||
/// Vertical offset from the top of this widget to the top of the lanes area.
|
||||
/// Used to correctly map pointer y-coordinates to lanes when this target
|
||||
/// wraps content above the lanes (e.g. group headers).
|
||||
final double verticalOffset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
return DragTarget<TimelineEntry>(
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
return child;
|
||||
},
|
||||
onWillAcceptWithDetails: (details) => true,
|
||||
onMove: (details) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final local = renderBox.globalToLocal(details.offset);
|
||||
|
||||
// Use centralized coordinate service for consistent transformations
|
||||
final ratio = LayoutCoordinateService.widgetXToNormalized(
|
||||
widgetX: local.dx,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
|
||||
final targetStart = TimeScaleService.mapPositionToTime(
|
||||
ratio,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
|
||||
// Adjust y to account for content above the lanes (e.g. group header)
|
||||
final adjustedY = local.dy - verticalOffset;
|
||||
|
||||
final rawLane = LayoutCoordinateService.yToLane(
|
||||
y: adjustedY,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1;
|
||||
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
||||
|
||||
// Resolve with collision avoidance
|
||||
final resolved = EntryPlacementService.resolvePlacement(
|
||||
entry: details.data,
|
||||
targetGroupId: group.id,
|
||||
targetLane: targetLane,
|
||||
targetStart: targetStart,
|
||||
existingEntries: allEntries,
|
||||
);
|
||||
|
||||
scope.interaction.updateDragTarget(
|
||||
targetGroupId: group.id,
|
||||
targetLane: resolved.lane,
|
||||
targetStart: targetStart,
|
||||
);
|
||||
},
|
||||
onAcceptWithDetails: (details) {
|
||||
final dragState = scope.interaction.dragState;
|
||||
if (dragState == null || onEntryMoved == null) {
|
||||
scope.interaction.endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
final resolved = EntryPlacementService.resolvePlacement(
|
||||
entry: details.data,
|
||||
targetGroupId: dragState.targetGroupId,
|
||||
targetLane: dragState.targetLane,
|
||||
targetStart: dragState.targetStart,
|
||||
existingEntries: allEntries,
|
||||
);
|
||||
|
||||
onEntryMoved!(
|
||||
details.data,
|
||||
dragState.targetStart,
|
||||
dragState.targetGroupId,
|
||||
resolved.lane,
|
||||
);
|
||||
|
||||
scope.interaction.endDrag();
|
||||
},
|
||||
onLeave: (data) {
|
||||
// Don't clear on leave - entry might move to another group
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/entry_resize_state.dart';
|
||||
import '../models/projected_entry.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../services/entry_placement_service.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
import '../services/timeline_group_registry.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'event_pill.dart';
|
||||
import 'timeline_scope.dart';
|
||||
import 'timeline_view.dart';
|
||||
|
||||
/// Interaction mode determined on pan-down.
|
||||
enum _InteractionMode { resizeStart, resizeEnd, drag }
|
||||
|
||||
/// An interactive event pill that handles both resize and drag-move
|
||||
/// with a single [GestureDetector] using pan gestures.
|
||||
class InteractiveEventPill extends StatefulWidget {
|
||||
const InteractiveEventPill({
|
||||
required this.entry,
|
||||
required this.laneHeight,
|
||||
required this.labelBuilder,
|
||||
required this.colorBuilder,
|
||||
required this.contentWidth,
|
||||
required this.enableDrag,
|
||||
required this.viewport,
|
||||
this.allEntries = const [],
|
||||
this.onEntryResized,
|
||||
this.onEntryMoved,
|
||||
this.groupRegistry,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProjectedEntry entry;
|
||||
final double laneHeight;
|
||||
final EntryLabelBuilder labelBuilder;
|
||||
final EntryColorBuilder colorBuilder;
|
||||
final double contentWidth;
|
||||
final bool enableDrag;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final List<TimelineEntry> allEntries;
|
||||
final OnEntryResized? onEntryResized;
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
final TimelineGroupRegistry? groupRegistry;
|
||||
|
||||
@override
|
||||
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
|
||||
}
|
||||
|
||||
class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
_InteractionMode? _mode;
|
||||
double _cumulativeDx = 0;
|
||||
|
||||
/// Normalized offset from the entry's start to the grab point,
|
||||
/// so dragging doesn't snap the entry start to the cursor.
|
||||
double _grabOffsetNormalized = 0;
|
||||
|
||||
double get _pillWidth {
|
||||
return LayoutCoordinateService.calculateItemWidth(
|
||||
normalizedWidth: widget.entry.widthX,
|
||||
contentWidth: widget.contentWidth,
|
||||
).clamp(0.0, double.infinity);
|
||||
}
|
||||
|
||||
_InteractionMode _determineMode(Offset localPosition) {
|
||||
final pillWidth = _pillWidth;
|
||||
const handleWidth = ZTimelineConstants.resizeHandleWidth;
|
||||
|
||||
if (widget.onEntryResized != null && pillWidth >= 16) {
|
||||
if (localPosition.dx <= handleWidth) {
|
||||
return _InteractionMode.resizeStart;
|
||||
}
|
||||
if (localPosition.dx >= pillWidth - handleWidth) {
|
||||
return _InteractionMode.resizeEnd;
|
||||
}
|
||||
}
|
||||
return _InteractionMode.drag;
|
||||
}
|
||||
|
||||
void _onPanDown(DragDownDetails details) {
|
||||
_mode = _determineMode(details.localPosition);
|
||||
_grabOffsetNormalized = details.localPosition.dx / widget.contentWidth;
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final entry = widget.entry.entry;
|
||||
|
||||
_cumulativeDx = 0;
|
||||
|
||||
switch (_mode!) {
|
||||
case _InteractionMode.resizeStart:
|
||||
scope.interaction.beginResize(entry, ResizeEdge.start);
|
||||
case _InteractionMode.resizeEnd:
|
||||
scope.interaction.beginResize(entry, ResizeEdge.end);
|
||||
case _InteractionMode.drag:
|
||||
scope.interaction.beginDrag(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
switch (_mode!) {
|
||||
case _InteractionMode.resizeStart:
|
||||
case _InteractionMode.resizeEnd:
|
||||
_handleResizeUpdate(details);
|
||||
case _InteractionMode.drag:
|
||||
_handleDragUpdate(details);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleResizeUpdate(DragUpdateDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
_cumulativeDx += details.delta.dx;
|
||||
|
||||
final deltaNormalized = _cumulativeDx / widget.contentWidth;
|
||||
final originalEntry = widget.entry.entry;
|
||||
final edge = _mode == _InteractionMode.resizeStart
|
||||
? ResizeEdge.start
|
||||
: ResizeEdge.end;
|
||||
|
||||
DateTime newStart = originalEntry.start;
|
||||
DateTime newEnd = originalEntry.end;
|
||||
|
||||
if (edge == ResizeEdge.start) {
|
||||
final originalStartNorm = TimeScaleService.mapTimeToPosition(
|
||||
originalEntry.start,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
newStart = TimeScaleService.mapPositionToTime(
|
||||
originalStartNorm + deltaNormalized,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
final maxStart = newEnd.subtract(ZTimelineConstants.minResizeDuration);
|
||||
if (newStart.isAfter(maxStart)) {
|
||||
newStart = maxStart;
|
||||
}
|
||||
} else {
|
||||
final originalEndNorm = TimeScaleService.mapTimeToPosition(
|
||||
originalEntry.end,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
newEnd = TimeScaleService.mapPositionToTime(
|
||||
originalEndNorm + deltaNormalized,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
final minEnd = newStart.add(ZTimelineConstants.minResizeDuration);
|
||||
if (newEnd.isBefore(minEnd)) {
|
||||
newEnd = minEnd;
|
||||
}
|
||||
}
|
||||
|
||||
final resolvedLane = EntryPlacementService.findNearestAvailableLane(
|
||||
entryId: originalEntry.id,
|
||||
targetGroupId: originalEntry.groupId,
|
||||
targetLane: originalEntry.lane,
|
||||
targetStart: newStart,
|
||||
targetEnd: newEnd,
|
||||
existingEntries: widget.allEntries,
|
||||
);
|
||||
|
||||
scope.interaction.updateResizeTarget(
|
||||
targetStart: newStart,
|
||||
targetEnd: newEnd,
|
||||
targetLane: resolvedLane,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final registry = widget.groupRegistry;
|
||||
if (registry == null) return;
|
||||
|
||||
final hit = registry.hitTest(details.globalPosition);
|
||||
if (hit == null) return;
|
||||
|
||||
final laneHeight = registry.laneHeightFor(hit.groupId) ?? widget.laneHeight;
|
||||
final contentWidth =
|
||||
registry.contentWidthFor(hit.groupId) ?? widget.contentWidth;
|
||||
final lanesCount = registry.lanesCountFor(hit.groupId) ?? 1;
|
||||
|
||||
final ratio = LayoutCoordinateService.widgetXToNormalized(
|
||||
widgetX: hit.localPosition.dx,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
|
||||
final targetStart = TimeScaleService.mapPositionToTime(
|
||||
ratio - _grabOffsetNormalized,
|
||||
widget.viewport.start,
|
||||
widget.viewport.end,
|
||||
);
|
||||
|
||||
final rawLane = LayoutCoordinateService.yToLane(
|
||||
y: hit.localPosition.dy,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1;
|
||||
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
||||
|
||||
final resolved = EntryPlacementService.resolvePlacement(
|
||||
entry: widget.entry.entry,
|
||||
targetGroupId: hit.groupId,
|
||||
targetLane: targetLane,
|
||||
targetStart: targetStart,
|
||||
existingEntries: widget.allEntries,
|
||||
);
|
||||
|
||||
scope.interaction.updateDragTarget(
|
||||
targetGroupId: hit.groupId,
|
||||
targetLane: resolved.lane,
|
||||
targetStart: targetStart,
|
||||
);
|
||||
}
|
||||
|
||||
void _onPanEnd(DragEndDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
switch (_mode!) {
|
||||
case _InteractionMode.resizeStart:
|
||||
case _InteractionMode.resizeEnd:
|
||||
final resizeState = scope.interaction.resizeState;
|
||||
if (resizeState != null && widget.onEntryResized != null) {
|
||||
widget.onEntryResized!(
|
||||
resizeState.originalEntry,
|
||||
resizeState.targetStart,
|
||||
resizeState.targetEnd,
|
||||
resizeState.targetLane,
|
||||
);
|
||||
}
|
||||
scope.interaction.endResize();
|
||||
case _InteractionMode.drag:
|
||||
final dragState = scope.interaction.dragState;
|
||||
if (dragState != null && widget.onEntryMoved != null) {
|
||||
widget.onEntryMoved!(
|
||||
dragState.originalEntry,
|
||||
dragState.targetStart,
|
||||
dragState.targetGroupId,
|
||||
dragState.targetLane,
|
||||
);
|
||||
}
|
||||
scope.interaction.endDrag();
|
||||
}
|
||||
|
||||
_mode = null;
|
||||
}
|
||||
|
||||
void _onPanCancel() {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
switch (_mode) {
|
||||
case _InteractionMode.resizeStart:
|
||||
case _InteractionMode.resizeEnd:
|
||||
scope.interaction.cancelResize();
|
||||
case _InteractionMode.drag:
|
||||
scope.interaction.cancelDrag();
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
|
||||
_mode = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pill = EventPill(
|
||||
color: widget.colorBuilder(widget.entry.entry),
|
||||
label: widget.labelBuilder(widget.entry.entry),
|
||||
);
|
||||
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: widget.entry.entry.lane,
|
||||
laneHeight: widget.laneHeight,
|
||||
);
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: widget.entry.startX,
|
||||
contentWidth: widget.contentWidth,
|
||||
);
|
||||
final width = _pillWidth;
|
||||
|
||||
if (!widget.enableDrag) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width,
|
||||
height: widget.laneHeight,
|
||||
child: pill,
|
||||
);
|
||||
}
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final hasResizeHandles = widget.onEntryResized != null && width >= 16;
|
||||
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width,
|
||||
height: widget.laneHeight,
|
||||
child: ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, child) {
|
||||
final entryId = widget.entry.entry.id;
|
||||
final isDragging =
|
||||
scope.interaction.isDraggingEntry &&
|
||||
scope.interaction.dragState?.entryId == entryId;
|
||||
final isResizing =
|
||||
scope.interaction.isResizingEntry &&
|
||||
scope.interaction.resizeState?.entryId == entryId;
|
||||
|
||||
return Opacity(
|
||||
opacity: isDragging || isResizing ? 0.3 : 1.0,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _buildInteractivePill(pill, hasResizeHandles),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInteractivePill(Widget pill, bool hasResizeHandles) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: _onPanDown,
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
onPanCancel: _onPanCancel,
|
||||
child: Stack(
|
||||
children: [
|
||||
pill,
|
||||
// Left resize cursor zone
|
||||
if (hasResizeHandles)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: ZTimelineConstants.resizeHandleWidth,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
opaque: false,
|
||||
),
|
||||
),
|
||||
// Right resize cursor zone
|
||||
if (hasResizeHandles)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: ZTimelineConstants.resizeHandleWidth,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
opaque: false,
|
||||
),
|
||||
),
|
||||
// Center click cursor zone (between handles)
|
||||
if (hasResizeHandles)
|
||||
Positioned(
|
||||
left: ZTimelineConstants.resizeHandleWidth,
|
||||
right: ZTimelineConstants.resizeHandleWidth,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: false,
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,7 @@ class ZTimelineBreadcrumb extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BreadcrumbSegmentRow extends StatelessWidget {
|
||||
const _BreadcrumbSegmentRow({
|
||||
required this.segments,
|
||||
required this.viewport,
|
||||
});
|
||||
const _BreadcrumbSegmentRow({required this.segments, required this.viewport});
|
||||
|
||||
final List<BreadcrumbSegment> segments;
|
||||
final TimelineViewportNotifier viewport;
|
||||
@@ -173,9 +170,9 @@ class _ZoomLevelIndicator extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
level.label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||
// Single-finger pan
|
||||
else if (details.pointerCount == 1 &&
|
||||
config.enablePan &&
|
||||
!scope.interaction.isDraggingEntry) {
|
||||
!scope.interaction.isInteracting) {
|
||||
if (_lastFocalPoint != null) {
|
||||
final diff = details.focalPoint - _lastFocalPoint!;
|
||||
final ratio = -diff.dx / width;
|
||||
@@ -221,9 +221,10 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||
listenable: scope.interaction,
|
||||
builder: (context, child) {
|
||||
return MouseRegion(
|
||||
cursor:
|
||||
scope.interaction.isGrabbing ||
|
||||
scope.interaction.isDraggingEntry
|
||||
cursor: scope.interaction.isResizingEntry
|
||||
? SystemMouseCursors.resizeColumn
|
||||
: scope.interaction.isGrabbing ||
|
||||
scope.interaction.isDraggingEntry
|
||||
? SystemMouseCursors.grabbing
|
||||
: SystemMouseCursors.basic,
|
||||
child: child,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/interaction_config.dart';
|
||||
import '../services/timeline_group_registry.dart';
|
||||
import '../state/timeline_interaction_notifier.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
|
||||
@@ -61,11 +62,13 @@ class ZTimelineScope extends StatefulWidget {
|
||||
|
||||
class _ZTimelineScopeState extends State<ZTimelineScope> {
|
||||
late final ZTimelineInteractionNotifier _interactionNotifier;
|
||||
late final TimelineGroupRegistry _groupRegistry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_interactionNotifier = ZTimelineInteractionNotifier();
|
||||
_groupRegistry = TimelineGroupRegistry();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -81,6 +84,7 @@ class _ZTimelineScopeState extends State<ZTimelineScope> {
|
||||
viewport: widget.viewport,
|
||||
interaction: _interactionNotifier,
|
||||
config: widget.config,
|
||||
groupRegistry: _groupRegistry,
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
@@ -105,6 +109,7 @@ class ZTimelineScopeData {
|
||||
required this.viewport,
|
||||
required this.interaction,
|
||||
required this.config,
|
||||
required this.groupRegistry,
|
||||
});
|
||||
|
||||
/// The viewport notifier for domain state (start/end times).
|
||||
@@ -116,15 +121,19 @@ class ZTimelineScopeData {
|
||||
/// Configuration for interaction behavior.
|
||||
final ZTimelineInteractionConfig config;
|
||||
|
||||
/// Registry for group lane areas, used for cross-group hit detection.
|
||||
final TimelineGroupRegistry groupRegistry;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ZTimelineScopeData &&
|
||||
other.viewport == viewport &&
|
||||
other.interaction == interaction &&
|
||||
other.config == config;
|
||||
other.config == config &&
|
||||
other.groupRegistry == groupRegistry;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(viewport, interaction, config);
|
||||
int get hashCode => Object.hash(viewport, interaction, config, groupRegistry);
|
||||
}
|
||||
|
||||
@@ -97,8 +97,9 @@ class ZTimelineTieredHeader extends StatelessWidget {
|
||||
domainEnd: effectiveViewport.end,
|
||||
borderColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||
secondaryLabelColor:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
secondaryLabelColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -126,9 +127,9 @@ class _ConfigIndicator extends StatelessWidget {
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
'Zoom: $configName',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -155,10 +156,9 @@ class _TieredHeaderPainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final borderPaint =
|
||||
Paint()
|
||||
..color = borderColor
|
||||
..strokeWidth = 1.0;
|
||||
final borderPaint = Paint()
|
||||
..color = borderColor
|
||||
..strokeWidth = 1.0;
|
||||
|
||||
// Draw horizontal border between tiers
|
||||
canvas.drawLine(
|
||||
|
||||
@@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/entry_drag_state.dart';
|
||||
import '../models/entry_resize_state.dart';
|
||||
import '../models/projected_entry.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../models/timeline_group.dart';
|
||||
import '../services/timeline_projection_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'draggable_event_pill.dart';
|
||||
import 'ghost_overlay.dart';
|
||||
import 'group_drop_target.dart';
|
||||
import 'interactive_event_pill.dart';
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
typedef EntryLabelBuilder = String Function(TimelineEntry entry);
|
||||
@@ -24,6 +24,15 @@ typedef OnEntryMoved =
|
||||
int newLane,
|
||||
);
|
||||
|
||||
/// Callback signature for when an entry is resized via edge drag.
|
||||
typedef OnEntryResized =
|
||||
void Function(
|
||||
TimelineEntry entry,
|
||||
DateTime newStart,
|
||||
DateTime newEnd,
|
||||
int newLane,
|
||||
);
|
||||
|
||||
/// Base timeline view: renders groups with between-group headers and
|
||||
/// lane rows containing event pills.
|
||||
class ZTimelineView extends StatelessWidget {
|
||||
@@ -37,6 +46,7 @@ class ZTimelineView extends StatelessWidget {
|
||||
this.laneHeight = ZTimelineConstants.laneHeight,
|
||||
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
|
||||
this.onEntryMoved,
|
||||
this.onEntryResized,
|
||||
this.enableDrag = true,
|
||||
});
|
||||
|
||||
@@ -54,6 +64,9 @@ class ZTimelineView extends StatelessWidget {
|
||||
/// position. The [newLane] is calculated to avoid collisions.
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
|
||||
/// Callback invoked when an entry is resized via edge drag.
|
||||
final OnEntryResized? onEntryResized;
|
||||
|
||||
/// Whether drag-and-drop is enabled.
|
||||
final bool enableDrag;
|
||||
|
||||
@@ -82,13 +95,14 @@ class ZTimelineView extends StatelessWidget {
|
||||
projected[group.id] ?? const <ProjectedEntry>[];
|
||||
final lanesCount = _countLanes(groupEntries);
|
||||
|
||||
Widget groupColumn = Column(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||
_GroupLanes(
|
||||
group: group,
|
||||
entries: groupEntries,
|
||||
allEntries: entries,
|
||||
viewport: viewport,
|
||||
lanesCount: lanesCount,
|
||||
laneHeight: laneHeight,
|
||||
@@ -96,30 +110,11 @@ class ZTimelineView extends StatelessWidget {
|
||||
labelBuilder: labelBuilder,
|
||||
contentWidth: contentWidth,
|
||||
enableDrag: enableDrag,
|
||||
onEntryResized: onEntryResized,
|
||||
onEntryMoved: onEntryMoved,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Wrap the entire group (header + lanes) in a DragTarget
|
||||
// so dragging over headers doesn't create a dead zone.
|
||||
if (enableDrag && onEntryMoved != null) {
|
||||
groupColumn = GroupDropTarget(
|
||||
group: group,
|
||||
entries: groupEntries,
|
||||
allEntries: entries,
|
||||
viewport: viewport,
|
||||
contentWidth: contentWidth,
|
||||
laneHeight: laneHeight,
|
||||
lanesCount: lanesCount,
|
||||
onEntryMoved: onEntryMoved,
|
||||
verticalOffset:
|
||||
groupHeaderHeight +
|
||||
ZTimelineConstants.verticalOuterPadding,
|
||||
child: groupColumn,
|
||||
);
|
||||
}
|
||||
|
||||
return groupColumn;
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -162,10 +157,11 @@ class _GroupHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupLanes extends StatelessWidget {
|
||||
class _GroupLanes extends StatefulWidget {
|
||||
const _GroupLanes({
|
||||
required this.group,
|
||||
required this.entries,
|
||||
required this.allEntries,
|
||||
required this.viewport,
|
||||
required this.lanesCount,
|
||||
required this.laneHeight,
|
||||
@@ -173,10 +169,13 @@ class _GroupLanes extends StatelessWidget {
|
||||
required this.colorBuilder,
|
||||
required this.contentWidth,
|
||||
required this.enableDrag,
|
||||
this.onEntryResized,
|
||||
this.onEntryMoved,
|
||||
});
|
||||
|
||||
final TimelineGroup group;
|
||||
final List<ProjectedEntry> entries;
|
||||
final List<TimelineEntry> allEntries;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final int lanesCount;
|
||||
final double laneHeight;
|
||||
@@ -184,24 +183,76 @@ class _GroupLanes extends StatelessWidget {
|
||||
final EntryColorBuilder colorBuilder;
|
||||
final double contentWidth;
|
||||
final bool enableDrag;
|
||||
final OnEntryResized? onEntryResized;
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
|
||||
@override
|
||||
State<_GroupLanes> createState() => _GroupLanesState();
|
||||
}
|
||||
|
||||
class _GroupLanesState extends State<_GroupLanes> {
|
||||
final GlobalKey _lanesKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_registerGroup();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_GroupLanes oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.group.id != widget.group.id) {
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
scope?.groupRegistry.unregister(oldWidget.group.id);
|
||||
}
|
||||
if (oldWidget.lanesCount != widget.lanesCount ||
|
||||
oldWidget.laneHeight != widget.laneHeight ||
|
||||
oldWidget.contentWidth != widget.contentWidth ||
|
||||
oldWidget.group.id != widget.group.id) {
|
||||
_registerGroup();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
scope?.groupRegistry.unregister(widget.group.id);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerGroup() {
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
if (scope == null || !widget.enableDrag) return;
|
||||
|
||||
scope.groupRegistry.register(
|
||||
widget.group.id,
|
||||
_lanesKey,
|
||||
verticalOffset: ZTimelineConstants.verticalOuterPadding,
|
||||
lanesCount: widget.lanesCount,
|
||||
laneHeight: widget.laneHeight,
|
||||
contentWidth: widget.contentWidth,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
|
||||
// If no scope (drag not enabled), use static height
|
||||
if (scope == null || !enableDrag) {
|
||||
return _buildContent(context, lanesCount);
|
||||
if (scope == null || !widget.enableDrag) {
|
||||
return _buildContent(context, widget.lanesCount);
|
||||
}
|
||||
|
||||
// Listen to interaction notifier for drag state changes
|
||||
// Listen to interaction notifier for drag/resize state changes
|
||||
return ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
final effectiveLanesCount = _calculateEffectiveLanesCount(
|
||||
actualLanesCount: lanesCount,
|
||||
actualLanesCount: widget.lanesCount,
|
||||
dragState: scope.interaction.dragState,
|
||||
groupId: group.id,
|
||||
resizeState: scope.interaction.resizeState,
|
||||
groupId: widget.group.id,
|
||||
);
|
||||
|
||||
return _buildContent(context, effectiveLanesCount);
|
||||
@@ -212,27 +263,31 @@ class _GroupLanes extends StatelessWidget {
|
||||
int _calculateEffectiveLanesCount({
|
||||
required int actualLanesCount,
|
||||
required EntryDragState? dragState,
|
||||
required EntryResizeState? resizeState,
|
||||
required String groupId,
|
||||
}) {
|
||||
// No drag active - use actual lane count
|
||||
if (dragState == null) {
|
||||
return actualLanesCount;
|
||||
var effective = actualLanesCount;
|
||||
|
||||
// Expand for drag target lane
|
||||
if (dragState != null && dragState.targetGroupId == groupId) {
|
||||
if (dragState.targetLane > effective) {
|
||||
effective = dragState.targetLane;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag active but over different group - use actual lane count
|
||||
if (dragState.targetGroupId != groupId) {
|
||||
return actualLanesCount;
|
||||
// Expand for resize target lane
|
||||
if (resizeState != null && resizeState.originalEntry.groupId == groupId) {
|
||||
if (resizeState.targetLane > effective) {
|
||||
effective = resizeState.targetLane;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag active over this group - expand to accommodate target lane
|
||||
return actualLanesCount > dragState.targetLane
|
||||
? actualLanesCount
|
||||
: dragState.targetLane;
|
||||
return effective;
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, int effectiveLanesCount) {
|
||||
final totalHeight =
|
||||
effectiveLanesCount * laneHeight +
|
||||
effectiveLanesCount * widget.laneHeight +
|
||||
(effectiveLanesCount > 0
|
||||
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
||||
: 0);
|
||||
@@ -245,31 +300,58 @@ class _GroupLanes extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Event pills
|
||||
for (final e in entries)
|
||||
DraggableEventPill(
|
||||
for (final e in widget.entries)
|
||||
InteractiveEventPill(
|
||||
entry: e,
|
||||
laneHeight: laneHeight,
|
||||
labelBuilder: labelBuilder,
|
||||
colorBuilder: colorBuilder,
|
||||
contentWidth: contentWidth,
|
||||
enableDrag: enableDrag,
|
||||
laneHeight: widget.laneHeight,
|
||||
labelBuilder: widget.labelBuilder,
|
||||
colorBuilder: widget.colorBuilder,
|
||||
contentWidth: widget.contentWidth,
|
||||
enableDrag: widget.enableDrag,
|
||||
viewport: widget.viewport,
|
||||
allEntries: widget.allEntries,
|
||||
onEntryResized: widget.onEntryResized,
|
||||
onEntryMoved: widget.onEntryMoved,
|
||||
groupRegistry: scope?.groupRegistry,
|
||||
),
|
||||
|
||||
// Ghost overlay (rendered in same coordinate space as pills)
|
||||
if (enableDrag && scope != null)
|
||||
// Ghost overlay for drag operations
|
||||
if (widget.enableDrag && scope != null)
|
||||
ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
final dragState = scope.interaction.dragState;
|
||||
if (dragState == null || dragState.targetGroupId != group.id) {
|
||||
return const SizedBox.shrink();
|
||||
final resizeState = scope.interaction.resizeState;
|
||||
|
||||
// Show ghost for resize (entry stays in its own group)
|
||||
if (resizeState != null &&
|
||||
resizeState.originalEntry.groupId == widget.group.id) {
|
||||
return GhostOverlay(
|
||||
targetStart: resizeState.targetStart,
|
||||
targetEnd: resizeState.targetEnd,
|
||||
targetLane: resizeState.targetLane,
|
||||
viewport: widget.viewport,
|
||||
contentWidth: widget.contentWidth,
|
||||
laneHeight: widget.laneHeight,
|
||||
color: widget.colorBuilder(resizeState.originalEntry),
|
||||
label: widget.labelBuilder(resizeState.originalEntry),
|
||||
);
|
||||
}
|
||||
return GhostOverlay(
|
||||
dragState: dragState,
|
||||
viewport: viewport,
|
||||
contentWidth: contentWidth,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
|
||||
// Show ghost for drag-move
|
||||
if (dragState != null &&
|
||||
dragState.targetGroupId == widget.group.id) {
|
||||
return GhostOverlay(
|
||||
targetStart: dragState.targetStart,
|
||||
targetEnd: dragState.targetEnd,
|
||||
targetLane: dragState.targetLane,
|
||||
viewport: widget.viewport,
|
||||
contentWidth: widget.contentWidth,
|
||||
laneHeight: widget.laneHeight,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -281,6 +363,7 @@ class _GroupLanes extends StatelessWidget {
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
key: _lanesKey,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: ZTimelineConstants.verticalOuterPadding,
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ export 'src/constants.dart';
|
||||
// Models
|
||||
export 'src/models/breadcrumb_segment.dart';
|
||||
export 'src/models/entry_drag_state.dart';
|
||||
export 'src/models/entry_resize_state.dart';
|
||||
export 'src/models/interaction_config.dart';
|
||||
export 'src/models/interaction_state.dart';
|
||||
export 'src/models/projected_entry.dart';
|
||||
@@ -23,6 +24,7 @@ export 'src/services/entry_placement_service.dart';
|
||||
export 'src/services/layout_coordinate_service.dart';
|
||||
export 'src/services/tiered_tick_service.dart';
|
||||
export 'src/services/time_scale_service.dart';
|
||||
export 'src/services/timeline_group_registry.dart';
|
||||
export 'src/services/timeline_projection_service.dart';
|
||||
|
||||
// State
|
||||
@@ -31,9 +33,9 @@ export 'src/state/timeline_viewport_notifier.dart';
|
||||
|
||||
// Widgets
|
||||
export 'src/widgets/breadcrumb_segment_chip.dart';
|
||||
export 'src/widgets/draggable_event_pill.dart';
|
||||
export 'src/widgets/event_pill.dart';
|
||||
export 'src/widgets/ghost_overlay.dart';
|
||||
export 'src/widgets/group_drop_target.dart';
|
||||
export 'src/widgets/interactive_event_pill.dart';
|
||||
export 'src/widgets/timeline_breadcrumb.dart';
|
||||
export 'src/widgets/timeline_interactor.dart';
|
||||
export 'src/widgets/timeline_scope.dart';
|
||||
|
||||
@@ -1,46 +1,338 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="description" content="Zendegi Timeline - Dev Mode">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="z_flutter">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>z_flutter</title>
|
||||
<title>Zendegi Timeline (Dev)</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#toolbar .group { display: flex; align-items: center; gap: 4px; }
|
||||
#toolbar label { color: #8899aa; }
|
||||
#toolbar button {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
#toolbar button:hover { background: #0f3460; }
|
||||
#toolbar .sep { width: 1px; height: 20px; background: #0f3460; }
|
||||
#event-log {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
background: #0d1117;
|
||||
border-top: 1px solid #0f3460;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
z-index: 100;
|
||||
}
|
||||
#event-log .entry { padding: 2px 0; border-bottom: 1px solid #161b22; }
|
||||
#event-log .type { color: #58a6ff; }
|
||||
#event-log .time { color: #6e7681; margin-right: 8px; }
|
||||
#event-log .payload { color: #8b949e; }
|
||||
#flutter-container { flex: 1; min-height: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
<div id="toolbar">
|
||||
<div class="group">
|
||||
<label>Theme:</label>
|
||||
<button id="btn-light">Light</button>
|
||||
<button id="btn-dark">Dark</button>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="group">
|
||||
<label>Data:</label>
|
||||
<button id="btn-few">Few items</button>
|
||||
<button id="btn-many">Many items</button>
|
||||
<button id="btn-empty">Empty</button>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="group">
|
||||
<button id="btn-push">Push state</button>
|
||||
<button id="btn-clear-log">Clear log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="flutter-container"></div>
|
||||
<div id="event-log"></div>
|
||||
|
||||
<script>
|
||||
// -----------------------------------------------------------------------
|
||||
// Test data generators
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function makeId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function iso(year, month, day) {
|
||||
return new Date(year, month - 1, day).toISOString();
|
||||
}
|
||||
|
||||
function buildFewItems() {
|
||||
const g1 = makeId();
|
||||
const g2 = makeId();
|
||||
const g3 = makeId();
|
||||
|
||||
const items = {};
|
||||
const addItem = (groupId, title, start, end, lane, desc) => {
|
||||
const id = makeId();
|
||||
items[id] = {
|
||||
id, groupId, title, description: desc ?? null,
|
||||
start, end: end ?? null, lane,
|
||||
};
|
||||
};
|
||||
|
||||
// Work group
|
||||
addItem(g1, "Project Alpha", iso(2026, 1, 5), iso(2026, 3, 15), 1, "Main project");
|
||||
addItem(g1, "Project Beta", iso(2026, 2, 10), iso(2026, 5, 20), 2, "Secondary project");
|
||||
addItem(g1, "Code Review", iso(2026, 3, 1), iso(2026, 3, 5), 1);
|
||||
addItem(g1, "Sprint Planning", iso(2026, 1, 15), null, 3); // point event
|
||||
|
||||
// Personal group
|
||||
addItem(g2, "Vacation", iso(2026, 4, 1), iso(2026, 4, 14), 1, "Spring break");
|
||||
addItem(g2, "Birthday", iso(2026, 6, 12), null, 1); // point event
|
||||
addItem(g2, "Move apartments", iso(2026, 3, 20), iso(2026, 3, 25), 2);
|
||||
|
||||
// Learning group
|
||||
addItem(g3, "Flutter course", iso(2026, 1, 1), iso(2026, 2, 28), 1);
|
||||
addItem(g3, "Rust book", iso(2026, 2, 15), iso(2026, 4, 30), 2);
|
||||
addItem(g3, "Conference talk", iso(2026, 5, 10), null, 1); // point event
|
||||
|
||||
return {
|
||||
timeline: { id: makeId(), title: "My Timeline" },
|
||||
groups: {
|
||||
[g1]: { id: g1, title: "Work", sortOrder: 0 },
|
||||
[g2]: { id: g2, title: "Personal", sortOrder: 1 },
|
||||
[g3]: { id: g3, title: "Learning", sortOrder: 2 },
|
||||
},
|
||||
items,
|
||||
groupOrder: [g1, g2, g3],
|
||||
selectedItemId: null,
|
||||
darkMode: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildManyItems() {
|
||||
const groupCount = 5;
|
||||
const groupIds = Array.from({ length: groupCount }, makeId);
|
||||
const groupNames = ["Engineering", "Design", "Marketing", "Operations", "Research"];
|
||||
const groups = {};
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
groups[groupIds[i]] = { id: groupIds[i], title: groupNames[i], sortOrder: i };
|
||||
}
|
||||
|
||||
const items = {};
|
||||
const baseDate = new Date(2026, 0, 1);
|
||||
let itemIndex = 0;
|
||||
for (const gId of groupIds) {
|
||||
for (let lane = 1; lane <= 3; lane++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const id = makeId();
|
||||
const startOffset = j * 45 + lane * 5 + Math.floor(Math.random() * 10);
|
||||
const duration = 14 + Math.floor(Math.random() * 30);
|
||||
const start = new Date(baseDate);
|
||||
start.setDate(start.getDate() + startOffset);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + duration);
|
||||
const isPoint = Math.random() < 0.15;
|
||||
items[id] = {
|
||||
id, groupId: gId,
|
||||
title: `Task ${++itemIndex}`,
|
||||
description: null,
|
||||
start: start.toISOString(),
|
||||
end: isPoint ? null : end.toISOString(),
|
||||
lane,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timeline: { id: makeId(), title: "Large Timeline" },
|
||||
groups, items,
|
||||
groupOrder: groupIds,
|
||||
selectedItemId: null,
|
||||
darkMode: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmpty() {
|
||||
const g1 = makeId();
|
||||
return {
|
||||
timeline: { id: makeId(), title: "Empty Timeline" },
|
||||
groups: { [g1]: { id: g1, title: "Untitled Group", sortOrder: 0 } },
|
||||
items: {},
|
||||
groupOrder: [g1],
|
||||
selectedItemId: null,
|
||||
darkMode: true,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bridge state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let currentState = buildFewItems();
|
||||
let updateStateCallback = null;
|
||||
|
||||
window.__zendegi__ = {
|
||||
getState: () => JSON.stringify(currentState),
|
||||
onEvent: (json) => {
|
||||
const event = JSON.parse(json);
|
||||
logEvent(event);
|
||||
handleEvent(event);
|
||||
},
|
||||
set updateState(cb) { updateStateCallback = cb; },
|
||||
get updateState() { return updateStateCallback; },
|
||||
};
|
||||
|
||||
function pushState(state) {
|
||||
currentState = state;
|
||||
if (updateStateCallback) {
|
||||
updateStateCallback(JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function handleEvent(event) {
|
||||
switch (event.type) {
|
||||
case "content_height": {
|
||||
const container = document.getElementById("flutter-container");
|
||||
if (container) container.style.height = event.payload.height + "px";
|
||||
break;
|
||||
}
|
||||
case "entry_moved": {
|
||||
const { entryId, newStart, newEnd, newGroupId, newLane } = event.payload;
|
||||
const item = currentState.items[entryId];
|
||||
if (item) {
|
||||
currentState.items[entryId] = {
|
||||
...item,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
groupId: newGroupId,
|
||||
lane: newLane,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "entry_resized": {
|
||||
const { entryId, newStart, newEnd, groupId, lane } = event.payload;
|
||||
const item = currentState.items[entryId];
|
||||
if (item) {
|
||||
currentState.items[entryId] = {
|
||||
...item,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
groupId: groupId,
|
||||
lane: lane,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "item_selected":
|
||||
currentState.selectedItemId = event.payload.itemId;
|
||||
break;
|
||||
case "item_deselected":
|
||||
currentState.selectedItemId = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event log
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function logEvent(event) {
|
||||
const log = document.getElementById("event-log");
|
||||
const entry = document.createElement("div");
|
||||
entry.className = "entry";
|
||||
const now = new Date().toLocaleTimeString("en-GB", { hour12: false });
|
||||
const payloadStr = event.payload ? " " + JSON.stringify(event.payload) : "";
|
||||
entry.innerHTML =
|
||||
`<span class="time">${now}</span>` +
|
||||
`<span class="type">${event.type}</span>` +
|
||||
`<span class="payload">${payloadStr}</span>`;
|
||||
log.appendChild(entry);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Toolbar actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
document.getElementById("btn-dark").addEventListener("click", () => {
|
||||
currentState.darkMode = true;
|
||||
document.body.style.background = "#1a1a2e";
|
||||
pushState(currentState);
|
||||
});
|
||||
|
||||
document.getElementById("btn-light").addEventListener("click", () => {
|
||||
currentState.darkMode = false;
|
||||
document.body.style.background = "#f0f0f0";
|
||||
document.body.style.color = "#333";
|
||||
pushState(currentState);
|
||||
});
|
||||
|
||||
document.getElementById("btn-few").addEventListener("click", () => {
|
||||
pushState(buildFewItems());
|
||||
});
|
||||
|
||||
document.getElementById("btn-many").addEventListener("click", () => {
|
||||
pushState(buildManyItems());
|
||||
});
|
||||
|
||||
document.getElementById("btn-empty").addEventListener("click", () => {
|
||||
pushState(buildEmpty());
|
||||
});
|
||||
|
||||
document.getElementById("btn-push").addEventListener("click", () => {
|
||||
pushState(currentState);
|
||||
});
|
||||
|
||||
document.getElementById("btn-clear-log").addEventListener("click", () => {
|
||||
document.getElementById("event-log").innerHTML = "";
|
||||
});
|
||||
</script>
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user