diff --git a/apps/web/.gitignore b/apps/web/.gitignore index f1cb16d..7b5d7e5 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -58,3 +58,6 @@ dev-dist .dev.vars* .open-next + +# Flutter build output +/public/flutter/ diff --git a/apps/web/package.json b/apps/web/package.json index 239bba6..9cb99f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@zendegi/auth": "workspace:*", "@zendegi/db": "workspace:*", "@zendegi/env": "workspace:*", + "@zendegi/z-timeline": "workspace:*", "better-auth": "catalog:", "clsx": "^2.1.1", "dotenv": "catalog:", diff --git a/apps/web/src/components/flutter-view.tsx b/apps/web/src/components/flutter-view.tsx new file mode 100644 index 0000000..0aa4341 --- /dev/null +++ b/apps/web/src/components/flutter-view.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from "react"; + +declare global { + interface Window { + __zendegi__?: { + getState: () => string; + onEvent: (json: string) => void; + updateState?: (json: string) => void; + }; + _flutter?: { + buildConfig?: unknown; + loader: { + load: (config?: { + config?: { + entrypointBaseUrl?: string; + assetBase?: string; + }; + onEntrypointLoaded?: (engineInitializer: { + initializeEngine: (config: { + hostElement: HTMLElement; + assetBase?: string; + }) => Promise<{ runApp: () => void }>; + }) => Promise; + }) => Promise; + }; + }; + } +} + +type FlutterViewProps = { + state: Record; + onEvent: (event: { type: string; payload?: Record }) => void; + className?: string; +}; + +export function FlutterView({ + state, + onEvent, + className, +}: FlutterViewProps) { + const containerRef = useRef(null); + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading" + ); + const stateRef = useRef(state); + stateRef.current = state; + + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let pollingInterval: ReturnType | undefined; + let pollingTimeout: ReturnType | undefined; + + window.__zendegi__ = { + getState: () => JSON.stringify(stateRef.current), + onEvent: (json: string) => { + const event = JSON.parse(json) as { + type: string; + payload?: Record; + }; + onEventRef.current(event); + }, + }; + + const init = async () => { + // Load flutter.js if not already loaded + if (!window._flutter) { + await new Promise((resolve, reject) => { + if (document.querySelector('script[src="/flutter/flutter.js"]')) { + // Script tag exists but hasn't finished — wait for _flutter to appear + pollingTimeout = setTimeout(() => { + clearInterval(pollingInterval); + reject( + new Error("Timed out waiting for flutter.js to initialize") + ); + }, 10_000); + pollingInterval = setInterval(() => { + if (window._flutter) { + clearInterval(pollingInterval); + clearTimeout(pollingTimeout); + resolve(); + } + }, 50); + return; + } + const script = document.createElement("script"); + script.src = "/flutter/flutter.js"; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load flutter.js")); + document.head.appendChild(script); + }); + } + + // Fetch and set buildConfig so load() works (supports wasm) + if (!window._flutter!.buildConfig) { + const res = await fetch("/flutter/build_config.json"); + if (!res.ok) { + throw new Error(`Failed to fetch build config: ${res.status}`); + } + window._flutter!.buildConfig = await res.json(); + } + + try { + await window._flutter!.loader.load({ + config: { + entrypointBaseUrl: "/flutter/", + assetBase: "/flutter/", + }, + onEntrypointLoaded: async (engineInitializer) => { + const appRunner = await engineInitializer.initializeEngine({ + hostElement: container, + assetBase: "/flutter/", + }); + appRunner.runApp(); + setStatus("ready"); + }, + }); + } catch (error) { + throw new Error( + `Flutter engine initialization failed: ${error instanceof Error ? error.message : error}` + ); + } + }; + + init().catch(() => setStatus("error")); + + return () => { + clearInterval(pollingInterval); + clearTimeout(pollingTimeout); + container.replaceChildren(); + delete (window as Partial).__zendegi__; + }; + }, []); + + useEffect(() => { + window.__zendegi__?.updateState?.(JSON.stringify(state)); + }, [state]); + + return ( +
+ {status === "loading" && ( +
+ Loading Flutter... +
+ )} + {status === "error" && ( +
+ Failed to load Flutter view +
+ )} +
+
+ ); +} diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index 2c8c917..59c42df 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -1,8 +1,10 @@ +import { useCallback, useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { useSuspenseQuery } from "@tanstack/react-query"; import { timelineQueryOptions } from "@/functions/get-timeline"; import GroupFormDrawer from "@/components/group-form-drawer"; import ItemFormDrawer from "@/components/item-form-drawer"; +import { FlutterView } from "@/components/flutter-view"; export const Route = createFileRoute("/timeline/$timelineId")({ loader: async ({ context, params }) => { @@ -17,48 +19,54 @@ function RouteComponent() { const { timelineId } = Route.useParams(); const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId)); const timeline = timelineQuery.data; + const [selectedItemId, setSelectedItemId] = useState(null); + + const flutterState = useMemo( + () => ({ + timeline: { + id: timeline.id, + title: timeline.title, + groups: timeline.groups.map((group) => ({ + id: group.id, + title: group.title, + sortOrder: group.sortOrder, + items: group.items.map((item) => ({ + id: item.id, + title: item.title, + description: item.description, + start: item.start, + end: item.end, + })), + })), + }, + selectedItemId, + }), + [timeline, selectedItemId] + ); + + const handleEvent = useCallback( + (event: { type: string; payload?: Record }) => { + switch (event.type) { + case "item_selected": + setSelectedItemId((event.payload?.itemId as string) ?? null); + break; + case "item_deselected": + setSelectedItemId(null); + break; + } + }, + [] + ); return ( -
-

{timeline.title}

+
+

{timeline.title}

- {timeline.groups.length === 0 && ( -

- No groups yet. Add a group to start building your timeline. -

- )} - -
- {timeline.groups.map((group) => ( -
-
-

{group.title}

- -
- - {group.items.length === 0 && ( -

No items in this group yet.

- )} - -
    - {group.items.map((item) => ( -
  • -
    {item.title}
    - {item.description && ( -

    {item.description}

    - )} -

    - {new Date(item.start).toLocaleDateString()} - {item.end && ` — ${new Date(item.end).toLocaleDateString()}`} -

    -
  • - ))} -
-
- ))} -
- - +
); } diff --git a/packages/z-timeline/lib/bridge.dart b/packages/z-timeline/lib/bridge.dart new file mode 100644 index 0000000..b5da557 --- /dev/null +++ b/packages/z-timeline/lib/bridge.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; +import 'dart:js_interop'; + +@JS('window.__zendegi__') +external _ZendegiBridge? get _bridge; + +extension type _ZendegiBridge._(JSObject _) implements JSObject { + external JSString getState(); + external void onEvent(JSString json); + external set updateState(JSFunction callback); +} + +Map? readInitialState() { + final bridge = _bridge; + if (bridge == null) return null; + final json = bridge.getState().toDart; + return jsonDecode(json) as Map; +} + +void onStateUpdated(void Function(Map state) callback) { + final bridge = _bridge; + if (bridge == null) return; + bridge.updateState = ((JSString json) { + final decoded = jsonDecode(json.toDart) as Map; + callback(decoded); + }).toJS; +} + +void emitEvent(String type, [Map? payload]) { + final bridge = _bridge; + if (bridge == null) return; + final event = {'type': type}; + if (payload != null) { + event['payload'] = payload; + } + bridge.onEvent(jsonEncode(event).toJS); +} diff --git a/packages/z-timeline/lib/main.dart b/packages/z-timeline/lib/main.dart index 640824f..7f6aa90 100644 --- a/packages/z-timeline/lib/main.dart +++ b/packages/z-timeline/lib/main.dart @@ -1,16 +1,230 @@ import 'package:flutter/material.dart'; +import 'bridge.dart'; +import 'state.dart'; +import 'timeline.dart'; + void main() { runApp(const MainApp()); } -class MainApp extends StatelessWidget { +class MainApp extends StatefulWidget { const MainApp({super.key}); + @override + State createState() => _MainAppState(); +} + +class _MainAppState extends State { + TimelineState? _state; + List _groups = const []; + List _entries = const []; + TimelineViewportNotifier? _viewport; + + @override + void initState() { + super.initState(); + _initBridge(); + } + + @override + void dispose() { + _viewport?.dispose(); + super.dispose(); + } + + void _initBridge() { + final initial = readInitialState(); + if (initial != null) { + _applyState(TimelineState.fromJson(initial)); + } + + onStateUpdated((json) { + _applyState(TimelineState.fromJson(json)); + }); + } + + void _applyState(TimelineState state) { + final groups = _convertGroups(state.timeline.groups); + final entries = _convertEntries(state.timeline.groups); + final domain = _computeDomain(entries); + + setState(() { + _state = state; + _groups = groups; + _entries = entries; + + _viewport ??= TimelineViewportNotifier( + start: domain.start, + end: domain.end, + ); + }); + } + + List _convertGroups(List groups) { + return [ + for (final g in groups) TimelineGroup(id: g.id, title: g.title), + ]; + } + + List _convertEntries(List groups) { + final entries = []; + for (final group in groups) { + // Collect all items for this group to compute lanes + final groupItems = group.items; + final sorted = [...groupItems]..sort( + (a, b) => a.start.compareTo(b.start), + ); + + for (final item in sorted) { + final start = DateTime.parse(item.start); + final end = item.end != null + ? DateTime.parse(item.end!) + : start.add(const Duration(days: 1)); + + final lane = _assignLane(entries, group.id, start, end); + + entries.add( + TimelineEntry( + id: item.id, + groupId: group.id, + start: start, + end: end, + lane: lane, + ), + ); + } + } + return entries; + } + + int _assignLane( + List existing, + String groupId, + DateTime start, + DateTime end, + ) { + final groupEntries = existing.where((e) => e.groupId == groupId); + for (var lane = 1; lane <= 100; lane++) { + final hasConflict = groupEntries.any( + (e) => e.lane == lane && e.overlaps(start, end), + ); + if (!hasConflict) return lane; + } + return 1; + } + + ({DateTime start, DateTime end}) _computeDomain(List entries) { + if (entries.isEmpty) { + final now = DateTime.now(); + return (start: now.subtract(const Duration(days: 30)), end: now.add(const Duration(days: 30))); + } + + var earliest = entries.first.start; + var latest = entries.first.end; + for (final e in entries) { + if (e.start.isBefore(earliest)) earliest = e.start; + if (e.end.isAfter(latest)) latest = e.end; + } + + // Add 10% padding on each side + final span = latest.difference(earliest); + final padding = Duration(milliseconds: (span.inMilliseconds * 0.1).round()); + return ( + start: earliest.subtract(padding), + end: latest.add(padding), + ); + } + + void _onEntryMoved( + TimelineEntry entry, + DateTime newStart, + String newGroupId, + int newLane, + ) { + // Emit event to React via bridge + emitEvent('entry_moved', { + 'entryId': entry.id, + 'newStart': newStart.toIso8601String(), + 'newGroupId': newGroupId, + 'newLane': newLane, + }); + + // Update local state so Flutter UI reflects the move immediately + setState(() { + final duration = entry.end.difference(entry.start); + final newEnd = newStart.add(duration); + + _entries = [ + for (final e in _entries) + if (e.id == entry.id) + e.copyWith( + groupId: newGroupId, + start: newStart, + end: newEnd, + lane: newLane, + ) + else + e, + ]; + }); + } + + String _labelForEntry(TimelineEntry entry) { + final state = _state; + if (state == null) return entry.id; + + for (final group in state.timeline.groups) { + for (final item in group.items) { + if (item.id == entry.id) return item.title; + } + } + return entry.id; + } + + static const _groupColors = [ + Color(0xFF4285F4), // blue + Color(0xFF34A853), // green + Color(0xFFFBBC04), // yellow + Color(0xFFEA4335), // red + Color(0xFF9C27B0), // purple + Color(0xFF00BCD4), // cyan + Color(0xFFFF9800), // orange + Color(0xFF795548), // brown + ]; + + Color _colorForEntry(TimelineEntry entry) { + final groupIndex = _groups.indexWhere((g) => g.id == entry.groupId); + if (groupIndex < 0) return _groupColors[0]; + return _groupColors[groupIndex % _groupColors.length]; + } + @override Widget build(BuildContext context) { - return const MaterialApp( - home: Scaffold(body: Center(child: Text('Hello World!'))), + final viewport = _viewport; + + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: Scaffold( + backgroundColor: Colors.transparent, + body: _state == null || viewport == null + ? const Center(child: Text('Waiting for state...')) + : ZTimelineScope( + viewport: viewport, + child: ZTimelineInteractor( + child: ZTimelineView( + groups: _groups, + entries: _entries, + viewport: viewport, + labelBuilder: _labelForEntry, + colorBuilder: _colorForEntry, + enableDrag: true, + onEntryMoved: _onEntryMoved, + ), + ), + ), + ), ); } } diff --git a/packages/z-timeline/lib/src/constants.dart b/packages/z-timeline/lib/src/constants.dart new file mode 100644 index 0000000..1c0fcfb --- /dev/null +++ b/packages/z-timeline/lib/src/constants.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +/// Shared layout constants for the z_timeline package UI. +class ZTimelineConstants { + const ZTimelineConstants._(); + + // Heights + static const double laneHeight = 28.0; + static const double groupHeaderHeight = 34.0; + + // Spacing + static const double laneVerticalSpacing = 8.0; + static const double verticalOuterPadding = 16.0; + + // Event pill appearance + static const double pillBorderRadius = 4.0; + static const EdgeInsets pillPadding = EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 6.0, + ); + + // Content width + static const double minContentWidth = 1200.0; +} diff --git a/packages/z-timeline/lib/src/models/entry_drag_state.dart b/packages/z-timeline/lib/src/models/entry_drag_state.dart new file mode 100644 index 0000000..69d4907 --- /dev/null +++ b/packages/z-timeline/lib/src/models/entry_drag_state.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; + +import 'timeline_entry.dart'; + +/// Immutable state tracking during drag operations. +/// +/// Captures the entry being dragged and its target position. +@immutable +class EntryDragState { + const EntryDragState({ + required this.entryId, + required this.originalEntry, + required this.targetGroupId, + required this.targetLane, + required this.targetStart, + }); + + /// The ID of the entry being dragged. + final String entryId; + + /// The original entry (for duration calculation). + final TimelineEntry originalEntry; + + /// The target group ID. + final String targetGroupId; + + /// The target lane (resolved to avoid conflicts). + final int targetLane; + + /// The target start time. + final DateTime targetStart; + + /// Calculate target end preserving the original duration. + DateTime get targetEnd => + targetStart.add(originalEntry.end.difference(originalEntry.start)); + + /// The duration of the entry. + Duration get duration => originalEntry.end.difference(originalEntry.start); + + EntryDragState copyWith({ + String? entryId, + TimelineEntry? originalEntry, + String? targetGroupId, + int? targetLane, + DateTime? targetStart, + }) { + return EntryDragState( + entryId: entryId ?? this.entryId, + originalEntry: originalEntry ?? this.originalEntry, + targetGroupId: targetGroupId ?? this.targetGroupId, + targetLane: targetLane ?? this.targetLane, + targetStart: targetStart ?? this.targetStart, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is EntryDragState && + other.entryId == entryId && + other.originalEntry == originalEntry && + other.targetGroupId == targetGroupId && + other.targetLane == targetLane && + other.targetStart == targetStart; + } + + @override + int get hashCode => Object.hash( + entryId, + originalEntry, + targetGroupId, + targetLane, + targetStart, + ); + + @override + String toString() => + 'EntryDragState(' + 'entryId: $entryId, ' + 'targetGroupId: $targetGroupId, ' + 'targetLane: $targetLane, ' + 'targetStart: $targetStart)'; +} diff --git a/packages/z-timeline/lib/src/models/interaction_config.dart b/packages/z-timeline/lib/src/models/interaction_config.dart new file mode 100644 index 0000000..45f31f2 --- /dev/null +++ b/packages/z-timeline/lib/src/models/interaction_config.dart @@ -0,0 +1,102 @@ +import 'package:flutter/foundation.dart'; + +/// Configuration for timeline pan/zoom interactions. +@immutable +class ZTimelineInteractionConfig { + const ZTimelineInteractionConfig({ + this.zoomFactorIn = 1.1, + this.zoomFactorOut = 0.9, + this.keyboardPanRatio = 0.1, + this.enablePinchZoom = true, + this.enableMouseWheelZoom = true, + this.enablePan = true, + this.enableKeyboardShortcuts = true, + this.minZoomDuration = const Duration(hours: 1), + this.maxZoomDuration = const Duration(days: 3650), + }); + + /// Zoom factor applied when zooming in (> 1.0 zooms in, reduces duration). + final double zoomFactorIn; + + /// Zoom factor applied when zooming out (< 1.0 zooms out, increases duration). + final double zoomFactorOut; + + /// Pan ratio for keyboard arrow keys (fraction of visible domain). + final double keyboardPanRatio; + + /// Enable two-finger pinch-to-zoom gesture. + final bool enablePinchZoom; + + /// Enable Ctrl/Cmd + mouse wheel zoom. + final bool enableMouseWheelZoom; + + /// Enable single-finger/mouse drag panning. + final bool enablePan; + + /// Enable keyboard shortcuts (arrows for pan, +/- for zoom). + final bool enableKeyboardShortcuts; + + /// Minimum domain duration (prevents zooming in too far). + final Duration minZoomDuration; + + /// Maximum domain duration (prevents zooming out too far). + final Duration maxZoomDuration; + + /// Default configuration. + static const defaults = ZTimelineInteractionConfig(); + + ZTimelineInteractionConfig copyWith({ + double? zoomFactorIn, + double? zoomFactorOut, + double? keyboardPanRatio, + bool? enablePinchZoom, + bool? enableMouseWheelZoom, + bool? enablePan, + bool? enableKeyboardShortcuts, + Duration? minZoomDuration, + Duration? maxZoomDuration, + }) { + return ZTimelineInteractionConfig( + zoomFactorIn: zoomFactorIn ?? this.zoomFactorIn, + zoomFactorOut: zoomFactorOut ?? this.zoomFactorOut, + keyboardPanRatio: keyboardPanRatio ?? this.keyboardPanRatio, + enablePinchZoom: enablePinchZoom ?? this.enablePinchZoom, + enableMouseWheelZoom: enableMouseWheelZoom ?? this.enableMouseWheelZoom, + enablePan: enablePan ?? this.enablePan, + enableKeyboardShortcuts: + enableKeyboardShortcuts ?? this.enableKeyboardShortcuts, + minZoomDuration: minZoomDuration ?? this.minZoomDuration, + maxZoomDuration: maxZoomDuration ?? this.maxZoomDuration, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ZTimelineInteractionConfig && + other.zoomFactorIn == zoomFactorIn && + other.zoomFactorOut == zoomFactorOut && + other.keyboardPanRatio == keyboardPanRatio && + other.enablePinchZoom == enablePinchZoom && + other.enableMouseWheelZoom == enableMouseWheelZoom && + other.enablePan == enablePan && + other.enableKeyboardShortcuts == enableKeyboardShortcuts && + other.minZoomDuration == minZoomDuration && + other.maxZoomDuration == maxZoomDuration; + } + + @override + int get hashCode { + return Object.hash( + zoomFactorIn, + zoomFactorOut, + keyboardPanRatio, + enablePinchZoom, + enableMouseWheelZoom, + enablePan, + enableKeyboardShortcuts, + minZoomDuration, + maxZoomDuration, + ); + } +} diff --git a/packages/z-timeline/lib/src/models/interaction_state.dart b/packages/z-timeline/lib/src/models/interaction_state.dart new file mode 100644 index 0000000..0842e40 --- /dev/null +++ b/packages/z-timeline/lib/src/models/interaction_state.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; + +/// Transient state for timeline interactions. +/// +/// This is separate from viewport state as it represents UI feedback state, +/// not the actual domain/data state. +@immutable +class ZTimelineInteractionState { + const ZTimelineInteractionState({ + this.isGrabbing = false, + this.isDraggingEntry = 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; + + ZTimelineInteractionState copyWith({ + bool? isGrabbing, + bool? isDraggingEntry, + }) { + return ZTimelineInteractionState( + isGrabbing: isGrabbing ?? this.isGrabbing, + isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ZTimelineInteractionState && + other.isGrabbing == isGrabbing && + other.isDraggingEntry == isDraggingEntry; + } + + @override + int get hashCode => Object.hash(isGrabbing, isDraggingEntry); +} diff --git a/packages/z-timeline/lib/src/models/projected_entry.dart b/packages/z-timeline/lib/src/models/projected_entry.dart new file mode 100644 index 0000000..68c2929 --- /dev/null +++ b/packages/z-timeline/lib/src/models/projected_entry.dart @@ -0,0 +1,37 @@ +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)'; +} diff --git a/packages/z-timeline/lib/src/models/timeline_entry.dart b/packages/z-timeline/lib/src/models/timeline_entry.dart new file mode 100644 index 0000000..d25e573 --- /dev/null +++ b/packages/z-timeline/lib/src/models/timeline_entry.dart @@ -0,0 +1,55 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class TimelineEntry { + TimelineEntry({ + required this.id, + required this.groupId, + required this.start, + required this.end, + required this.lane, + }) : assert(!end.isBefore(start), 'Entry end must be on/after start'); + + final String id; + final String groupId; + final DateTime start; + final DateTime end; + final int lane; // provided by consumer for stacking + + bool overlaps(DateTime a, DateTime b) { + return !(end.isBefore(a) || start.isAfter(b)); + } + + TimelineEntry copyWith({ + String? id, + String? groupId, + DateTime? start, + DateTime? end, + int? lane, + }) { + return TimelineEntry( + id: id ?? this.id, + groupId: groupId ?? this.groupId, + start: start ?? this.start, + end: end ?? this.end, + lane: lane ?? this.lane, + ); + } + + @override + int get hashCode => Object.hash(id, groupId, start, end, lane); + + @override + bool operator ==(Object other) { + return other is TimelineEntry && + other.id == id && + other.groupId == groupId && + other.start == start && + other.end == end && + other.lane == lane; + } + + @override + String toString() => + 'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane)'; +} diff --git a/packages/z-timeline/lib/src/models/timeline_group.dart b/packages/z-timeline/lib/src/models/timeline_group.dart new file mode 100644 index 0000000..1a39743 --- /dev/null +++ b/packages/z-timeline/lib/src/models/timeline_group.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class TimelineGroup { + const TimelineGroup({required this.id, required this.title}); + + final String id; + final String title; + + TimelineGroup copyWith({String? id, String? title}) { + return TimelineGroup(id: id ?? this.id, title: title ?? this.title); + } + + @override + int get hashCode => Object.hash(id, title); + + @override + bool operator ==(Object other) { + return other is TimelineGroup && other.id == id && other.title == title; + } + + @override + String toString() => 'TimelineGroup(id: $id, title: $title)'; +} diff --git a/packages/z-timeline/lib/src/services/entry_placement_service.dart b/packages/z-timeline/lib/src/services/entry_placement_service.dart new file mode 100644 index 0000000..d18ef69 --- /dev/null +++ b/packages/z-timeline/lib/src/services/entry_placement_service.dart @@ -0,0 +1,168 @@ +import 'package:flutter/foundation.dart'; + +import '../models/timeline_entry.dart'; + +/// Result of resolving placement for an entry. +@immutable +class ResolvedPlacement { + const ResolvedPlacement({required this.lane, required this.end}); + + /// The resolved lane (collision-free). + final int lane; + + /// The calculated end time (preserving duration). + final DateTime end; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ResolvedPlacement && other.lane == lane && other.end == end; + } + + @override + int get hashCode => Object.hash(lane, end); + + @override + String toString() => 'ResolvedPlacement(lane: $lane, end: $end)'; +} + +/// Pure utility for entry placement validation and resolution. +/// +/// Handles collision detection and lane assignment for drag-and-drop +/// operations. +class EntryPlacementService { + const EntryPlacementService._(); + + /// Check if a position is available for an entry. + /// + /// Returns true if no other entries occupy the same lane and time range. + /// The entry being dragged (identified by [entryId]) is excluded from + /// collision detection. + static bool isPositionAvailable({ + required String entryId, + required String targetGroupId, + required int targetLane, + required DateTime targetStart, + required DateTime targetEnd, + required List existingEntries, + }) { + // Filter entries in target group and lane, excluding dragged entry + final conflicting = existingEntries.where( + (e) => + e.id != entryId && e.groupId == targetGroupId && e.lane == targetLane, + ); + + // Check for time overlaps + for (final existing in conflicting) { + if (_entriesOverlap( + targetStart, + targetEnd, + existing.start, + existing.end, + )) { + return false; + } + } + return true; + } + + /// Find the nearest available lane from target. + /// + /// Returns the target lane if available, otherwise searches in expanding + /// radius: target, target+1, target-1, target+2, target-2, etc. + static int findNearestAvailableLane({ + required String entryId, + required String targetGroupId, + required int targetLane, + required DateTime targetStart, + required DateTime targetEnd, + required List existingEntries, + }) { + // Check target lane first + if (isPositionAvailable( + entryId: entryId, + targetGroupId: targetGroupId, + targetLane: targetLane, + targetStart: targetStart, + targetEnd: targetEnd, + existingEntries: existingEntries, + )) { + return targetLane; + } + + // Search in expanding radius: +1, -1, +2, -2, ... + var offset = 1; + while (offset <= 100) { + // Try lane above + final upperLane = targetLane + offset; + if (isPositionAvailable( + entryId: entryId, + targetGroupId: targetGroupId, + targetLane: upperLane, + targetStart: targetStart, + targetEnd: targetEnd, + existingEntries: existingEntries, + )) { + return upperLane; + } + + // Try lane below (if valid) + final lowerLane = targetLane - offset; + if (lowerLane >= 1 && + isPositionAvailable( + entryId: entryId, + targetGroupId: targetGroupId, + targetLane: lowerLane, + targetStart: targetStart, + targetEnd: targetEnd, + existingEntries: existingEntries, + )) { + return lowerLane; + } + + offset++; + } + + // Fallback: new lane above all existing + final maxLane = existingEntries + .where((e) => e.groupId == targetGroupId) + .fold(0, (max, e) => e.lane > max ? e.lane : max); + return maxLane + 1; + } + + /// Resolve final placement with collision avoidance. + /// + /// Returns a [ResolvedPlacement] with the calculated lane (avoiding + /// collisions) and end time (preserving the original entry's duration). + static ResolvedPlacement resolvePlacement({ + required TimelineEntry entry, + required String targetGroupId, + required int targetLane, + required DateTime targetStart, + required List existingEntries, + }) { + final duration = entry.end.difference(entry.start); + final targetEnd = targetStart.add(duration); + + final lane = findNearestAvailableLane( + entryId: entry.id, + targetGroupId: targetGroupId, + targetLane: targetLane, + targetStart: targetStart, + targetEnd: targetEnd, + existingEntries: existingEntries, + ); + + return ResolvedPlacement(lane: lane, end: targetEnd); + } + + /// Check if two time ranges overlap. + static bool _entriesOverlap( + DateTime s1, + DateTime e1, + DateTime s2, + DateTime e2, + ) { + return s1.isBefore(e2) && e1.isAfter(s2); + } +} diff --git a/packages/z-timeline/lib/src/services/layout_coordinate_service.dart b/packages/z-timeline/lib/src/services/layout_coordinate_service.dart new file mode 100644 index 0000000..e54f4a0 --- /dev/null +++ b/packages/z-timeline/lib/src/services/layout_coordinate_service.dart @@ -0,0 +1,96 @@ +import '../constants.dart'; + +/// Handles ALL coordinate transformations for timeline layout. +/// +/// This service centralizes position calculations to ensure consistency +/// between different components (pills, ghost overlays, drop targets). +/// +/// ## Coordinate Spaces +/// +/// The timeline uses two coordinate spaces: +/// +/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain. +/// Used by [TimeScaleService] and stored in [ProjectedEntry]. +/// +/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline. +/// What gets passed to [Positioned] widgets. +/// +/// All positioned elements (pills, ghost overlay, drop targets) share the +/// same coordinate space inside the Padding widget. +class LayoutCoordinateService { + const LayoutCoordinateService._(); + + // ───────────────────────────────────────────────────────────────────────── + // Width Calculations + // ───────────────────────────────────────────────────────────────────────── + + /// Calculate item width (pill or ghost) from normalized width. + /// + /// This is the CANONICAL width calculation. Both pills and ghosts use this + /// to ensure they have identical widths. + /// + /// The horizontal padding is subtracted to create visual spacing between + /// adjacent items. + static double calculateItemWidth({ + required double normalizedWidth, + required double contentWidth, + }) { + return (normalizedWidth * contentWidth) + .clamp(0.0, double.infinity); + } + + // ───────────────────────────────────────────────────────────────────────── + // Horizontal Position Transformations + // ───────────────────────────────────────────────────────────────────────── + + /// Convert normalized X position to widget X coordinate. + /// + /// Adds horizontal padding to create left margin for items. + /// Used by both pills and ghost overlay for left positioning. + static double normalizedToWidgetX({ + required double normalizedX, + required double contentWidth, + }) { + return normalizedX * contentWidth; + } + + /// Convert widget X coordinate to normalized position. + /// + /// Used by drop targets to convert cursor position back to normalized + /// space for time calculation. + static double widgetXToNormalized({ + required double widgetX, + required double contentWidth, + }) { + final adjustedX = + (widgetX).clamp(0.0, contentWidth); + return contentWidth == 0 ? 0.0 : adjustedX / contentWidth; + } + + // ───────────────────────────────────────────────────────────────────────── + // Vertical Position Transformations + // ───────────────────────────────────────────────────────────────────────── + + /// Calculate Y position for a lane. + /// + /// 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, + }) { + return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing); + } + + /// Convert Y coordinate to lane number. + /// + /// 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, + }) { + final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing; + return (y / laneStep).floor() + 1; + } +} diff --git a/packages/z-timeline/lib/src/services/time_scale_service.dart b/packages/z-timeline/lib/src/services/time_scale_service.dart new file mode 100644 index 0000000..0fb0e04 --- /dev/null +++ b/packages/z-timeline/lib/src/services/time_scale_service.dart @@ -0,0 +1,78 @@ +class TimeScaleService { + const TimeScaleService._(); + + /// Maps a time to a position in the domain. + /// The result is a value between 0.0 and 1.0 and it is the relative position + /// of the time in the domain. + static double mapTimeToPosition( + DateTime time, + DateTime domainStart, + DateTime domainEnd, + ) { + final timeMs = time.millisecondsSinceEpoch.toDouble(); + final startMs = domainStart.millisecondsSinceEpoch.toDouble(); + final endMs = domainEnd.millisecondsSinceEpoch.toDouble(); + return (timeMs - startMs) / (endMs - startMs); + } + + /// Maps a position in the domain to a time. + /// The result is a DateTime value and it is the time corresponding to the + /// position in the domain. + static DateTime mapPositionToTime( + double position, + DateTime domainStart, + DateTime domainEnd, + ) { + final startMs = domainStart.millisecondsSinceEpoch.toDouble(); + final endMs = domainEnd.millisecondsSinceEpoch.toDouble(); + final timeMs = startMs + position * (endMs - startMs); + return DateTime.fromMillisecondsSinceEpoch(timeMs.round()); + } + + /// Calculates the duration of the domain in milliseconds. + static double domainDuration(DateTime domainStart, DateTime domainEnd) { + return (domainEnd.millisecondsSinceEpoch - + domainStart.millisecondsSinceEpoch) + .toDouble(); + } + + /// Calculates a new domain after applying a zoom operation. + /// The result is a record with the new (start, end) domain. + static ({DateTime start, DateTime end}) calculateZoomedDomain( + DateTime domainStart, + DateTime domainEnd, { + required double factor, + double focusPosition = 0.5, + }) { + final oldDuration = domainDuration(domainStart, domainEnd); + final newDuration = oldDuration / factor; + final focusTime = mapPositionToTime(focusPosition, domainStart, domainEnd); + final newStartOffset = focusPosition * newDuration; + final newStart = DateTime.fromMillisecondsSinceEpoch( + (focusTime.millisecondsSinceEpoch - newStartOffset).round(), + ); + final newEnd = DateTime.fromMillisecondsSinceEpoch( + (focusTime.millisecondsSinceEpoch + (newDuration - newStartOffset)) + .round(), + ); + return (start: newStart, end: newEnd); + } + + /// Calculates a new domain after applying a pan operation. + /// The result is a record with the new (start, end) domain. + static ({DateTime start, DateTime end}) calculatePannedDomain( + DateTime domainStart, + DateTime domainEnd, { + required double ratio, + }) { + final shiftAmount = domainDuration(domainStart, domainEnd) * ratio; + final shiftMs = shiftAmount.round(); + final newStart = DateTime.fromMillisecondsSinceEpoch( + domainStart.millisecondsSinceEpoch + shiftMs, + ); + final newEnd = DateTime.fromMillisecondsSinceEpoch( + domainEnd.millisecondsSinceEpoch + shiftMs, + ); + return (start: newStart, end: newEnd); + } +} diff --git a/packages/z-timeline/lib/src/services/time_tick_builder.dart b/packages/z-timeline/lib/src/services/time_tick_builder.dart new file mode 100644 index 0000000..59da42f --- /dev/null +++ b/packages/z-timeline/lib/src/services/time_tick_builder.dart @@ -0,0 +1,124 @@ +class TimeInterval { + const TimeInterval(this.unit, this.step, this.approx); + final TimeUnit unit; + final int step; + final Duration approx; + + DateTime floor(DateTime d) { + switch (unit) { + case TimeUnit.second: + return DateTime.utc( + d.year, + d.month, + d.day, + d.hour, + d.minute, + (d.second ~/ step) * step, + ); + case TimeUnit.minute: + return DateTime.utc( + d.year, + d.month, + d.day, + d.hour, + (d.minute ~/ step) * step, + ); + case TimeUnit.hour: + return DateTime.utc(d.year, d.month, d.day, (d.hour ~/ step) * step); + case TimeUnit.day: + return DateTime.utc(d.year, d.month, (d.day - 1) ~/ step * step + 1); + case TimeUnit.week: + final monday = d.subtract(Duration(days: d.weekday - 1)); + return DateTime.utc( + monday.year, + monday.month, + monday.day, + ).subtract(Duration(days: 0 % step)); + case TimeUnit.month: + return DateTime.utc(d.year, ((d.month - 1) ~/ step) * step + 1); + case TimeUnit.year: + return DateTime.utc((d.year ~/ step) * step); + } + } + + DateTime addTo(DateTime d) { + switch (unit) { + case TimeUnit.second: + return d.add(Duration(seconds: step)); + case TimeUnit.minute: + return d.add(Duration(minutes: step)); + case TimeUnit.hour: + return d.add(Duration(hours: step)); + case TimeUnit.day: + return d.add(Duration(days: step)); + case TimeUnit.week: + return d.add(Duration(days: 7 * step)); + case TimeUnit.month: + return DateTime.utc(d.year, d.month + step, d.day); + case TimeUnit.year: + return DateTime.utc(d.year + step, d.month, d.day); + } + } +} + +enum TimeUnit { second, minute, hour, day, week, month, year } + +class TickConfig { + const TickConfig({ + this.minPixelsPerTick = 60, + this.candidates = _defaultIntervals, + }); + final double minPixelsPerTick; + final List candidates; +} + +List dateTicks({ + required DateTime start, + required DateTime end, + required double widthPx, + TickConfig config = const TickConfig(), +}) { + assert(!end.isBefore(start), 'End date must not be before start date'); + final spanMs = end.difference(start).inMilliseconds; + final msPerPixel = spanMs / widthPx; + + var chosen = config.candidates.last; + for (final i in config.candidates) { + final pixels = i.approx.inMilliseconds / msPerPixel; + if (pixels >= config.minPixelsPerTick) { + chosen = i; + break; + } + } + + final ticks = []; + var t = chosen.floor(start); + while (!t.isAfter(end)) { + ticks.add(t); + t = chosen.addTo(t); + } + return ticks; +} + +const _defaultIntervals = [ + TimeInterval(TimeUnit.second, 1, Duration(seconds: 1)), + TimeInterval(TimeUnit.second, 5, Duration(seconds: 5)), + TimeInterval(TimeUnit.second, 15, Duration(seconds: 15)), + TimeInterval(TimeUnit.second, 30, Duration(seconds: 30)), + TimeInterval(TimeUnit.minute, 1, Duration(minutes: 1)), + TimeInterval(TimeUnit.minute, 5, Duration(minutes: 5)), + TimeInterval(TimeUnit.minute, 15, Duration(minutes: 15)), + TimeInterval(TimeUnit.minute, 30, Duration(minutes: 30)), + TimeInterval(TimeUnit.hour, 1, Duration(hours: 1)), + TimeInterval(TimeUnit.hour, 3, Duration(hours: 3)), + TimeInterval(TimeUnit.hour, 6, Duration(hours: 6)), + TimeInterval(TimeUnit.hour, 12, Duration(hours: 12)), + TimeInterval(TimeUnit.day, 1, Duration(days: 1)), + TimeInterval(TimeUnit.day, 7, Duration(days: 7)), + TimeInterval(TimeUnit.month, 1, Duration(days: 30)), + TimeInterval(TimeUnit.month, 3, Duration(days: 90)), + TimeInterval(TimeUnit.month, 6, Duration(days: 180)), + TimeInterval(TimeUnit.year, 1, Duration(days: 365)), + TimeInterval(TimeUnit.year, 5, Duration(days: 365 * 5)), + TimeInterval(TimeUnit.year, 10, Duration(days: 365 * 10)), +]; diff --git a/packages/z-timeline/lib/src/services/timeline_projection_service.dart b/packages/z-timeline/lib/src/services/timeline_projection_service.dart new file mode 100644 index 0000000..8e84d22 --- /dev/null +++ b/packages/z-timeline/lib/src/services/timeline_projection_service.dart @@ -0,0 +1,42 @@ +import '../models/timeline_entry.dart'; +import '../models/projected_entry.dart'; +import 'time_scale_service.dart'; + +class TimelineProjectionService { + const TimelineProjectionService(); + + Map> project({ + required Iterable entries, + required DateTime domainStart, + required DateTime domainEnd, + }) { + final byGroup = >{}; + 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] ??= []).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; + } +} diff --git a/packages/z-timeline/lib/src/state/timeline_interaction_notifier.dart b/packages/z-timeline/lib/src/state/timeline_interaction_notifier.dart new file mode 100644 index 0000000..df29b73 --- /dev/null +++ b/packages/z-timeline/lib/src/state/timeline_interaction_notifier.dart @@ -0,0 +1,92 @@ +import 'package:flutter/foundation.dart'; + +import '../models/entry_drag_state.dart'; +import '../models/interaction_state.dart'; +import '../models/timeline_entry.dart'; + +/// Notifier for transient interaction state. +/// +/// Separate from viewport to handle UI feedback state like cursor changes +/// and drag-drop coordination. +class ZTimelineInteractionNotifier extends ChangeNotifier { + ZTimelineInteractionNotifier(); + + ZTimelineInteractionState _state = const ZTimelineInteractionState(); + EntryDragState? _dragState; + + /// The current interaction state. + ZTimelineInteractionState get state => _state; + + /// The current drag state, or null if no drag is active. + EntryDragState? get dragState => _dragState; + + /// 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; + + /// Update the grabbing state. + void setGrabbing(bool value) { + if (_state.isGrabbing == value) return; + _state = _state.copyWith(isGrabbing: value); + notifyListeners(); + } + + /// Update the dragging entry state. + void setDraggingEntry(bool value) { + if (_state.isDraggingEntry == value) return; + _state = _state.copyWith(isDraggingEntry: value); + notifyListeners(); + } + + /// Begin dragging an entry. + /// + /// Sets drag state and marks [isDraggingEntry] as true. + void beginDrag(TimelineEntry entry) { + _dragState = EntryDragState( + entryId: entry.id, + originalEntry: entry, + targetGroupId: entry.groupId, + targetLane: entry.lane, + targetStart: entry.start, + ); + setDraggingEntry(true); + } + + /// Update the drag target position. + /// + /// Called during drag to update where the entry would land. + void updateDragTarget({ + required String targetGroupId, + required int targetLane, + required DateTime targetStart, + }) { + if (_dragState == null) return; + final newState = _dragState!.copyWith( + targetGroupId: targetGroupId, + targetLane: targetLane, + targetStart: targetStart, + ); + if (newState == _dragState) return; + _dragState = newState; + notifyListeners(); + } + + /// End the drag and clear state. + void endDrag() { + _dragState = null; + setDraggingEntry(false); + } + + /// 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); + + /// Called by drag-drop system when an entry drag ends. + @Deprecated('Use endDrag instead') + void endEntryDrag() => setDraggingEntry(false); +} diff --git a/packages/z-timeline/lib/src/state/timeline_viewport_notifier.dart b/packages/z-timeline/lib/src/state/timeline_viewport_notifier.dart new file mode 100644 index 0000000..b810ad7 --- /dev/null +++ b/packages/z-timeline/lib/src/state/timeline_viewport_notifier.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import '../services/time_scale_service.dart'; + +class TimelineViewportNotifier extends ChangeNotifier { + TimelineViewportNotifier({required DateTime start, required DateTime end}) + : assert(start.isBefore(end), 'Viewport start must be before end'), + _start = start, + _end = end; + + DateTime _start; + DateTime _end; + + DateTime get start => _start; + DateTime get end => _end; + + void setDomain(DateTime start, DateTime end) { + if (!start.isBefore(end)) return; + if (start == _start && end == _end) return; + _start = start; + _end = end; + notifyListeners(); + } + + void zoom(double factor, {double focusPosition = 0.5}) { + final next = TimeScaleService.calculateZoomedDomain( + _start, + _end, + factor: factor, + focusPosition: focusPosition, + ); + if (!next.start.isBefore(next.end)) return; + _start = next.start; + _end = next.end; + notifyListeners(); + } + + void pan(double ratio) { + final next = TimeScaleService.calculatePannedDomain( + _start, + _end, + ratio: ratio, + ); + if (!next.start.isBefore(next.end)) return; + _start = next.start; + _end = next.end; + notifyListeners(); + } +} diff --git a/packages/z-timeline/lib/src/widgets/draggable_event_pill.dart b/packages/z-timeline/lib/src/widgets/draggable_event_pill.dart new file mode 100644 index 0000000..6fe3ad0 --- /dev/null +++ b/packages/z-timeline/lib/src/widgets/draggable_event_pill.dart @@ -0,0 +1,122 @@ +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, + ); + + if (!enableDrag) { + return Positioned( + top: top, + left: left.clamp(0.0, double.infinity), + width: width.clamp(0.0, double.infinity), + height: laneHeight, + child: pill, + ); + } + + final scope = ZTimelineScope.of(context); + + return Positioned( + top: top, + left: left.clamp(0.0, double.infinity), + width: width.clamp(0.0, double.infinity), + height: laneHeight, + child: Draggable( + 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: pill, + ), + ); + } + + 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, + ), + ), + ); + } +} diff --git a/packages/z-timeline/lib/src/widgets/ghost_overlay.dart b/packages/z-timeline/lib/src/widgets/ghost_overlay.dart new file mode 100644 index 0000000..fec3ba1 --- /dev/null +++ b/packages/z-timeline/lib/src/widgets/ghost_overlay.dart @@ -0,0 +1,77 @@ +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'; + +/// A semi-transparent ghost overlay showing where an entry will land. +/// +/// Displayed during drag operations to give visual feedback about the +/// target position. +class GhostOverlay extends StatelessWidget { + const GhostOverlay({ + required this.dragState, + required this.viewport, + required this.contentWidth, + required this.laneHeight, + super.key, + }); + + final EntryDragState dragState; + final TimelineViewportNotifier viewport; + final double contentWidth; + final double laneHeight; + + @override + Widget build(BuildContext context) { + final startX = TimeScaleService.mapTimeToPosition( + dragState.targetStart, + viewport.start, + viewport.end, + ); + final endX = TimeScaleService.mapTimeToPosition( + dragState.targetEnd, + viewport.start, + viewport.end, + ); + + // Use centralized coordinate service to ensure ghost matches pill layout + final left = LayoutCoordinateService.normalizedToWidgetX( + normalizedX: startX, + contentWidth: contentWidth, + ); + final width = LayoutCoordinateService.calculateItemWidth( + normalizedWidth: endX - startX, + contentWidth: contentWidth, + ); + final top = LayoutCoordinateService.laneToY( + lane: dragState.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, + ), + ), + ), + ), + ); + } +} diff --git a/packages/z-timeline/lib/src/widgets/group_drop_target.dart b/packages/z-timeline/lib/src/widgets/group_drop_target.dart new file mode 100644 index 0000000..c7275e8 --- /dev/null +++ b/packages/z-timeline/lib/src/widgets/group_drop_target.dart @@ -0,0 +1,119 @@ +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, + super.key, + }); + + final TimelineGroup group; + final List entries; + final List allEntries; + final TimelineViewportNotifier viewport; + final double contentWidth; + final double laneHeight; + final int lanesCount; + final OnEntryMoved? onEntryMoved; + final Widget child; + + @override + Widget build(BuildContext context) { + final scope = ZTimelineScope.of(context); + + return DragTarget( + 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, + ); + + final rawLane = LayoutCoordinateService.yToLane( + y: local.dy, + 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 + }, + ); + } +} diff --git a/packages/z-timeline/lib/src/widgets/timeline_interactor.dart b/packages/z-timeline/lib/src/widgets/timeline_interactor.dart new file mode 100644 index 0000000..e2de6e6 --- /dev/null +++ b/packages/z-timeline/lib/src/widgets/timeline_interactor.dart @@ -0,0 +1,242 @@ +import 'package:flutter/gestures.dart' + show PointerScrollEvent, PointerSignalEvent; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'timeline_scope.dart'; + +/// Handles pan/zoom gestures for the timeline. +/// +/// Must be used within a [ZTimelineScope]. Supports: +/// - Two-finger pinch-to-zoom (with focal point) +/// - Single-finger horizontal pan +/// - Ctrl/Cmd + mouse wheel zoom +/// - Horizontal mouse scroll for pan +/// - Keyboard shortcuts: arrows (pan), +/- (zoom) +/// - Mouse cursor feedback (grab/grabbing) +/// +/// ```dart +/// ZTimelineScope( +/// viewport: viewport, +/// child: ZTimelineInteractor( +/// child: ZTimelineView(...), +/// ), +/// ) +/// ``` +class ZTimelineInteractor extends StatefulWidget { + const ZTimelineInteractor({ + required this.child, + this.autofocus = true, + super.key, + }); + + /// The widget to wrap with gesture handling. + final Widget child; + + /// Whether to automatically focus this widget for keyboard input. + final bool autofocus; + + @override + State createState() => _ZTimelineInteractorState(); +} + +class _ZTimelineInteractorState extends State { + double _prevScaleValue = 1.0; + Offset? _lastFocalPoint; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + double get _width { + final renderBox = context.findRenderObject() as RenderBox?; + return renderBox?.size.width ?? 1.0; + } + + void _handleScaleStart(ScaleStartDetails details) { + _prevScaleValue = 1.0; + _lastFocalPoint = details.focalPoint; + + final scope = ZTimelineScope.of(context); + scope.interaction.setGrabbing(true); + } + + void _handleScaleUpdate(ScaleUpdateDetails details) { + final scope = ZTimelineScope.of(context); + final config = scope.config; + final width = _width; + + // Two-finger pinch-to-zoom + if (details.pointerCount >= 2 && config.enablePinchZoom) { + if (details.scale != _prevScaleValue) { + final scaleFactor = details.scale / _prevScaleValue; + _prevScaleValue = details.scale; + + final focalPosition = (details.focalPoint.dx / width).clamp(0.0, 1.0); + _performZoom(scaleFactor, focusPosition: focalPosition); + } + } + // Single-finger pan + else if (details.pointerCount == 1 && + config.enablePan && + !scope.interaction.isDraggingEntry) { + if (_lastFocalPoint != null) { + final diff = details.focalPoint - _lastFocalPoint!; + final ratio = -diff.dx / width; + + if (ratio != 0) { + scope.viewport.pan(ratio); + } + } + } + + _lastFocalPoint = details.focalPoint; + } + + void _handleScaleEnd(ScaleEndDetails details) { + _lastFocalPoint = null; + + final scope = ZTimelineScope.of(context); + scope.interaction.setGrabbing(false); + } + + void _handlePointerSignal(PointerSignalEvent event) { + if (event is! PointerScrollEvent) return; + + final scope = ZTimelineScope.of(context); + final config = scope.config; + final width = _width; + + final pressed = HardwareKeyboard.instance.logicalKeysPressed; + final isCtrlOrMeta = + pressed.contains(LogicalKeyboardKey.controlLeft) || + pressed.contains(LogicalKeyboardKey.controlRight) || + pressed.contains(LogicalKeyboardKey.metaLeft) || + pressed.contains(LogicalKeyboardKey.metaRight); + + // Ctrl/Cmd + scroll = zoom + if (isCtrlOrMeta && config.enableMouseWheelZoom) { + final renderBox = context.findRenderObject() as RenderBox?; + final local = renderBox?.globalToLocal(event.position) ?? Offset.zero; + final focusPosition = (local.dx / width).clamp(0.0, 1.0); + final factor = event.scrollDelta.dy < 0 + ? config.zoomFactorIn + : config.zoomFactorOut; + _performZoom(factor, focusPosition: focusPosition); + return; + } + + // Horizontal scroll = pan + if (config.enablePan) { + final dx = event.scrollDelta.dx; + if (dx == 0.0) return; + + final ratio = dx / width; + if (ratio != 0) { + scope.viewport.pan(ratio); + } + } + } + + void _performZoom(double factor, {double focusPosition = 0.5}) { + final scope = ZTimelineScope.of(context); + final config = scope.config; + final viewport = scope.viewport; + + // Check limits before zooming + final currentDuration = viewport.end.difference(viewport.start); + final newDurationMs = (currentDuration.inMilliseconds / factor).round(); + final newDuration = Duration(milliseconds: newDurationMs); + + // Prevent zooming in too far + if (factor > 1 && newDuration < config.minZoomDuration) return; + // Prevent zooming out too far + if (factor < 1 && newDuration > config.maxZoomDuration) return; + + viewport.zoom(factor, focusPosition: focusPosition); + } + + Map _buildKeyboardBindings( + ZTimelineScopeData scope, + ) { + final config = scope.config; + + if (!config.enableKeyboardShortcuts) { + return const {}; + } + + return { + // Pan left + const SingleActivator(LogicalKeyboardKey.arrowLeft): () { + if (config.enablePan) { + scope.viewport.pan(-config.keyboardPanRatio); + } + }, + // Pan right + const SingleActivator(LogicalKeyboardKey.arrowRight): () { + if (config.enablePan) { + scope.viewport.pan(config.keyboardPanRatio); + } + }, + // Zoom in (equals key, typically + without shift) + const SingleActivator(LogicalKeyboardKey.equal): () { + _performZoom(config.zoomFactorIn); + }, + // Zoom out + const SingleActivator(LogicalKeyboardKey.minus): () { + _performZoom(config.zoomFactorOut); + }, + // Zoom in (numpad +) + const SingleActivator(LogicalKeyboardKey.numpadAdd): () { + _performZoom(config.zoomFactorIn); + }, + // Zoom out (numpad -) + const SingleActivator(LogicalKeyboardKey.numpadSubtract): () { + _performZoom(config.zoomFactorOut); + }, + }; + } + + @override + Widget build(BuildContext context) { + final scope = ZTimelineScope.of(context); + + return CallbackShortcuts( + bindings: _buildKeyboardBindings(scope), + child: Focus( + autofocus: widget.autofocus, + focusNode: _focusNode, + child: Listener( + onPointerSignal: _handlePointerSignal, + child: ListenableBuilder( + listenable: scope.interaction, + builder: (context, child) { + return MouseRegion( + cursor: scope.interaction.isGrabbing + ? SystemMouseCursors.grabbing + : SystemMouseCursors.grab, + child: child, + ); + }, + child: GestureDetector( + onScaleStart: _handleScaleStart, + onScaleUpdate: _handleScaleUpdate, + onScaleEnd: _handleScaleEnd, + behavior: HitTestBehavior.deferToChild, + child: widget.child, + ), + ), + ), + ), + ); + } +} diff --git a/packages/z-timeline/lib/src/widgets/timeline_scope.dart b/packages/z-timeline/lib/src/widgets/timeline_scope.dart new file mode 100644 index 0000000..1512973 --- /dev/null +++ b/packages/z-timeline/lib/src/widgets/timeline_scope.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import '../models/interaction_config.dart'; +import '../state/timeline_interaction_notifier.dart'; +import '../state/timeline_viewport_notifier.dart'; + +/// Provides timeline viewport and interaction state to descendants. +/// +/// Similar to Flutter's Theme or MediaQuery pattern. Wrap your timeline +/// widgets with this scope to enable interactions. +/// +/// ```dart +/// ZTimelineScope( +/// viewport: myViewportNotifier, +/// config: const ZTimelineInteractionConfig( +/// minZoomDuration: Duration(days: 1), +/// ), +/// child: ZTimelineInteractor( +/// child: ZTimelineView(...), +/// ), +/// ) +/// ``` +class ZTimelineScope extends StatefulWidget { + const ZTimelineScope({ + required this.viewport, + required this.child, + this.config = ZTimelineInteractionConfig.defaults, + super.key, + }); + + /// The viewport notifier managing the visible time domain. + final TimelineViewportNotifier viewport; + + /// Configuration for interaction behavior. + final ZTimelineInteractionConfig config; + + /// The widget subtree that can access this scope. + final Widget child; + + /// Get the nearest [ZTimelineScopeData] or throw an assertion error. + static ZTimelineScopeData of(BuildContext context) { + final data = maybeOf(context); + assert( + data != null, + 'No ZTimelineScope found in context. ' + 'Wrap your widget tree with ZTimelineScope.', + ); + return data!; + } + + /// Get the nearest [ZTimelineScopeData] or null if not found. + static ZTimelineScopeData? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_ZTimelineScopeInherited>() + ?.data; + } + + @override + State createState() => _ZTimelineScopeState(); +} + +class _ZTimelineScopeState extends State { + late final ZTimelineInteractionNotifier _interactionNotifier; + + @override + void initState() { + super.initState(); + _interactionNotifier = ZTimelineInteractionNotifier(); + } + + @override + void dispose() { + _interactionNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ZTimelineScopeInherited( + data: ZTimelineScopeData( + viewport: widget.viewport, + interaction: _interactionNotifier, + config: widget.config, + ), + child: widget.child, + ); + } +} + +class _ZTimelineScopeInherited extends InheritedWidget { + const _ZTimelineScopeInherited({required this.data, required super.child}); + + final ZTimelineScopeData data; + + @override + bool updateShouldNotify(_ZTimelineScopeInherited oldWidget) { + return data != oldWidget.data; + } +} + +/// Data provided by [ZTimelineScope]. +@immutable +class ZTimelineScopeData { + const ZTimelineScopeData({ + required this.viewport, + required this.interaction, + required this.config, + }); + + /// The viewport notifier for domain state (start/end times). + final TimelineViewportNotifier viewport; + + /// The interaction notifier for transient UI state (grabbing, dragging). + final ZTimelineInteractionNotifier interaction; + + /// Configuration for interaction behavior. + final ZTimelineInteractionConfig config; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ZTimelineScopeData && + other.viewport == viewport && + other.interaction == interaction && + other.config == config; + } + + @override + int get hashCode => Object.hash(viewport, interaction, config); +} diff --git a/packages/z-timeline/lib/src/widgets/timeline_view.dart b/packages/z-timeline/lib/src/widgets/timeline_view.dart new file mode 100644 index 0000000..a808ee4 --- /dev/null +++ b/packages/z-timeline/lib/src/widgets/timeline_view.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; + +import '../constants.dart'; +import '../models/entry_drag_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 'timeline_scope.dart'; + +typedef EntryLabelBuilder = String Function(TimelineEntry entry); +typedef EntryColorBuilder = Color Function(TimelineEntry entry); + +/// Callback signature for when an entry is moved via drag-and-drop. +typedef OnEntryMoved = + void Function( + TimelineEntry entry, + DateTime newStart, + String newGroupId, + int newLane, + ); + +/// Base timeline view: renders groups with between-group headers and +/// lane rows containing event pills. +class ZTimelineView extends StatelessWidget { + const ZTimelineView({ + super.key, + required this.groups, + required this.entries, + required this.viewport, + required this.labelBuilder, + required this.colorBuilder, + this.laneHeight = ZTimelineConstants.laneHeight, + this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight, + this.onEntryMoved, + this.enableDrag = true, + }); + + final List groups; + final List entries; + final TimelineViewportNotifier viewport; + final EntryLabelBuilder labelBuilder; + final EntryColorBuilder colorBuilder; + final double laneHeight; + final double groupHeaderHeight; + + /// Callback invoked when an entry is moved via drag-and-drop. + /// + /// The consumer is responsible for updating their state with the new + /// position. The [newLane] is calculated to avoid collisions. + final OnEntryMoved? onEntryMoved; + + /// Whether drag-and-drop is enabled. + final bool enableDrag; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: viewport, + builder: (context, _) { + final projected = const TimelineProjectionService().project( + entries: entries, + domainStart: viewport.start, + domainEnd: viewport.end, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final contentWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : ZTimelineConstants.minContentWidth; + + return ListView.builder( + itemCount: groups.length, + itemBuilder: (context, index) { + final group = groups[index]; + final groupEntries = + projected[group.id] ?? const []; + final lanesCount = _countLanes(groupEntries); + + 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, + colorBuilder: colorBuilder, + labelBuilder: labelBuilder, + contentWidth: contentWidth, + onEntryMoved: onEntryMoved, + enableDrag: enableDrag, + ), + ], + ); + }, + ); + }, + ); + }, + ); + } + + int _countLanes(List entries) { + var maxLane = 0; + for (final e in entries) { + if (e.entry.lane > maxLane) maxLane = e.entry.lane; + } + return maxLane.clamp(0, 1000); // basic guard + } +} + +class _GroupHeader extends StatelessWidget { + const _GroupHeader({required this.title, required this.height}); + final String title; + final double height; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + height: height, + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant), + ), + ), + child: Text(title, style: Theme.of(context).textTheme.titleSmall), + ); + } +} + +class _GroupLanes extends StatelessWidget { + const _GroupLanes({ + required this.group, + required this.entries, + required this.allEntries, + required this.viewport, + required this.lanesCount, + required this.laneHeight, + required this.labelBuilder, + required this.colorBuilder, + required this.contentWidth, + required this.onEntryMoved, + required this.enableDrag, + }); + + final TimelineGroup group; + final List entries; + final List allEntries; + final TimelineViewportNotifier viewport; + final int lanesCount; + final double laneHeight; + final EntryLabelBuilder labelBuilder; + final EntryColorBuilder colorBuilder; + final double contentWidth; + final OnEntryMoved? onEntryMoved; + final bool enableDrag; + + @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); + } + + // Listen to interaction notifier for drag state changes + return ListenableBuilder( + listenable: scope.interaction, + builder: (context, _) { + final effectiveLanesCount = _calculateEffectiveLanesCount( + actualLanesCount: lanesCount, + dragState: scope.interaction.dragState, + groupId: group.id, + ); + + return _buildContent(context, effectiveLanesCount); + }, + ); + } + + int _calculateEffectiveLanesCount({ + required int actualLanesCount, + required EntryDragState? dragState, + required String groupId, + }) { + // No drag active - use actual lane count + if (dragState == null) { + return actualLanesCount; + } + + // Drag active but over different group - use actual lane count + if (dragState.targetGroupId != groupId) { + return actualLanesCount; + } + + // Drag active over this group - expand to accommodate target lane + return actualLanesCount > dragState.targetLane + ? actualLanesCount + : dragState.targetLane; + } + + Widget _buildContent(BuildContext context, int effectiveLanesCount) { + final totalHeight = + effectiveLanesCount * laneHeight + + (effectiveLanesCount > 0 + ? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing + : 0); + final scope = ZTimelineScope.maybeOf(context); + + // The inner Stack with pills and ghost overlay + Widget innerStack = SizedBox( + height: totalHeight, + width: double.infinity, + child: Stack( + children: [ + // Event pills + for (final e in entries) + DraggableEventPill( + entry: e, + laneHeight: laneHeight, + labelBuilder: labelBuilder, + colorBuilder: colorBuilder, + contentWidth: contentWidth, + enableDrag: enableDrag, + ), + + // Ghost overlay (rendered in same coordinate space as pills) + if (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(); + } + return GhostOverlay( + dragState: dragState, + viewport: viewport, + contentWidth: contentWidth, + laneHeight: laneHeight, + ); + }, + ), + ], + ), + ); + + // Wrap with DragTarget if drag is enabled + if (enableDrag && onEntryMoved != null) { + innerStack = GroupDropTarget( + group: group, + entries: entries, + allEntries: allEntries, + viewport: viewport, + contentWidth: contentWidth, + laneHeight: laneHeight, + lanesCount: lanesCount, + onEntryMoved: onEntryMoved, + child: innerStack, + ); + } + + return AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.symmetric( + vertical: ZTimelineConstants.verticalOuterPadding, + ), + child: innerStack, + ), + ); + } +} diff --git a/packages/z-timeline/lib/state.dart b/packages/z-timeline/lib/state.dart new file mode 100644 index 0000000..f90a059 --- /dev/null +++ b/packages/z-timeline/lib/state.dart @@ -0,0 +1,82 @@ +class TimelineState { + final TimelineData timeline; + final String? selectedItemId; + + TimelineState({required this.timeline, this.selectedItemId}); + + factory TimelineState.fromJson(Map json) { + return TimelineState( + timeline: TimelineData.fromJson(json['timeline'] as Map), + selectedItemId: json['selectedItemId'] as String?, + ); + } +} + +class TimelineData { + final String id; + final String title; + final List groups; + + TimelineData({required this.id, required this.title, required this.groups}); + + factory TimelineData.fromJson(Map json) { + return TimelineData( + id: json['id'] as String, + title: json['title'] as String, + groups: (json['groups'] as List) + .map((g) => TimelineGroupData.fromJson(g as Map)) + .toList(), + ); + } +} + +class TimelineGroupData { + final String id; + final String title; + final int sortOrder; + final List items; + + TimelineGroupData({ + required this.id, + required this.title, + required this.sortOrder, + required this.items, + }); + + factory TimelineGroupData.fromJson(Map json) { + return TimelineGroupData( + id: json['id'] as String, + title: json['title'] as String, + sortOrder: json['sortOrder'] as int, + items: (json['items'] as List) + .map((i) => TimelineItemData.fromJson(i as Map)) + .toList(), + ); + } +} + +class TimelineItemData { + final String id; + final String title; + final String? description; + final String start; + final String? end; + + TimelineItemData({ + required this.id, + required this.title, + this.description, + required this.start, + this.end, + }); + + factory TimelineItemData.fromJson(Map json) { + return TimelineItemData( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String?, + start: json['start'] as String, + end: json['end'] as String?, + ); + } +} diff --git a/packages/z-timeline/lib/timeline.dart b/packages/z-timeline/lib/timeline.dart new file mode 100644 index 0000000..4c2fa69 --- /dev/null +++ b/packages/z-timeline/lib/timeline.dart @@ -0,0 +1,24 @@ +// Models +export 'src/models/timeline_group.dart'; +export 'src/models/timeline_entry.dart'; +export 'src/models/projected_entry.dart'; +export 'src/models/interaction_config.dart'; +export 'src/models/interaction_state.dart'; +export 'src/models/entry_drag_state.dart'; + +// State +export 'src/state/timeline_viewport_notifier.dart'; +export 'src/state/timeline_interaction_notifier.dart'; + +// Services +export 'src/services/time_scale_service.dart'; +export 'src/services/time_tick_builder.dart'; +export 'src/services/timeline_projection_service.dart'; +export 'src/services/entry_placement_service.dart'; +export 'src/services/layout_coordinate_service.dart'; + +// Widgets +export 'src/constants.dart'; +export 'src/widgets/timeline_view.dart'; +export 'src/widgets/timeline_scope.dart'; +export 'src/widgets/timeline_interactor.dart'; diff --git a/packages/z-timeline/package.json b/packages/z-timeline/package.json new file mode 100644 index 0000000..c725cbf --- /dev/null +++ b/packages/z-timeline/package.json @@ -0,0 +1,8 @@ +{ + "name": "@zendegi/z-timeline", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "flutter build web --release --wasm --base-href /flutter/ && node scripts/copy-build.mjs" + } +} diff --git a/packages/z-timeline/scripts/copy-build.mjs b/packages/z-timeline/scripts/copy-build.mjs new file mode 100644 index 0000000..59768ef --- /dev/null +++ b/packages/z-timeline/scripts/copy-build.mjs @@ -0,0 +1,20 @@ +import { cpSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const src = resolve(__dirname, "../build/web"); +const dest = resolve(__dirname, "../../../apps/web/public/flutter"); + +cpSync(src, dest, { recursive: true }); +console.log(`Copied Flutter build: ${src} → ${dest}`); + +// Extract buildConfig from flutter_bootstrap.js so the React app can fetch it +const bootstrap = readFileSync(resolve(dest, "flutter_bootstrap.js"), "utf-8"); +const match = bootstrap.match(/_flutter\.buildConfig\s*=\s*({.*?});/); +if (match) { + writeFileSync(resolve(dest, "build_config.json"), match[1]); + console.log("Extracted build_config.json"); +} else { + console.warn("Could not extract buildConfig from flutter_bootstrap.js"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e85b6f..6aa837c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@zendegi/env': specifier: workspace:* version: link:../../packages/env + '@zendegi/z-timeline': + specifier: workspace:* + version: link:../../packages/z-timeline better-auth: specifier: 'catalog:' version: 1.4.18(@tanstack/react-start@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -310,6 +313,8 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/z-timeline: {} + packages: '@antfu/ni@25.0.0': diff --git a/timeline_poc b/timeline_poc new file mode 160000 index 0000000..cd7679d --- /dev/null +++ b/timeline_poc @@ -0,0 +1 @@ +Subproject commit cd7679dca0803beb3ae7bafd565005f9426f44d9 diff --git a/turbo.json b/turbo.json index d3a6dd9..e5c5577 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,7 @@ "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": ["dist/**"] + "outputs": ["dist/**", "build/web/**"] }, "transit": { "dependsOn": ["^transit"]