add timeline pkg to flutter
This commit is contained in:
3
apps/web/.gitignore
vendored
3
apps/web/.gitignore
vendored
@@ -58,3 +58,6 @@ dev-dist
|
|||||||
.dev.vars*
|
.dev.vars*
|
||||||
|
|
||||||
.open-next
|
.open-next
|
||||||
|
|
||||||
|
# Flutter build output
|
||||||
|
/public/flutter/
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@zendegi/auth": "workspace:*",
|
"@zendegi/auth": "workspace:*",
|
||||||
"@zendegi/db": "workspace:*",
|
"@zendegi/db": "workspace:*",
|
||||||
"@zendegi/env": "workspace:*",
|
"@zendegi/env": "workspace:*",
|
||||||
|
"@zendegi/z-timeline": "workspace:*",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
|||||||
176
apps/web/src/components/flutter-view.tsx
Normal file
176
apps/web/src/components/flutter-view.tsx
Normal file
@@ -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<void>;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlutterViewProps = {
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
onEvent: (event: { type: string; payload?: Record<string, unknown> }) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FlutterView({
|
||||||
|
state,
|
||||||
|
onEvent,
|
||||||
|
className,
|
||||||
|
}: FlutterViewProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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<typeof setInterval> | undefined;
|
||||||
|
let pollingTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
window.__zendegi__ = {
|
||||||
|
getState: () => JSON.stringify(stateRef.current),
|
||||||
|
onEvent: (json: string) => {
|
||||||
|
const event = JSON.parse(json) as {
|
||||||
|
type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
onEventRef.current(event);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
// Load flutter.js if not already loaded
|
||||||
|
if (!window._flutter) {
|
||||||
|
await new Promise<void>((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<Window>).__zendegi__;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.__zendegi__?.updateState?.(JSON.stringify(state));
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ height: "100%", position: "relative" }}>
|
||||||
|
{status === "loading" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading Flutter...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "red",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Failed to load Flutter view
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { timelineQueryOptions } from "@/functions/get-timeline";
|
import { timelineQueryOptions } from "@/functions/get-timeline";
|
||||||
import GroupFormDrawer from "@/components/group-form-drawer";
|
import GroupFormDrawer from "@/components/group-form-drawer";
|
||||||
import ItemFormDrawer from "@/components/item-form-drawer";
|
import ItemFormDrawer from "@/components/item-form-drawer";
|
||||||
|
import { FlutterView } from "@/components/flutter-view";
|
||||||
|
|
||||||
export const Route = createFileRoute("/timeline/$timelineId")({
|
export const Route = createFileRoute("/timeline/$timelineId")({
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ context, params }) => {
|
||||||
@@ -17,48 +19,54 @@ function RouteComponent() {
|
|||||||
const { timelineId } = Route.useParams();
|
const { timelineId } = Route.useParams();
|
||||||
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
|
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
|
||||||
const timeline = timelineQuery.data;
|
const timeline = timelineQuery.data;
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(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<string, unknown> }) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "item_selected":
|
||||||
|
setSelectedItemId((event.payload?.itemId as string) ?? null);
|
||||||
|
break;
|
||||||
|
case "item_deselected":
|
||||||
|
setSelectedItemId(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-3xl px-4 py-6 space-y-6">
|
<div className="flex h-screen flex-col px-4 py-6">
|
||||||
<h1 className="text-3xl font-serif font-bold">{timeline.title}</h1>
|
<h1 className="text-3xl font-serif font-bold mb-6">{timeline.title}</h1>
|
||||||
|
|
||||||
{timeline.groups.length === 0 && (
|
<FlutterView
|
||||||
<p className="text-muted-foreground">
|
state={flutterState}
|
||||||
No groups yet. Add a group to start building your timeline.
|
onEvent={handleEvent}
|
||||||
</p>
|
className="min-h-0 flex-1 rounded-lg overflow-hidden"
|
||||||
)}
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{timeline.groups.map((group) => (
|
|
||||||
<div key={group.id} className="border border-border rounded-lg p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-xl font-semibold">{group.title}</h2>
|
|
||||||
<ItemFormDrawer timelineGroupId={group.id} timelineId={timelineId} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{group.items.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">No items in this group yet.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{group.items.map((item) => (
|
|
||||||
<li key={item.id} className="border border-border rounded p-3">
|
|
||||||
<div className="font-medium">{item.title}</div>
|
|
||||||
{item.description && (
|
|
||||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{new Date(item.start).toLocaleDateString()}
|
|
||||||
{item.end && ` — ${new Date(item.end).toLocaleDateString()}`}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GroupFormDrawer timelineId={timelineId} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
37
packages/z-timeline/lib/bridge.dart
Normal file
37
packages/z-timeline/lib/bridge.dart
Normal file
@@ -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<String, dynamic>? readInitialState() {
|
||||||
|
final bridge = _bridge;
|
||||||
|
if (bridge == null) return null;
|
||||||
|
final json = bridge.getState().toDart;
|
||||||
|
return jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onStateUpdated(void Function(Map<String, dynamic> state) callback) {
|
||||||
|
final bridge = _bridge;
|
||||||
|
if (bridge == null) return;
|
||||||
|
bridge.updateState = ((JSString json) {
|
||||||
|
final decoded = jsonDecode(json.toDart) as Map<String, dynamic>;
|
||||||
|
callback(decoded);
|
||||||
|
}).toJS;
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitEvent(String type, [Map<String, dynamic>? payload]) {
|
||||||
|
final bridge = _bridge;
|
||||||
|
if (bridge == null) return;
|
||||||
|
final event = <String, dynamic>{'type': type};
|
||||||
|
if (payload != null) {
|
||||||
|
event['payload'] = payload;
|
||||||
|
}
|
||||||
|
bridge.onEvent(jsonEncode(event).toJS);
|
||||||
|
}
|
||||||
@@ -1,16 +1,230 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'bridge.dart';
|
||||||
|
import 'state.dart';
|
||||||
|
import 'timeline.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const MainApp());
|
runApp(const MainApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MainApp extends StatelessWidget {
|
class MainApp extends StatefulWidget {
|
||||||
const MainApp({super.key});
|
const MainApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainApp> createState() => _MainAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainAppState extends State<MainApp> {
|
||||||
|
TimelineState? _state;
|
||||||
|
List<TimelineGroup> _groups = const [];
|
||||||
|
List<TimelineEntry> _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<TimelineGroup> _convertGroups(List<TimelineGroupData> groups) {
|
||||||
|
return [
|
||||||
|
for (final g in groups) TimelineGroup(id: g.id, title: g.title),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TimelineEntry> _convertEntries(List<TimelineGroupData> groups) {
|
||||||
|
final entries = <TimelineEntry>[];
|
||||||
|
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<TimelineEntry> 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<TimelineEntry> 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const MaterialApp(
|
final viewport = _viewport;
|
||||||
home: Scaffold(body: Center(child: Text('Hello World!'))),
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
packages/z-timeline/lib/src/constants.dart
Normal file
24
packages/z-timeline/lib/src/constants.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
83
packages/z-timeline/lib/src/models/entry_drag_state.dart
Normal file
83
packages/z-timeline/lib/src/models/entry_drag_state.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
102
packages/z-timeline/lib/src/models/interaction_config.dart
Normal file
102
packages/z-timeline/lib/src/models/interaction_config.dart
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/z-timeline/lib/src/models/interaction_state.dart
Normal file
41
packages/z-timeline/lib/src/models/interaction_state.dart
Normal file
@@ -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);
|
||||||
|
}
|
||||||
37
packages/z-timeline/lib/src/models/projected_entry.dart
Normal file
37
packages/z-timeline/lib/src/models/projected_entry.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
55
packages/z-timeline/lib/src/models/timeline_entry.dart
Normal file
55
packages/z-timeline/lib/src/models/timeline_entry.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
24
packages/z-timeline/lib/src/models/timeline_group.dart
Normal file
24
packages/z-timeline/lib/src/models/timeline_group.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
@@ -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<TimelineEntry> 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<TimelineEntry> 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<int>(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<TimelineEntry> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
packages/z-timeline/lib/src/services/time_scale_service.dart
Normal file
78
packages/z-timeline/lib/src/services/time_scale_service.dart
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
packages/z-timeline/lib/src/services/time_tick_builder.dart
Normal file
124
packages/z-timeline/lib/src/services/time_tick_builder.dart
Normal file
@@ -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<TimeInterval> candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DateTime> 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 = <DateTime>[];
|
||||||
|
var t = chosen.floor(start);
|
||||||
|
while (!t.isAfter(end)) {
|
||||||
|
ticks.add(t);
|
||||||
|
t = chosen.addTo(t);
|
||||||
|
}
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaultIntervals = <TimeInterval>[
|
||||||
|
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)),
|
||||||
|
];
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import '../models/timeline_entry.dart';
|
||||||
|
import '../models/projected_entry.dart';
|
||||||
|
import 'time_scale_service.dart';
|
||||||
|
|
||||||
|
class TimelineProjectionService {
|
||||||
|
const TimelineProjectionService();
|
||||||
|
|
||||||
|
Map<String, List<ProjectedEntry>> project({
|
||||||
|
required Iterable<TimelineEntry> entries,
|
||||||
|
required DateTime domainStart,
|
||||||
|
required DateTime domainEnd,
|
||||||
|
}) {
|
||||||
|
final byGroup = <String, List<ProjectedEntry>>{};
|
||||||
|
for (final e in entries) {
|
||||||
|
if (e.overlaps(domainStart, domainEnd)) {
|
||||||
|
final startX = TimeScaleService.mapTimeToPosition(
|
||||||
|
e.start.isBefore(domainStart) ? domainStart : e.start,
|
||||||
|
domainStart,
|
||||||
|
domainEnd,
|
||||||
|
).clamp(0.0, 1.0);
|
||||||
|
final endX = TimeScaleService.mapTimeToPosition(
|
||||||
|
e.end.isAfter(domainEnd) ? domainEnd : e.end,
|
||||||
|
domainStart,
|
||||||
|
domainEnd,
|
||||||
|
).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
final pe = ProjectedEntry(entry: e, startX: startX, endX: endX);
|
||||||
|
(byGroup[e.groupId] ??= <ProjectedEntry>[]).add(pe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep original order stable by lane then startX
|
||||||
|
for (final list in byGroup.values) {
|
||||||
|
list.sort((a, b) {
|
||||||
|
final laneCmp = a.entry.lane.compareTo(b.entry.lane);
|
||||||
|
if (laneCmp != 0) return laneCmp;
|
||||||
|
return a.startX.compareTo(b.startX);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return byGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
packages/z-timeline/lib/src/widgets/draggable_event_pill.dart
Normal file
122
packages/z-timeline/lib/src/widgets/draggable_event_pill.dart
Normal file
@@ -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<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: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/z-timeline/lib/src/widgets/ghost_overlay.dart
Normal file
77
packages/z-timeline/lib/src/widgets/ghost_overlay.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
packages/z-timeline/lib/src/widgets/group_drop_target.dart
Normal file
119
packages/z-timeline/lib/src/widgets/group_drop_target.dart
Normal file
@@ -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<ProjectedEntry> entries;
|
||||||
|
final List<TimelineEntry> 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<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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
packages/z-timeline/lib/src/widgets/timeline_interactor.dart
Normal file
242
packages/z-timeline/lib/src/widgets/timeline_interactor.dart
Normal file
@@ -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<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||||
|
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<ShortcutActivator, VoidCallback> _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
packages/z-timeline/lib/src/widgets/timeline_scope.dart
Normal file
130
packages/z-timeline/lib/src/widgets/timeline_scope.dart
Normal file
@@ -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<ZTimelineScope> createState() => _ZTimelineScopeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZTimelineScopeState extends State<ZTimelineScope> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
291
packages/z-timeline/lib/src/widgets/timeline_view.dart
Normal file
291
packages/z-timeline/lib/src/widgets/timeline_view.dart
Normal file
@@ -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<TimelineGroup> groups;
|
||||||
|
final List<TimelineEntry> 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 <ProjectedEntry>[];
|
||||||
|
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<ProjectedEntry> 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<ProjectedEntry> entries;
|
||||||
|
final List<TimelineEntry> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
packages/z-timeline/lib/state.dart
Normal file
82
packages/z-timeline/lib/state.dart
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
class TimelineState {
|
||||||
|
final TimelineData timeline;
|
||||||
|
final String? selectedItemId;
|
||||||
|
|
||||||
|
TimelineState({required this.timeline, this.selectedItemId});
|
||||||
|
|
||||||
|
factory TimelineState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TimelineState(
|
||||||
|
timeline: TimelineData.fromJson(json['timeline'] as Map<String, dynamic>),
|
||||||
|
selectedItemId: json['selectedItemId'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimelineData {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final List<TimelineGroupData> groups;
|
||||||
|
|
||||||
|
TimelineData({required this.id, required this.title, required this.groups});
|
||||||
|
|
||||||
|
factory TimelineData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TimelineData(
|
||||||
|
id: json['id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
groups: (json['groups'] as List<dynamic>)
|
||||||
|
.map((g) => TimelineGroupData.fromJson(g as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimelineGroupData {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final int sortOrder;
|
||||||
|
final List<TimelineItemData> items;
|
||||||
|
|
||||||
|
TimelineGroupData({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.sortOrder,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TimelineGroupData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TimelineGroupData(
|
||||||
|
id: json['id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
sortOrder: json['sortOrder'] as int,
|
||||||
|
items: (json['items'] as List<dynamic>)
|
||||||
|
.map((i) => TimelineItemData.fromJson(i as Map<String, dynamic>))
|
||||||
|
.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<String, dynamic> 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/z-timeline/lib/timeline.dart
Normal file
24
packages/z-timeline/lib/timeline.dart
Normal file
@@ -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';
|
||||||
8
packages/z-timeline/package.json
Normal file
8
packages/z-timeline/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/z-timeline/scripts/copy-build.mjs
Normal file
20
packages/z-timeline/scripts/copy-build.mjs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
5
pnpm-lock.yaml
generated
5
pnpm-lock.yaml
generated
@@ -99,6 +99,9 @@ importers:
|
|||||||
'@zendegi/env':
|
'@zendegi/env':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/env
|
version: link:../../packages/env
|
||||||
|
'@zendegi/z-timeline':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/z-timeline
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: 'catalog:'
|
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)
|
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
|
specifier: ^5.0.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/z-timeline: {}
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@antfu/ni@25.0.0':
|
'@antfu/ni@25.0.0':
|
||||||
|
|||||||
1
timeline_poc
Submodule
1
timeline_poc
Submodule
Submodule timeline_poc added at cd7679dca0
@@ -5,7 +5,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
"outputs": ["dist/**"]
|
"outputs": ["dist/**", "build/web/**"]
|
||||||
},
|
},
|
||||||
"transit": {
|
"transit": {
|
||||||
"dependsOn": ["^transit"]
|
"dependsOn": ["^transit"]
|
||||||
|
|||||||
Reference in New Issue
Block a user