Compare commits
10 Commits
1cca200eda
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c872624d2 | |||
| c39dcb6164 | |||
| addd78d057 | |||
| 9d015c2e2c | |||
| 724980fd31 | |||
| dc524cad24 | |||
| 44ffe219b1 | |||
| b2c88dc7cd | |||
| acb2878ed6 | |||
| 765aa83fb6 |
@@ -2,4 +2,4 @@
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
**/.claude/**
|
**/.claude/**
|
||||||
**/public/flutter/**
|
**/public/flutter/**
|
||||||
**/z-timeline/**
|
**/z-flutter/**
|
||||||
|
|||||||
@@ -28,6 +28,112 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module-level singleton state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let flutterContainer: HTMLDivElement | null = null;
|
||||||
|
let engineReady = false;
|
||||||
|
let initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Module-level refs so bridge closures always read latest values
|
||||||
|
let currentStateRef: FlutterTimelineState | null = null;
|
||||||
|
let currentOnEventRef: ((event: FlutterEvent) => void) | null = null;
|
||||||
|
|
||||||
|
function ensureBridge() {
|
||||||
|
if (window.__zendegi__) return;
|
||||||
|
window.__zendegi__ = {
|
||||||
|
getState: () => JSON.stringify(currentStateRef),
|
||||||
|
onEvent: (json: string) => {
|
||||||
|
const event = JSON.parse(json) as FlutterEvent;
|
||||||
|
currentOnEventRef?.(event);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadFlutter() {
|
||||||
|
initFlutter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFlutter(): Promise<void> {
|
||||||
|
if (initPromise) return initPromise;
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
ensureBridge();
|
||||||
|
// Load flutter.js if not already loaded
|
||||||
|
if (!window._flutter) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (document.querySelector('script[src="/flutter/flutter.js"]')) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
reject(
|
||||||
|
new Error("Timed out waiting for flutter.js to initialize"),
|
||||||
|
);
|
||||||
|
}, 10_000);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (window._flutter) {
|
||||||
|
clearInterval(interval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the persistent container once
|
||||||
|
if (!flutterContainer) {
|
||||||
|
flutterContainer = document.createElement("div");
|
||||||
|
flutterContainer.style.width = "100%";
|
||||||
|
flutterContainer.style.height = "100%";
|
||||||
|
}
|
||||||
|
|
||||||
|
await window._flutter!.loader.load({
|
||||||
|
config: {
|
||||||
|
entrypointBaseUrl: "/flutter/",
|
||||||
|
assetBase: "/flutter/",
|
||||||
|
},
|
||||||
|
onEntrypointLoaded: async (engineInitializer) => {
|
||||||
|
const appRunner = await engineInitializer.initializeEngine({
|
||||||
|
hostElement: flutterContainer!,
|
||||||
|
assetBase: "/flutter/",
|
||||||
|
});
|
||||||
|
appRunner.runApp();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
engineReady = true;
|
||||||
|
})().catch((error) => {
|
||||||
|
// Reset all module state so next mount retries
|
||||||
|
flutterContainer = null;
|
||||||
|
engineReady = false;
|
||||||
|
initPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type FlutterViewProps = {
|
type FlutterViewProps = {
|
||||||
state: FlutterTimelineState;
|
state: FlutterTimelineState;
|
||||||
onEvent: (event: FlutterEvent) => void;
|
onEvent: (event: FlutterEvent) => void;
|
||||||
@@ -43,97 +149,43 @@ export function FlutterView({
|
|||||||
}: FlutterViewProps) {
|
}: FlutterViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
||||||
"loading"
|
engineReady ? "ready" : "loading",
|
||||||
);
|
);
|
||||||
const stateRef = useRef(state);
|
|
||||||
stateRef.current = state;
|
|
||||||
|
|
||||||
const onEventRef = useRef(onEvent);
|
// Keep module-level refs in sync
|
||||||
onEventRef.current = onEvent;
|
currentStateRef = state;
|
||||||
|
currentOnEventRef = onEvent;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const wrapper = containerRef.current;
|
||||||
if (!container) return;
|
if (!wrapper) return;
|
||||||
|
|
||||||
let pollingInterval: ReturnType<typeof setInterval> | undefined;
|
if (engineReady && flutterContainer) {
|
||||||
let pollingTimeout: ReturnType<typeof setTimeout> | undefined;
|
// Engine already running — reparent immediately, no loading flash
|
||||||
|
wrapper.appendChild(flutterContainer);
|
||||||
window.__zendegi__ = {
|
setStatus("ready");
|
||||||
getState: () => JSON.stringify(stateRef.current),
|
// Push latest state so Flutter renders the correct timeline
|
||||||
onEvent: (json: string) => {
|
window.__zendegi__?.updateState?.(JSON.stringify(currentStateRef));
|
||||||
const event = JSON.parse(json) as FlutterEvent;
|
} else {
|
||||||
onEventRef.current(event);
|
// First mount — initialize engine
|
||||||
},
|
setStatus("loading");
|
||||||
};
|
initFlutter()
|
||||||
|
.then(() => {
|
||||||
const init = async () => {
|
// Guard: component may have unmounted while awaiting
|
||||||
// Load flutter.js if not already loaded
|
if (!containerRef.current || !flutterContainer) return;
|
||||||
if (!window._flutter) {
|
wrapper.appendChild(flutterContainer);
|
||||||
await new Promise<void>((resolve, reject) => {
|
setStatus("ready");
|
||||||
if (document.querySelector('script[src="/flutter/flutter.js"]')) {
|
// Push current state after engine is ready
|
||||||
// Script tag exists but hasn't finished — wait for _flutter to appear
|
window.__zendegi__?.updateState?.(JSON.stringify(currentStateRef));
|
||||||
pollingTimeout = setTimeout(() => {
|
})
|
||||||
clearInterval(pollingInterval);
|
.catch(() => setStatus("error"));
|
||||||
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 () => {
|
return () => {
|
||||||
clearInterval(pollingInterval);
|
// Detach but don't destroy the Flutter container
|
||||||
clearTimeout(pollingTimeout);
|
if (flutterContainer?.parentElement === wrapper) {
|
||||||
container.replaceChildren();
|
wrapper.removeChild(flutterContainer);
|
||||||
delete (window as Partial<Window>).__zendegi__;
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
|
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
|
||||||
|
import { inputClasses } from "./ui/input";
|
||||||
import {
|
import {
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -12,63 +13,101 @@ import {
|
|||||||
DrawerPortal,
|
DrawerPortal,
|
||||||
DrawerRoot,
|
DrawerRoot,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerTrigger,
|
|
||||||
DrawerViewport,
|
DrawerViewport,
|
||||||
} from "./ui/drawer";
|
} from "./ui/drawer";
|
||||||
|
import type { NormalizedGroup, NormalizedItem } from "@/functions/get-timeline";
|
||||||
|
import { updateTimelineItem } from "@/functions/update-timeline-item";
|
||||||
import { createTimelineItem } from "@/functions/create-timeline-item";
|
import { createTimelineItem } from "@/functions/create-timeline-item";
|
||||||
|
|
||||||
export default function ItemFormDrawer({
|
type ItemFormDrawerProps = {
|
||||||
timelineGroupId,
|
open: boolean;
|
||||||
timelineId,
|
onOpenChange: (open: boolean) => void;
|
||||||
}: {
|
|
||||||
timelineGroupId: string;
|
|
||||||
timelineId: string;
|
timelineId: string;
|
||||||
}) {
|
groups: Record<string, NormalizedGroup | undefined>;
|
||||||
const [open, setOpen] = useState(false);
|
groupOrder: Array<string>;
|
||||||
|
editItem?: NormalizedItem | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ItemFormDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
timelineId,
|
||||||
|
groups,
|
||||||
|
groupOrder,
|
||||||
|
editItem,
|
||||||
|
}: ItemFormDrawerProps) {
|
||||||
|
const isEdit = !!editItem;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const getDefaultValues = useCallback(
|
||||||
|
() => ({
|
||||||
|
title: editItem?.title ?? "",
|
||||||
|
description: editItem?.description ?? "",
|
||||||
|
start: editItem
|
||||||
|
? editItem.start.toISOString().split("T")[0]
|
||||||
|
: new Date().toISOString().split("T")[0],
|
||||||
|
end: editItem?.end ? editItem.end.toISOString().split("T")[0] : "",
|
||||||
|
groupId: (editItem?.groupId ?? groupOrder[0]) || "",
|
||||||
|
}),
|
||||||
|
[editItem, groupOrder]
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: getDefaultValues(),
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
start: new Date().toISOString().split("T")[0],
|
|
||||||
end: "",
|
|
||||||
},
|
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
try {
|
try {
|
||||||
await createTimelineItem({
|
if (isEdit) {
|
||||||
data: {
|
await updateTimelineItem({
|
||||||
title: value.title,
|
data: {
|
||||||
description: value.description,
|
id: editItem.id,
|
||||||
start: value.start,
|
title: value.title,
|
||||||
end: value.end || undefined,
|
description: value.description,
|
||||||
timelineGroupId,
|
start: value.start,
|
||||||
},
|
end: value.end || null,
|
||||||
});
|
timelineGroupId: value.groupId,
|
||||||
|
lane: editItem.lane,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("Item updated");
|
||||||
|
} else {
|
||||||
|
await createTimelineItem({
|
||||||
|
data: {
|
||||||
|
title: value.title,
|
||||||
|
description: value.description,
|
||||||
|
start: value.start,
|
||||||
|
end: value.end || null,
|
||||||
|
timelineGroupId: value.groupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("Item created");
|
||||||
|
}
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["timeline", timelineId],
|
queryKey: ["timeline", timelineId],
|
||||||
});
|
});
|
||||||
setOpen(false);
|
onOpenChange(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
toast.success("Item created");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : "Failed to create item"
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: `Failed to ${isEdit ? "update" : "create"} item`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
form.reset(getDefaultValues());
|
||||||
|
}, [open, getDefaultValues, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DrawerRoot open={open} onOpenChange={setOpen}>
|
<DrawerRoot open={open} onOpenChange={onOpenChange}>
|
||||||
<DrawerTrigger render={<Button variant="outline" size="sm" />}>
|
|
||||||
Add Item
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerPortal>
|
<DrawerPortal>
|
||||||
<DrawerViewport>
|
<DrawerViewport>
|
||||||
<DrawerPopup>
|
<DrawerPopup>
|
||||||
<div className="flex items-center justify-between border-b border-border p-4">
|
<div className="flex items-center justify-between border-b border-border p-4">
|
||||||
<DrawerTitle>New Item</DrawerTitle>
|
<DrawerTitle>{isEdit ? "Edit Item" : "New Item"}</DrawerTitle>
|
||||||
<DrawerClose render={<Button variant="outline" size="sm" />}>
|
<DrawerClose render={<Button variant="outline" size="sm" />}>
|
||||||
Close
|
Close
|
||||||
</DrawerClose>
|
</DrawerClose>
|
||||||
@@ -122,6 +161,26 @@ export default function ItemFormDrawer({
|
|||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="groupId">
|
||||||
|
{(field) => (
|
||||||
|
<FieldRoot>
|
||||||
|
<FieldLabel>Group</FieldLabel>
|
||||||
|
<select
|
||||||
|
className={inputClasses}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{groupOrder.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{groups[id]?.title ?? id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FieldRoot>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
<form.Field name="start">
|
<form.Field name="start">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<FieldRoot>
|
<FieldRoot>
|
||||||
@@ -168,7 +227,13 @@ export default function ItemFormDrawer({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={!state.canSubmit || state.isSubmitting}
|
disabled={!state.canSubmit || state.isSubmitting}
|
||||||
>
|
>
|
||||||
{state.isSubmitting ? "Creating..." : "Create Item"}
|
{state.isSubmitting
|
||||||
|
? isEdit
|
||||||
|
? "Saving..."
|
||||||
|
: "Creating..."
|
||||||
|
: isEdit
|
||||||
|
? "Save Changes"
|
||||||
|
: "Create Item"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ const { viewport, popup, title, description, close, content } = drawerStyles();
|
|||||||
type DrawerRootProps = React.ComponentProps<typeof BaseDrawer.Root>;
|
type DrawerRootProps = React.ComponentProps<typeof BaseDrawer.Root>;
|
||||||
|
|
||||||
function DrawerRoot(props: DrawerRootProps) {
|
function DrawerRoot(props: DrawerRootProps) {
|
||||||
return <BaseDrawer.Root modal={false} swipeDirection="right" {...props} />;
|
return (
|
||||||
|
<BaseDrawer.Root
|
||||||
|
modal={false}
|
||||||
|
swipeDirection="right"
|
||||||
|
disablePointerDismissal
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type DrawerTriggerProps = React.ComponentProps<typeof BaseDrawer.Trigger>;
|
type DrawerTriggerProps = React.ComponentProps<typeof BaseDrawer.Trigger>;
|
||||||
|
|||||||
18
apps/web/src/entry-server.ts
Normal file
18
apps/web/src/entry-server.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
createStartHandler,
|
||||||
|
defaultStreamHandler,
|
||||||
|
} from "@tanstack/react-start/server";
|
||||||
|
|
||||||
|
const handler = createStartHandler(defaultStreamHandler);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const response = await handler(request);
|
||||||
|
response.headers.set(
|
||||||
|
"Cross-Origin-Embedder-Policy",
|
||||||
|
"credentialless",
|
||||||
|
);
|
||||||
|
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,8 +15,8 @@ export const createTimelineItem = createServerFn({ method: "POST" })
|
|||||||
start: z.string().transform((s) => new Date(s)),
|
start: z.string().transform((s) => new Date(s)),
|
||||||
end: z
|
end: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.nullable()
|
||||||
.transform((s) => (s ? new Date(s) : undefined)),
|
.transform((s) => (s ? new Date(s) : null)),
|
||||||
timelineGroupId: z.string().uuid(),
|
timelineGroupId: z.string().uuid(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
|
|||||||
.transform((s) => (s ? new Date(s) : null)),
|
.transform((s) => (s ? new Date(s) : null)),
|
||||||
timelineGroupId: z.string().uuid(),
|
timelineGroupId: z.string().uuid(),
|
||||||
lane: z.number().int().min(1),
|
lane: z.number().int().min(1),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
@@ -27,6 +29,10 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
|
|||||||
end: data.end,
|
end: data.end,
|
||||||
timelineGroupId: data.timelineGroupId,
|
timelineGroupId: data.timelineGroupId,
|
||||||
lane: data.lane,
|
lane: data.lane,
|
||||||
|
...(data.title !== undefined && { title: data.title }),
|
||||||
|
...(data.description !== undefined && {
|
||||||
|
description: data.description,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.where(eq(timelineItem.id, data.id))
|
.where(eq(timelineItem.id, data.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
66
apps/web/src/hooks/use-entry-resized-mutation.ts
Normal file
66
apps/web/src/hooks/use-entry-resized-mutation.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { NormalizedTimeline } from "@/functions/get-timeline";
|
||||||
|
import { updateTimelineItem } from "@/functions/update-timeline-item";
|
||||||
|
|
||||||
|
type EntryResizedVars = {
|
||||||
|
entryId: string;
|
||||||
|
newStart: string;
|
||||||
|
newEnd: string | null;
|
||||||
|
groupId: string;
|
||||||
|
lane: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEntryResizedMutation(timelineId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = ["timeline", timelineId];
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: EntryResizedVars) =>
|
||||||
|
updateTimelineItem({
|
||||||
|
data: {
|
||||||
|
id: vars.entryId,
|
||||||
|
start: vars.newStart,
|
||||||
|
end: vars.newEnd,
|
||||||
|
timelineGroupId: vars.groupId,
|
||||||
|
lane: vars.lane,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
onMutate: async (vars) => {
|
||||||
|
// Cancel in-flight fetches so they don't overwrite our optimistic update
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previous = queryClient.getQueryData<NormalizedTimeline>(queryKey);
|
||||||
|
|
||||||
|
queryClient.setQueryData<NormalizedTimeline>(queryKey, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
items: {
|
||||||
|
...old.items,
|
||||||
|
[vars.entryId]: {
|
||||||
|
...old.items[vars.entryId],
|
||||||
|
start: new Date(vars.newStart),
|
||||||
|
end: vars.newEnd ? new Date(vars.newEnd) : null,
|
||||||
|
lane: vars.lane,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
// Roll back to the previous cache state on server error
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(queryKey, context.previous);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
// Re-fetch from server to ensure consistency
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* lanes on item creation.
|
* lanes on item creation.
|
||||||
*/
|
*/
|
||||||
export function assignLane(
|
export function assignLane(
|
||||||
existingItems: { start: Date; end: Date | null; lane: number }[],
|
existingItems: Array<{ start: Date; end: Date | null; lane: number }>,
|
||||||
newStart: Date,
|
newStart: Date,
|
||||||
newEnd: Date | null
|
newEnd: Date | null
|
||||||
): number {
|
): number {
|
||||||
|
|||||||
@@ -55,4 +55,14 @@ export type FlutterEvent =
|
|||||||
newGroupId: string;
|
newGroupId: string;
|
||||||
newLane: number;
|
newLane: number;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "entry_resized";
|
||||||
|
payload: {
|
||||||
|
entryId: string;
|
||||||
|
newStart: string;
|
||||||
|
newEnd: string | null;
|
||||||
|
groupId: string;
|
||||||
|
lane: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,14 +9,15 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as TimelinesRouteImport } from './routes/timelines'
|
|
||||||
import { Route as TimelineRouteImport } from './routes/timeline'
|
import { Route as TimelineRouteImport } from './routes/timeline'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
|
||||||
import { Route as DemoRouteImport } from './routes/demo'
|
import { Route as DemoRouteImport } from './routes/demo'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
import { Route as ConsentRouteImport } from './routes/consent'
|
import { Route as ConsentRouteImport } from './routes/consent'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as DefaultRouteImport } from './routes/_default'
|
||||||
|
import { Route as DefaultIndexRouteImport } from './routes/_default.index'
|
||||||
import { Route as TimelineTimelineIdRouteImport } from './routes/timeline.$timelineId'
|
import { Route as TimelineTimelineIdRouteImport } from './routes/timeline.$timelineId'
|
||||||
|
import { Route as DefaultTimelinesRouteImport } from './routes/_default.timelines'
|
||||||
|
import { Route as DefaultLoginRouteImport } from './routes/_default.login'
|
||||||
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.well-known]/openid-configuration'
|
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.well-known]/openid-configuration'
|
||||||
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.well-known]/oauth-protected-resource'
|
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.well-known]/oauth-protected-resource'
|
||||||
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.well-known]/oauth-authorization-server'
|
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.well-known]/oauth-authorization-server'
|
||||||
@@ -24,21 +25,11 @@ import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$'
|
|||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.well-known]/oauth-authorization-server.api.auth'
|
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.well-known]/oauth-authorization-server.api.auth'
|
||||||
|
|
||||||
const TimelinesRoute = TimelinesRouteImport.update({
|
|
||||||
id: '/timelines',
|
|
||||||
path: '/timelines',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const TimelineRoute = TimelineRouteImport.update({
|
const TimelineRoute = TimelineRouteImport.update({
|
||||||
id: '/timeline',
|
id: '/timeline',
|
||||||
path: '/timeline',
|
path: '/timeline',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
|
||||||
id: '/login',
|
|
||||||
path: '/login',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const DemoRoute = DemoRouteImport.update({
|
const DemoRoute = DemoRouteImport.update({
|
||||||
id: '/demo',
|
id: '/demo',
|
||||||
path: '/demo',
|
path: '/demo',
|
||||||
@@ -54,16 +45,30 @@ const ConsentRoute = ConsentRouteImport.update({
|
|||||||
path: '/consent',
|
path: '/consent',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const DefaultRoute = DefaultRouteImport.update({
|
||||||
|
id: '/_default',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DefaultIndexRoute = DefaultIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => DefaultRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const TimelineTimelineIdRoute = TimelineTimelineIdRouteImport.update({
|
const TimelineTimelineIdRoute = TimelineTimelineIdRouteImport.update({
|
||||||
id: '/$timelineId',
|
id: '/$timelineId',
|
||||||
path: '/$timelineId',
|
path: '/$timelineId',
|
||||||
getParentRoute: () => TimelineRoute,
|
getParentRoute: () => TimelineRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DefaultTimelinesRoute = DefaultTimelinesRouteImport.update({
|
||||||
|
id: '/timelines',
|
||||||
|
path: '/timelines',
|
||||||
|
getParentRoute: () => DefaultRoute,
|
||||||
|
} as any)
|
||||||
|
const DefaultLoginRoute = DefaultLoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => DefaultRoute,
|
||||||
|
} as any)
|
||||||
const DotwellKnownOpenidConfigurationRoute =
|
const DotwellKnownOpenidConfigurationRoute =
|
||||||
DotwellKnownOpenidConfigurationRouteImport.update({
|
DotwellKnownOpenidConfigurationRouteImport.update({
|
||||||
id: '/.well-known/openid-configuration',
|
id: '/.well-known/openid-configuration',
|
||||||
@@ -100,50 +105,51 @@ const DotwellKnownOauthAuthorizationServerApiAuthRoute =
|
|||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof DefaultIndexRoute
|
||||||
'/consent': typeof ConsentRoute
|
'/consent': typeof ConsentRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
|
||||||
'/timeline': typeof TimelineRouteWithChildren
|
'/timeline': typeof TimelineRouteWithChildren
|
||||||
'/timelines': typeof TimelinesRoute
|
|
||||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||||
|
'/login': typeof DefaultLoginRoute
|
||||||
|
'/timelines': typeof DefaultTimelinesRoute
|
||||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/consent': typeof ConsentRoute
|
'/consent': typeof ConsentRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
|
||||||
'/timeline': typeof TimelineRouteWithChildren
|
'/timeline': typeof TimelineRouteWithChildren
|
||||||
'/timelines': typeof TimelinesRoute
|
|
||||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||||
|
'/login': typeof DefaultLoginRoute
|
||||||
|
'/timelines': typeof DefaultTimelinesRoute
|
||||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||||
|
'/': typeof DefaultIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/_default': typeof DefaultRouteWithChildren
|
||||||
'/consent': typeof ConsentRoute
|
'/consent': typeof ConsentRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
|
||||||
'/timeline': typeof TimelineRouteWithChildren
|
'/timeline': typeof TimelineRouteWithChildren
|
||||||
'/timelines': typeof TimelinesRoute
|
|
||||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||||
|
'/_default/login': typeof DefaultLoginRoute
|
||||||
|
'/_default/timelines': typeof DefaultTimelinesRoute
|
||||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||||
|
'/_default/': typeof DefaultIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||||
@@ -155,58 +161,57 @@ export interface FileRouteTypes {
|
|||||||
| '/consent'
|
| '/consent'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/demo'
|
| '/demo'
|
||||||
| '/login'
|
|
||||||
| '/timeline'
|
| '/timeline'
|
||||||
| '/timelines'
|
|
||||||
| '/.well-known/oauth-authorization-server'
|
| '/.well-known/oauth-authorization-server'
|
||||||
| '/.well-known/oauth-protected-resource'
|
| '/.well-known/oauth-protected-resource'
|
||||||
| '/.well-known/openid-configuration'
|
| '/.well-known/openid-configuration'
|
||||||
|
| '/login'
|
||||||
|
| '/timelines'
|
||||||
| '/timeline/$timelineId'
|
| '/timeline/$timelineId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/mcp/$'
|
| '/api/mcp/$'
|
||||||
| '/.well-known/oauth-authorization-server/api/auth'
|
| '/.well-known/oauth-authorization-server/api/auth'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
|
||||||
| '/consent'
|
| '/consent'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/demo'
|
| '/demo'
|
||||||
| '/login'
|
|
||||||
| '/timeline'
|
| '/timeline'
|
||||||
| '/timelines'
|
|
||||||
| '/.well-known/oauth-authorization-server'
|
| '/.well-known/oauth-authorization-server'
|
||||||
| '/.well-known/oauth-protected-resource'
|
| '/.well-known/oauth-protected-resource'
|
||||||
| '/.well-known/openid-configuration'
|
| '/.well-known/openid-configuration'
|
||||||
|
| '/login'
|
||||||
|
| '/timelines'
|
||||||
| '/timeline/$timelineId'
|
| '/timeline/$timelineId'
|
||||||
|
| '/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/mcp/$'
|
| '/api/mcp/$'
|
||||||
| '/.well-known/oauth-authorization-server/api/auth'
|
| '/.well-known/oauth-authorization-server/api/auth'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/_default'
|
||||||
| '/consent'
|
| '/consent'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/demo'
|
| '/demo'
|
||||||
| '/login'
|
|
||||||
| '/timeline'
|
| '/timeline'
|
||||||
| '/timelines'
|
|
||||||
| '/.well-known/oauth-authorization-server'
|
| '/.well-known/oauth-authorization-server'
|
||||||
| '/.well-known/oauth-protected-resource'
|
| '/.well-known/oauth-protected-resource'
|
||||||
| '/.well-known/openid-configuration'
|
| '/.well-known/openid-configuration'
|
||||||
|
| '/_default/login'
|
||||||
|
| '/_default/timelines'
|
||||||
| '/timeline/$timelineId'
|
| '/timeline/$timelineId'
|
||||||
|
| '/_default/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/mcp/$'
|
| '/api/mcp/$'
|
||||||
| '/.well-known/oauth-authorization-server/api/auth'
|
| '/.well-known/oauth-authorization-server/api/auth'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
DefaultRoute: typeof DefaultRouteWithChildren
|
||||||
ConsentRoute: typeof ConsentRoute
|
ConsentRoute: typeof ConsentRoute
|
||||||
DashboardRoute: typeof DashboardRoute
|
DashboardRoute: typeof DashboardRoute
|
||||||
DemoRoute: typeof DemoRoute
|
DemoRoute: typeof DemoRoute
|
||||||
LoginRoute: typeof LoginRoute
|
|
||||||
TimelineRoute: typeof TimelineRouteWithChildren
|
TimelineRoute: typeof TimelineRouteWithChildren
|
||||||
TimelinesRoute: typeof TimelinesRoute
|
|
||||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRoute
|
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRoute
|
||||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
||||||
@@ -216,13 +221,6 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/timelines': {
|
|
||||||
id: '/timelines'
|
|
||||||
path: '/timelines'
|
|
||||||
fullPath: '/timelines'
|
|
||||||
preLoaderRoute: typeof TimelinesRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/timeline': {
|
'/timeline': {
|
||||||
id: '/timeline'
|
id: '/timeline'
|
||||||
path: '/timeline'
|
path: '/timeline'
|
||||||
@@ -230,13 +228,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof TimelineRouteImport
|
preLoaderRoute: typeof TimelineRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
|
||||||
id: '/login'
|
|
||||||
path: '/login'
|
|
||||||
fullPath: '/login'
|
|
||||||
preLoaderRoute: typeof LoginRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/demo': {
|
'/demo': {
|
||||||
id: '/demo'
|
id: '/demo'
|
||||||
path: '/demo'
|
path: '/demo'
|
||||||
@@ -258,12 +249,19 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ConsentRouteImport
|
preLoaderRoute: typeof ConsentRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/': {
|
'/_default': {
|
||||||
id: '/'
|
id: '/_default'
|
||||||
|
path: ''
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof DefaultRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/_default/': {
|
||||||
|
id: '/_default/'
|
||||||
path: '/'
|
path: '/'
|
||||||
fullPath: '/'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof DefaultIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof DefaultRoute
|
||||||
}
|
}
|
||||||
'/timeline/$timelineId': {
|
'/timeline/$timelineId': {
|
||||||
id: '/timeline/$timelineId'
|
id: '/timeline/$timelineId'
|
||||||
@@ -272,6 +270,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof TimelineTimelineIdRouteImport
|
preLoaderRoute: typeof TimelineTimelineIdRouteImport
|
||||||
parentRoute: typeof TimelineRoute
|
parentRoute: typeof TimelineRoute
|
||||||
}
|
}
|
||||||
|
'/_default/timelines': {
|
||||||
|
id: '/_default/timelines'
|
||||||
|
path: '/timelines'
|
||||||
|
fullPath: '/timelines'
|
||||||
|
preLoaderRoute: typeof DefaultTimelinesRouteImport
|
||||||
|
parentRoute: typeof DefaultRoute
|
||||||
|
}
|
||||||
|
'/_default/login': {
|
||||||
|
id: '/_default/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof DefaultLoginRouteImport
|
||||||
|
parentRoute: typeof DefaultRoute
|
||||||
|
}
|
||||||
'/.well-known/openid-configuration': {
|
'/.well-known/openid-configuration': {
|
||||||
id: '/.well-known/openid-configuration'
|
id: '/.well-known/openid-configuration'
|
||||||
path: '/.well-known/openid-configuration'
|
path: '/.well-known/openid-configuration'
|
||||||
@@ -317,6 +329,21 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DefaultRouteChildren {
|
||||||
|
DefaultLoginRoute: typeof DefaultLoginRoute
|
||||||
|
DefaultTimelinesRoute: typeof DefaultTimelinesRoute
|
||||||
|
DefaultIndexRoute: typeof DefaultIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultRouteChildren: DefaultRouteChildren = {
|
||||||
|
DefaultLoginRoute: DefaultLoginRoute,
|
||||||
|
DefaultTimelinesRoute: DefaultTimelinesRoute,
|
||||||
|
DefaultIndexRoute: DefaultIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultRouteWithChildren =
|
||||||
|
DefaultRoute._addFileChildren(DefaultRouteChildren)
|
||||||
|
|
||||||
interface TimelineRouteChildren {
|
interface TimelineRouteChildren {
|
||||||
TimelineTimelineIdRoute: typeof TimelineTimelineIdRoute
|
TimelineTimelineIdRoute: typeof TimelineTimelineIdRoute
|
||||||
}
|
}
|
||||||
@@ -345,13 +372,11 @@ const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
|||||||
)
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
DefaultRoute: DefaultRouteWithChildren,
|
||||||
ConsentRoute: ConsentRoute,
|
ConsentRoute: ConsentRoute,
|
||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
DemoRoute: DemoRoute,
|
DemoRoute: DemoRoute,
|
||||||
LoginRoute: LoginRoute,
|
|
||||||
TimelineRoute: TimelineRouteWithChildren,
|
TimelineRoute: TimelineRouteWithChildren,
|
||||||
TimelinesRoute: TimelinesRoute,
|
|
||||||
DotwellKnownOauthAuthorizationServerRoute:
|
DotwellKnownOauthAuthorizationServerRoute:
|
||||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||||
DotwellKnownOauthProtectedResourceRoute:
|
DotwellKnownOauthProtectedResourceRoute:
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
|
|
||||||
import Header from "../components/header";
|
|
||||||
import appCss from "../index.css?url";
|
import appCss from "../index.css?url";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
@@ -47,7 +45,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
|
|||||||
|
|
||||||
function RootDocument() {
|
function RootDocument() {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
@@ -55,7 +53,6 @@ function RootDocument() {
|
|||||||
<body>
|
<body>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<div className="grid min-h-svh grid-rows-[auto_1fr]">
|
<div className="grid min-h-svh grid-rows-[auto_1fr]">
|
||||||
<Header />
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
FieldRoot,
|
FieldRoot,
|
||||||
} from "@/components/ui/field";
|
} from "@/components/ui/field";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/_default/")({
|
||||||
component: HomeComponent,
|
component: HomeComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
44
apps/web/src/routes/_default.timelines.tsx
Normal file
44
apps/web/src/routes/_default.timelines.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { timelinesQueryOptions } from "@/functions/get-timelines";
|
||||||
|
import { preloadFlutter } from "@/components/flutter-view";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_default/timelines")({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
await context.queryClient.ensureQueryData(timelinesQueryOptions());
|
||||||
|
},
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const timelinesQuery = useSuspenseQuery(timelinesQueryOptions());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
preloadFlutter();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<section className="container mx-auto max-w-3xl px-4 py-2">
|
||||||
|
<h1 className="text-4xl font-serif font-bold mb-6">Timelines</h1>
|
||||||
|
{timelinesQuery.data.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">No timelines yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{timelinesQuery.data.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.id}
|
||||||
|
to="/timeline/$timelineId"
|
||||||
|
params={{ timelineId: t.id }}
|
||||||
|
className="block border border-border rounded-lg p-4 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<h2 className="font-serif font-bold">{t.title}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/web/src/routes/_default.tsx
Normal file
15
apps/web/src/routes/_default.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import Header from "@/components/header";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_default")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import SignInForm from "@/components/sign-in-form";
|
|
||||||
import SignUpForm from "@/components/sign-up-form";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/login")({
|
|
||||||
component: RouteComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const [showSignIn, setShowSignIn] = useState(false);
|
|
||||||
|
|
||||||
return showSignIn ? (
|
|
||||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
|
||||||
) : (
|
|
||||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Moon, Plus, Sun } from "lucide-react";
|
||||||
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
|
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
|
||||||
import { timelineQueryOptions } from "@/functions/get-timeline";
|
import { timelineQueryOptions } from "@/functions/get-timeline";
|
||||||
import { FlutterView } from "@/components/flutter-view";
|
import { FlutterView } from "@/components/flutter-view";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import UserMenu from "@/components/user-menu";
|
||||||
|
import ItemFormDrawer from "@/components/item-form-drawer";
|
||||||
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
|
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
|
||||||
|
import { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation";
|
||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
|
|
||||||
export const Route = createFileRoute("/timeline/$timelineId")({
|
export const Route = createFileRoute("/timeline/$timelineId")({
|
||||||
@@ -20,9 +25,15 @@ function RouteComponent() {
|
|||||||
const { timelineId } = Route.useParams();
|
const { timelineId } = Route.useParams();
|
||||||
const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
|
const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
|
||||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
|
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
|
||||||
const entryMoved = useEntryMovedMutation(timelineId);
|
const entryMoved = useEntryMovedMutation(timelineId);
|
||||||
const { theme } = useTheme();
|
const entryResized = useEntryResizedMutation(timelineId);
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const editItem = selectedItemId
|
||||||
|
? (timeline.items[selectedItemId] ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const flutterState: FlutterTimelineState = useMemo(
|
const flutterState: FlutterTimelineState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -57,23 +68,71 @@ function RouteComponent() {
|
|||||||
break;
|
break;
|
||||||
case "item_selected":
|
case "item_selected":
|
||||||
setSelectedItemId(event.payload.itemId);
|
setSelectedItemId(event.payload.itemId);
|
||||||
|
setDrawerOpen(true);
|
||||||
break;
|
break;
|
||||||
case "item_deselected":
|
case "item_deselected":
|
||||||
setSelectedItemId(null);
|
setSelectedItemId(null);
|
||||||
|
setDrawerOpen(false);
|
||||||
break;
|
break;
|
||||||
case "entry_moved":
|
case "entry_moved":
|
||||||
entryMoved.mutate(event.payload);
|
entryMoved.mutate(event.payload);
|
||||||
break;
|
break;
|
||||||
|
case "entry_resized":
|
||||||
|
entryResized.mutate(event.payload);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[entryMoved]
|
[entryMoved, entryResized]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDrawerOpenChange = useCallback((open: boolean) => {
|
||||||
|
setDrawerOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setSelectedItemId(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddItem = useCallback(() => {
|
||||||
|
setSelectedItemId(null);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-3xl font-serif font-bold mb-6 mx-4">
|
<div className="flex flex-row items-center justify-between px-2 py-1">
|
||||||
{timeline.title}
|
<div className="flex items-center gap-2">
|
||||||
</h1>
|
<Link
|
||||||
|
to="/timelines"
|
||||||
|
className="text-muted-foreground hover:text-foreground inline-flex size-8 items-center justify-center rounded-md transition-colors"
|
||||||
|
aria-label="Back to timelines"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-lg font-serif font-bold">{timeline.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="text-muted-foreground hover:text-foreground inline-flex size-8 items-center justify-center rounded-md transition-colors"
|
||||||
|
aria-label={
|
||||||
|
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
|
||||||
<FlutterView
|
<FlutterView
|
||||||
state={flutterState}
|
state={flutterState}
|
||||||
@@ -81,6 +140,15 @@ function RouteComponent() {
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
height={flutterHeight}
|
height={flutterHeight}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ItemFormDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onOpenChange={handleDrawerOpenChange}
|
||||||
|
timelineId={timelineId}
|
||||||
|
groups={timeline.groups}
|
||||||
|
groupOrder={timeline.groupOrder}
|
||||||
|
editItem={editItem}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
||||||
import { timelinesQueryOptions } from "@/functions/get-timelines";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/timelines")({
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
await context.queryClient.ensureQueryData(timelinesQueryOptions());
|
|
||||||
},
|
|
||||||
component: RouteComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const timelinesQuery = useSuspenseQuery(timelinesQueryOptions());
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>List of timelines</h1>
|
|
||||||
<div>
|
|
||||||
{timelinesQuery.data.map((t) => (
|
|
||||||
<div key={t.id}>{t.title}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,19 @@ import { defineConfig } from "vite";
|
|||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tsconfigPaths(), tailwindcss(), tanstackStart(), viteReact()],
|
plugins: [
|
||||||
|
tsconfigPaths(),
|
||||||
|
tailwindcss(),
|
||||||
|
tanstackStart({
|
||||||
|
server: { entry: "./entry-server.ts" },
|
||||||
|
}),
|
||||||
|
viteReact(),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 3001,
|
port: 3001,
|
||||||
|
headers: {
|
||||||
|
"Cross-Origin-Embedder-Policy": "credentialless",
|
||||||
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface ItemForLane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assignLane(
|
function assignLane(
|
||||||
existing: ItemForLane[],
|
existing: Array<ItemForLane>,
|
||||||
newStart: Date,
|
newStart: Date,
|
||||||
newEnd: Date | null
|
newEnd: Date | null
|
||||||
): number {
|
): number {
|
||||||
@@ -57,7 +57,7 @@ async function main() {
|
|||||||
|
|
||||||
if (items.length === 0) continue;
|
if (items.length === 0) continue;
|
||||||
|
|
||||||
const assigned: ItemForLane[] = [];
|
const assigned: Array<ItemForLane> = [];
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ class _MainAppState extends State<MainApp> {
|
|||||||
return (start: earliest.subtract(padding), end: latest.add(padding));
|
return (start: earliest.subtract(padding), end: latest.add(padding));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onEntrySelected(TimelineEntry entry) {
|
||||||
|
emitEvent('item_selected', {'itemId': entry.id});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onBackgroundTap() {
|
||||||
|
emitEvent('item_deselected');
|
||||||
|
}
|
||||||
|
|
||||||
void _onEntryMoved(
|
void _onEntryMoved(
|
||||||
TimelineEntry entry,
|
TimelineEntry entry,
|
||||||
DateTime newStart,
|
DateTime newStart,
|
||||||
@@ -169,6 +177,47 @@ class _MainAppState extends State<MainApp> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onEntryResized(
|
||||||
|
TimelineEntry entry,
|
||||||
|
DateTime newStart,
|
||||||
|
DateTime newEnd,
|
||||||
|
int newLane,
|
||||||
|
) {
|
||||||
|
// Optimistic update -- apply locally before the host round-trips.
|
||||||
|
if (_state case final state?) {
|
||||||
|
final oldItem = state.items[entry.id];
|
||||||
|
if (oldItem != null) {
|
||||||
|
final updatedItems = Map<String, TimelineItemData>.of(state.items);
|
||||||
|
updatedItems[entry.id] = TimelineItemData(
|
||||||
|
id: oldItem.id,
|
||||||
|
groupId: oldItem.groupId,
|
||||||
|
title: oldItem.title,
|
||||||
|
description: oldItem.description,
|
||||||
|
start: newStart.toIso8601String(),
|
||||||
|
end: entry.hasEnd ? newEnd.toIso8601String() : null,
|
||||||
|
lane: newLane,
|
||||||
|
);
|
||||||
|
final updatedState = TimelineState(
|
||||||
|
timeline: state.timeline,
|
||||||
|
groups: state.groups,
|
||||||
|
items: updatedItems,
|
||||||
|
groupOrder: state.groupOrder,
|
||||||
|
selectedItemId: state.selectedItemId,
|
||||||
|
darkMode: state.darkMode,
|
||||||
|
);
|
||||||
|
_applyState(updatedState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent('entry_resized', <String, Object?>{
|
||||||
|
'entryId': entry.id,
|
||||||
|
'newStart': newStart.toIso8601String(),
|
||||||
|
'newEnd': entry.hasEnd ? newEnd.toIso8601String() : null,
|
||||||
|
'groupId': entry.groupId,
|
||||||
|
'lane': newLane,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _emitContentHeight() {
|
void _emitContentHeight() {
|
||||||
// Start with the fixed chrome heights.
|
// Start with the fixed chrome heights.
|
||||||
var totalHeight = _tieredHeaderHeight + _breadcrumbHeight;
|
var totalHeight = _tieredHeaderHeight + _breadcrumbHeight;
|
||||||
@@ -213,6 +262,74 @@ class _MainAppState extends State<MainApp> {
|
|||||||
return _groupColors[groupIndex % _groupColors.length];
|
return _groupColors[groupIndex % _groupColors.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
|
String _formatDate(DateTime d) {
|
||||||
|
return '${_months[d.month - 1]} ${d.day}, ${d.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateRange(DateTime start, DateTime? end) {
|
||||||
|
final s = _formatDate(start);
|
||||||
|
if (end == null) return s;
|
||||||
|
final e = _formatDate(end);
|
||||||
|
return s == e ? s : '$s – $e';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _buildPopoverContent(String entryId) {
|
||||||
|
final item = _state?.items[entryId];
|
||||||
|
if (item == null) return null;
|
||||||
|
|
||||||
|
final start = DateTime.parse(item.start);
|
||||||
|
final end = item.end != null ? DateTime.parse(item.end!) : null;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (item.description != null && item.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
item.description!,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_formatDateRange(start, end),
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactDate(DateTime start, DateTime end) {
|
||||||
|
return Text(
|
||||||
|
_formatDateRange(start, end),
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final viewport = _viewport;
|
final viewport = _viewport;
|
||||||
@@ -228,24 +345,32 @@ class _MainAppState extends State<MainApp> {
|
|||||||
? const Center(child: Text('Waiting for state...'))
|
? const Center(child: Text('Waiting for state...'))
|
||||||
: ZTimelineScope(
|
: ZTimelineScope(
|
||||||
viewport: viewport,
|
viewport: viewport,
|
||||||
child: Column(
|
child: EntryPopoverOverlay(
|
||||||
children: [
|
popoverContentBuilder: _buildPopoverContent,
|
||||||
const ZTimelineBreadcrumb(),
|
compactDateBuilder: _buildCompactDate,
|
||||||
const ZTimelineTieredHeader(),
|
child: Column(
|
||||||
Expanded(
|
children: [
|
||||||
child: ZTimelineInteractor(
|
const ZTimelineBreadcrumb(),
|
||||||
child: ZTimelineView(
|
const ZTimelineTieredHeader(),
|
||||||
groups: _groups,
|
Expanded(
|
||||||
entries: _entries,
|
child: ZTimelineInteractor(
|
||||||
viewport: viewport,
|
onBackgroundTap: _onBackgroundTap,
|
||||||
labelBuilder: _labelForEntry,
|
child: ZTimelineView(
|
||||||
colorBuilder: _colorForEntry,
|
groups: _groups,
|
||||||
enableDrag: true,
|
entries: _entries,
|
||||||
onEntryMoved: _onEntryMoved,
|
viewport: viewport,
|
||||||
|
labelBuilder: _labelForEntry,
|
||||||
|
colorBuilder: _colorForEntry,
|
||||||
|
enableDrag: true,
|
||||||
|
onEntryMoved: _onEntryMoved,
|
||||||
|
onEntryResized: _onEntryResized,
|
||||||
|
onEntrySelected: _onEntrySelected,
|
||||||
|
selectedEntryId: _state?.selectedItemId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "flutter build web --release --wasm --base-href /flutter/ && node scripts/copy-build.mjs"
|
"build": "flutter build web --release --wasm --base-href /flutter/ --source-maps && node scripts/copy-build.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,20 @@ import 'package:flutter/material.dart';
|
|||||||
class ZTimelineConstants {
|
class ZTimelineConstants {
|
||||||
const ZTimelineConstants._();
|
const ZTimelineConstants._();
|
||||||
|
|
||||||
|
// Pill body
|
||||||
|
static const double pillInnerBorderWidth = 1.0;
|
||||||
|
static const double pillContentHeight = 16.0; // labelMedium line height
|
||||||
|
|
||||||
|
// Computed pill height (single source of truth for lane height)
|
||||||
|
// pillPadding.vertical (6.0 * 2 = 12.0) inlined because EdgeInsets
|
||||||
|
// getters are not compile-time constants.
|
||||||
|
static const double pillHeight =
|
||||||
|
pillInnerBorderWidth * 2 +
|
||||||
|
12.0 + // pillPadding.vertical
|
||||||
|
pillContentHeight; // = 30.0
|
||||||
|
|
||||||
// Heights
|
// Heights
|
||||||
static const double laneHeight = 28.0;
|
static const double laneHeight = pillHeight;
|
||||||
static const double groupHeaderHeight = 34.0;
|
static const double groupHeaderHeight = 34.0;
|
||||||
|
|
||||||
// Spacing
|
// Spacing
|
||||||
@@ -19,6 +31,15 @@ class ZTimelineConstants {
|
|||||||
vertical: 6.0,
|
vertical: 6.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Point events (hasEnd == false)
|
||||||
|
static const double pointEventCircleDiameter = 12.0;
|
||||||
|
static const double pointEventCircleTextGap = 6.0;
|
||||||
|
static const double pointEventTextGap = 8.0;
|
||||||
|
|
||||||
|
// Resize handles
|
||||||
|
static const double resizeHandleWidth = 6.0;
|
||||||
|
static const Duration minResizeDuration = Duration(hours: 1);
|
||||||
|
|
||||||
// Content width
|
// Content width
|
||||||
static const double minContentWidth = 1200.0;
|
static const double minContentWidth = 1200.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'timeline_entry.dart';
|
||||||
|
|
||||||
|
/// Which edge of the pill is being resized.
|
||||||
|
enum ResizeEdge { start, end }
|
||||||
|
|
||||||
|
/// Immutable state tracking during resize operations.
|
||||||
|
///
|
||||||
|
/// Captures the entry being resized, which edge, and the target bounds.
|
||||||
|
@immutable
|
||||||
|
class EntryResizeState {
|
||||||
|
const EntryResizeState({
|
||||||
|
required this.entryId,
|
||||||
|
required this.originalEntry,
|
||||||
|
required this.edge,
|
||||||
|
required this.targetStart,
|
||||||
|
required this.targetEnd,
|
||||||
|
required this.targetLane,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The ID of the entry being resized.
|
||||||
|
final String entryId;
|
||||||
|
|
||||||
|
/// The original entry (for reverting / reference).
|
||||||
|
final TimelineEntry originalEntry;
|
||||||
|
|
||||||
|
/// Which edge the user is dragging.
|
||||||
|
final ResizeEdge edge;
|
||||||
|
|
||||||
|
/// The current target start time.
|
||||||
|
final DateTime targetStart;
|
||||||
|
|
||||||
|
/// The current target end time.
|
||||||
|
final DateTime targetEnd;
|
||||||
|
|
||||||
|
/// The resolved lane (collision-aware).
|
||||||
|
final int targetLane;
|
||||||
|
|
||||||
|
EntryResizeState copyWith({
|
||||||
|
String? entryId,
|
||||||
|
TimelineEntry? originalEntry,
|
||||||
|
ResizeEdge? edge,
|
||||||
|
DateTime? targetStart,
|
||||||
|
DateTime? targetEnd,
|
||||||
|
int? targetLane,
|
||||||
|
}) {
|
||||||
|
return EntryResizeState(
|
||||||
|
entryId: entryId ?? this.entryId,
|
||||||
|
originalEntry: originalEntry ?? this.originalEntry,
|
||||||
|
edge: edge ?? this.edge,
|
||||||
|
targetStart: targetStart ?? this.targetStart,
|
||||||
|
targetEnd: targetEnd ?? this.targetEnd,
|
||||||
|
targetLane: targetLane ?? this.targetLane,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is EntryResizeState &&
|
||||||
|
other.entryId == entryId &&
|
||||||
|
other.originalEntry == originalEntry &&
|
||||||
|
other.edge == edge &&
|
||||||
|
other.targetStart == targetStart &&
|
||||||
|
other.targetEnd == targetEnd &&
|
||||||
|
other.targetLane == targetLane;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
entryId,
|
||||||
|
originalEntry,
|
||||||
|
edge,
|
||||||
|
targetStart,
|
||||||
|
targetEnd,
|
||||||
|
targetLane,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'EntryResizeState('
|
||||||
|
'entryId: $entryId, '
|
||||||
|
'edge: $edge, '
|
||||||
|
'targetStart: $targetStart, '
|
||||||
|
'targetEnd: $targetEnd, '
|
||||||
|
'targetLane: $targetLane)';
|
||||||
|
}
|
||||||
@@ -9,22 +9,27 @@ class ZTimelineInteractionState {
|
|||||||
const ZTimelineInteractionState({
|
const ZTimelineInteractionState({
|
||||||
this.isGrabbing = false,
|
this.isGrabbing = false,
|
||||||
this.isDraggingEntry = false,
|
this.isDraggingEntry = false,
|
||||||
|
this.isResizingEntry = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Whether the user is actively panning (for cursor feedback).
|
/// Whether the user is actively panning (for cursor feedback).
|
||||||
final bool isGrabbing;
|
final bool isGrabbing;
|
||||||
|
|
||||||
/// Whether an entry is being dragged (disables pan gesture).
|
/// Whether an entry is being dragged (disables pan gesture).
|
||||||
/// This will be used by future drag-and-drop functionality.
|
|
||||||
final bool isDraggingEntry;
|
final bool isDraggingEntry;
|
||||||
|
|
||||||
|
/// Whether an entry is being resized (disables pan gesture).
|
||||||
|
final bool isResizingEntry;
|
||||||
|
|
||||||
ZTimelineInteractionState copyWith({
|
ZTimelineInteractionState copyWith({
|
||||||
bool? isGrabbing,
|
bool? isGrabbing,
|
||||||
bool? isDraggingEntry,
|
bool? isDraggingEntry,
|
||||||
|
bool? isResizingEntry,
|
||||||
}) {
|
}) {
|
||||||
return ZTimelineInteractionState(
|
return ZTimelineInteractionState(
|
||||||
isGrabbing: isGrabbing ?? this.isGrabbing,
|
isGrabbing: isGrabbing ?? this.isGrabbing,
|
||||||
isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry,
|
isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry,
|
||||||
|
isResizingEntry: isResizingEntry ?? this.isResizingEntry,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +38,10 @@ class ZTimelineInteractionState {
|
|||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
return other is ZTimelineInteractionState &&
|
return other is ZTimelineInteractionState &&
|
||||||
other.isGrabbing == isGrabbing &&
|
other.isGrabbing == isGrabbing &&
|
||||||
other.isDraggingEntry == isDraggingEntry;
|
other.isDraggingEntry == isDraggingEntry &&
|
||||||
|
other.isResizingEntry == isResizingEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(isGrabbing, isDraggingEntry);
|
int get hashCode => Object.hash(isGrabbing, isDraggingEntry, isResizingEntry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'timeline_entry.dart';
|
|
||||||
|
|
||||||
/// Represents a projected entry on the timeline.
|
|
||||||
/// This is used to represent an entry on the timeline in a normalized space.
|
|
||||||
/// The startX and endX are normalized in [0, 1] and represent the position of
|
|
||||||
/// the entry on the timeline.
|
|
||||||
/// The widthX is the width of the entry in the normalized space.
|
|
||||||
@immutable
|
|
||||||
class ProjectedEntry {
|
|
||||||
const ProjectedEntry({
|
|
||||||
required this.entry,
|
|
||||||
required this.startX,
|
|
||||||
required this.endX,
|
|
||||||
}) : assert(startX <= endX, 'Projected startX must be <= endX');
|
|
||||||
|
|
||||||
final TimelineEntry entry;
|
|
||||||
final double startX; // normalized in [0, 1]
|
|
||||||
final double endX; // normalized in [0, 1]
|
|
||||||
|
|
||||||
double get widthX => (endX - startX).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(entry, startX, endX);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
return other is ProjectedEntry &&
|
|
||||||
other.entry == entry &&
|
|
||||||
other.startX == startX &&
|
|
||||||
other.endX == endX;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() =>
|
|
||||||
'ProjectedEntry(entry: ${entry.id}, startX: $startX, endX: $endX)';
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import '../constants.dart';
|
|||||||
/// The timeline uses two coordinate spaces:
|
/// The timeline uses two coordinate spaces:
|
||||||
///
|
///
|
||||||
/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain.
|
/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain.
|
||||||
/// Used by [TimeScaleService] and stored in [ProjectedEntry].
|
/// Computed via [TimeScaleService].
|
||||||
///
|
///
|
||||||
/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline.
|
/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline.
|
||||||
/// What gets passed to [Positioned] widgets.
|
/// What gets passed to [Positioned] widgets.
|
||||||
@@ -35,8 +35,7 @@ class LayoutCoordinateService {
|
|||||||
required double normalizedWidth,
|
required double normalizedWidth,
|
||||||
required double contentWidth,
|
required double contentWidth,
|
||||||
}) {
|
}) {
|
||||||
return (normalizedWidth * contentWidth)
|
return (normalizedWidth * contentWidth).clamp(0.0, double.infinity);
|
||||||
.clamp(0.0, double.infinity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -62,8 +61,7 @@ class LayoutCoordinateService {
|
|||||||
required double widgetX,
|
required double widgetX,
|
||||||
required double contentWidth,
|
required double contentWidth,
|
||||||
}) {
|
}) {
|
||||||
final adjustedX =
|
final adjustedX = (widgetX).clamp(0.0, contentWidth);
|
||||||
(widgetX).clamp(0.0, contentWidth);
|
|
||||||
return contentWidth == 0 ? 0.0 : adjustedX / contentWidth;
|
return contentWidth == 0 ? 0.0 : adjustedX / contentWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +73,7 @@ class LayoutCoordinateService {
|
|||||||
///
|
///
|
||||||
/// Used by all positioned elements (pills, ghost overlay) within the
|
/// Used by all positioned elements (pills, ghost overlay) within the
|
||||||
/// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0.
|
/// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0.
|
||||||
static double laneToY({
|
static double laneToY({required int lane, required double laneHeight}) {
|
||||||
required int lane,
|
|
||||||
required double laneHeight,
|
|
||||||
}) {
|
|
||||||
return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +81,7 @@ class LayoutCoordinateService {
|
|||||||
///
|
///
|
||||||
/// Used by drop targets to determine which lane the cursor is over.
|
/// Used by drop targets to determine which lane the cursor is over.
|
||||||
/// The Y coordinate should be relative to the timeline Stack.
|
/// The Y coordinate should be relative to the timeline Stack.
|
||||||
static int yToLane({
|
static int yToLane({required double y, required double laneHeight}) {
|
||||||
required double y,
|
|
||||||
required double laneHeight,
|
|
||||||
}) {
|
|
||||||
final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing;
|
final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing;
|
||||||
return (y / laneStep).floor() + 1;
|
return (y / laneStep).floor() + 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,8 +286,10 @@ class TieredTickService {
|
|||||||
|
|
||||||
// Start one period before to ensure we cover partial sections at the edge
|
// Start one period before to ensure we cover partial sections at the edge
|
||||||
var current = alignToUnit(startUtc, unit);
|
var current = alignToUnit(startUtc, unit);
|
||||||
final oneBeforeStart =
|
final oneBeforeStart = alignToUnit(
|
||||||
alignToUnit(current.subtract(const Duration(milliseconds: 1)), unit);
|
current.subtract(const Duration(milliseconds: 1)),
|
||||||
|
unit,
|
||||||
|
);
|
||||||
current = oneBeforeStart;
|
current = oneBeforeStart;
|
||||||
|
|
||||||
while (current.isBefore(endUtc)) {
|
while (current.isBefore(endUtc)) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../constants.dart';
|
||||||
|
|
||||||
|
/// Result of a hit test against registered groups.
|
||||||
|
class GroupHitResult {
|
||||||
|
const GroupHitResult({required this.groupId, required this.localPosition});
|
||||||
|
|
||||||
|
/// The ID of the group that was hit.
|
||||||
|
final String groupId;
|
||||||
|
|
||||||
|
/// The position relative to the group's lanes area.
|
||||||
|
final Offset localPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registration data for a timeline group's lanes area.
|
||||||
|
class _GroupRegistration {
|
||||||
|
const _GroupRegistration({
|
||||||
|
required this.key,
|
||||||
|
required this.verticalOffset,
|
||||||
|
required this.lanesCount,
|
||||||
|
required this.laneHeight,
|
||||||
|
required this.contentWidth,
|
||||||
|
this.headerHeight = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final GlobalKey key;
|
||||||
|
final double verticalOffset;
|
||||||
|
final int lanesCount;
|
||||||
|
final double laneHeight;
|
||||||
|
final double contentWidth;
|
||||||
|
final double headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry for timeline group lane areas, enabling cross-group hit detection.
|
||||||
|
///
|
||||||
|
/// Each group registers its lanes area [GlobalKey] so that during drag
|
||||||
|
/// operations we can determine which group contains a given global position
|
||||||
|
/// without relying on Flutter's [DragTarget] system.
|
||||||
|
class TimelineGroupRegistry {
|
||||||
|
final _groups = <String, _GroupRegistration>{};
|
||||||
|
|
||||||
|
/// Register a group's lanes area.
|
||||||
|
void register(
|
||||||
|
String groupId,
|
||||||
|
GlobalKey key, {
|
||||||
|
required double verticalOffset,
|
||||||
|
required int lanesCount,
|
||||||
|
required double laneHeight,
|
||||||
|
required double contentWidth,
|
||||||
|
double headerHeight = 0,
|
||||||
|
}) {
|
||||||
|
_groups[groupId] = _GroupRegistration(
|
||||||
|
key: key,
|
||||||
|
verticalOffset: verticalOffset,
|
||||||
|
lanesCount: lanesCount,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
headerHeight: headerHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unregister a group.
|
||||||
|
void unregister(String groupId) {
|
||||||
|
_groups.remove(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit test against all registered groups.
|
||||||
|
///
|
||||||
|
/// Returns the group whose lanes area contains [globalPosition],
|
||||||
|
/// along with the local position within that lanes area.
|
||||||
|
GroupHitResult? hitTest(Offset globalPosition) {
|
||||||
|
for (final entry in _groups.entries) {
|
||||||
|
final reg = entry.value;
|
||||||
|
final renderBox =
|
||||||
|
reg.key.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox == null || !renderBox.attached) continue;
|
||||||
|
|
||||||
|
final local = renderBox.globalToLocal(globalPosition);
|
||||||
|
final adjustedY = local.dy - reg.verticalOffset;
|
||||||
|
|
||||||
|
// Check horizontal bounds
|
||||||
|
if (local.dx < 0 || local.dx > reg.contentWidth) continue;
|
||||||
|
|
||||||
|
// Check vertical bounds: extend upward by headerHeight to cover
|
||||||
|
// the group header dead zone, and allow one extra lane for expansion.
|
||||||
|
if (adjustedY < -reg.headerHeight) continue;
|
||||||
|
final maxLanes = (reg.lanesCount <= 0 ? 1 : reg.lanesCount) + 1;
|
||||||
|
final maxY =
|
||||||
|
maxLanes * (reg.laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||||
|
if (adjustedY > maxY) continue;
|
||||||
|
|
||||||
|
// Clamp negative values (pointer over header) to top of lanes area.
|
||||||
|
final clampedY = adjustedY < 0 ? 0.0 : adjustedY;
|
||||||
|
|
||||||
|
return GroupHitResult(
|
||||||
|
groupId: entry.key,
|
||||||
|
localPosition: Offset(local.dx, clampedY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the lane height for a registered group.
|
||||||
|
double? laneHeightFor(String groupId) => _groups[groupId]?.laneHeight;
|
||||||
|
|
||||||
|
/// Get the content width for a registered group.
|
||||||
|
double? contentWidthFor(String groupId) => _groups[groupId]?.contentWidth;
|
||||||
|
|
||||||
|
/// Get the lanes count for a registered group.
|
||||||
|
int? lanesCountFor(String groupId) => _groups[groupId]?.lanesCount;
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import '../models/timeline_entry.dart';
|
|
||||||
import '../models/projected_entry.dart';
|
|
||||||
import 'time_scale_service.dart';
|
|
||||||
|
|
||||||
class TimelineProjectionService {
|
|
||||||
const TimelineProjectionService();
|
|
||||||
|
|
||||||
Map<String, List<ProjectedEntry>> project({
|
|
||||||
required Iterable<TimelineEntry> entries,
|
|
||||||
required DateTime domainStart,
|
|
||||||
required DateTime domainEnd,
|
|
||||||
}) {
|
|
||||||
final byGroup = <String, List<ProjectedEntry>>{};
|
|
||||||
for (final e in entries) {
|
|
||||||
if (e.overlaps(domainStart, domainEnd)) {
|
|
||||||
final startX = TimeScaleService.mapTimeToPosition(
|
|
||||||
e.start.isBefore(domainStart) ? domainStart : e.start,
|
|
||||||
domainStart,
|
|
||||||
domainEnd,
|
|
||||||
).clamp(0.0, 1.0);
|
|
||||||
final endX = TimeScaleService.mapTimeToPosition(
|
|
||||||
e.end.isAfter(domainEnd) ? domainEnd : e.end,
|
|
||||||
domainStart,
|
|
||||||
domainEnd,
|
|
||||||
).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
final pe = ProjectedEntry(entry: e, startX: startX, endX: endX);
|
|
||||||
(byGroup[e.groupId] ??= <ProjectedEntry>[]).add(pe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep original order stable by lane then startX
|
|
||||||
for (final list in byGroup.values) {
|
|
||||||
list.sort((a, b) {
|
|
||||||
final laneCmp = a.entry.lane.compareTo(b.entry.lane);
|
|
||||||
if (laneCmp != 0) return laneCmp;
|
|
||||||
return a.startX.compareTo(b.startX);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return byGroup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/entry_drag_state.dart';
|
import '../models/entry_drag_state.dart';
|
||||||
|
import '../models/entry_resize_state.dart';
|
||||||
import '../models/interaction_state.dart';
|
import '../models/interaction_state.dart';
|
||||||
import '../models/timeline_entry.dart';
|
import '../models/timeline_entry.dart';
|
||||||
|
|
||||||
@@ -13,6 +16,14 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
|||||||
|
|
||||||
ZTimelineInteractionState _state = const ZTimelineInteractionState();
|
ZTimelineInteractionState _state = const ZTimelineInteractionState();
|
||||||
EntryDragState? _dragState;
|
EntryDragState? _dragState;
|
||||||
|
EntryResizeState? _resizeState;
|
||||||
|
|
||||||
|
// -- Hover state --
|
||||||
|
String? _hoveredEntryId;
|
||||||
|
Rect? _hoveredPillGlobalRect;
|
||||||
|
|
||||||
|
// -- Interaction cursor position (during drag/resize) --
|
||||||
|
Offset? _interactionGlobalPosition;
|
||||||
|
|
||||||
/// The current interaction state.
|
/// The current interaction state.
|
||||||
ZTimelineInteractionState get state => _state;
|
ZTimelineInteractionState get state => _state;
|
||||||
@@ -20,12 +31,52 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
|||||||
/// The current drag state, or null if no drag is active.
|
/// The current drag state, or null if no drag is active.
|
||||||
EntryDragState? get dragState => _dragState;
|
EntryDragState? get dragState => _dragState;
|
||||||
|
|
||||||
|
/// The current resize state, or null if no resize is active.
|
||||||
|
EntryResizeState? get resizeState => _resizeState;
|
||||||
|
|
||||||
/// Whether the user is actively panning (for cursor feedback).
|
/// Whether the user is actively panning (for cursor feedback).
|
||||||
bool get isGrabbing => _state.isGrabbing;
|
bool get isGrabbing => _state.isGrabbing;
|
||||||
|
|
||||||
/// Whether an entry is being dragged (disables pan gesture).
|
/// Whether an entry is being dragged (disables pan gesture).
|
||||||
bool get isDraggingEntry => _state.isDraggingEntry;
|
bool get isDraggingEntry => _state.isDraggingEntry;
|
||||||
|
|
||||||
|
/// Whether an entry is being resized.
|
||||||
|
bool get isResizingEntry => _state.isResizingEntry;
|
||||||
|
|
||||||
|
/// Whether any entry interaction (drag or resize) is active.
|
||||||
|
bool get isInteracting => isDraggingEntry || isResizingEntry;
|
||||||
|
|
||||||
|
/// The currently hovered entry ID, or null.
|
||||||
|
String? get hoveredEntryId => _hoveredEntryId;
|
||||||
|
|
||||||
|
/// The global rect of the hovered pill, or null.
|
||||||
|
Rect? get hoveredPillGlobalRect => _hoveredPillGlobalRect;
|
||||||
|
|
||||||
|
/// The global cursor position during drag/resize, or null.
|
||||||
|
Offset? get interactionGlobalPosition => _interactionGlobalPosition;
|
||||||
|
|
||||||
|
/// Set the hovered entry and its global rect.
|
||||||
|
void setHoveredEntry(String id, Rect globalRect) {
|
||||||
|
if (_hoveredEntryId == id && _hoveredPillGlobalRect == globalRect) return;
|
||||||
|
_hoveredEntryId = id;
|
||||||
|
_hoveredPillGlobalRect = globalRect;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the hovered entry.
|
||||||
|
void clearHoveredEntry() {
|
||||||
|
if (_hoveredEntryId == null) return;
|
||||||
|
_hoveredEntryId = null;
|
||||||
|
_hoveredPillGlobalRect = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the cursor position during drag/resize.
|
||||||
|
void updateInteractionPosition(Offset globalPosition) {
|
||||||
|
_interactionGlobalPosition = globalPosition;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the grabbing state.
|
/// Update the grabbing state.
|
||||||
void setGrabbing(bool value) {
|
void setGrabbing(bool value) {
|
||||||
if (_state.isGrabbing == value) return;
|
if (_state.isGrabbing == value) return;
|
||||||
@@ -43,7 +94,10 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
|||||||
/// Begin dragging an entry.
|
/// Begin dragging an entry.
|
||||||
///
|
///
|
||||||
/// Sets drag state and marks [isDraggingEntry] as true.
|
/// Sets drag state and marks [isDraggingEntry] as true.
|
||||||
|
/// Clears hover state since the user is now dragging.
|
||||||
void beginDrag(TimelineEntry entry) {
|
void beginDrag(TimelineEntry entry) {
|
||||||
|
_hoveredEntryId = null;
|
||||||
|
_hoveredPillGlobalRect = null;
|
||||||
_dragState = EntryDragState(
|
_dragState = EntryDragState(
|
||||||
entryId: entry.id,
|
entryId: entry.id,
|
||||||
originalEntry: entry,
|
originalEntry: entry,
|
||||||
@@ -76,17 +130,56 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
|||||||
/// End the drag and clear state.
|
/// End the drag and clear state.
|
||||||
void endDrag() {
|
void endDrag() {
|
||||||
_dragState = null;
|
_dragState = null;
|
||||||
|
_interactionGlobalPosition = null;
|
||||||
setDraggingEntry(false);
|
setDraggingEntry(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel the drag (alias for [endDrag]).
|
/// Cancel the drag (alias for [endDrag]).
|
||||||
void cancelDrag() => endDrag();
|
void cancelDrag() => endDrag();
|
||||||
|
|
||||||
/// Called by drag-drop system when an entry drag starts.
|
/// Begin resizing an entry edge.
|
||||||
@Deprecated('Use beginDrag instead')
|
///
|
||||||
void beginEntryDrag() => setDraggingEntry(true);
|
/// Clears hover state since the user is now resizing.
|
||||||
|
void beginResize(TimelineEntry entry, ResizeEdge edge) {
|
||||||
|
_hoveredEntryId = null;
|
||||||
|
_hoveredPillGlobalRect = null;
|
||||||
|
_resizeState = EntryResizeState(
|
||||||
|
entryId: entry.id,
|
||||||
|
originalEntry: entry,
|
||||||
|
edge: edge,
|
||||||
|
targetStart: entry.start,
|
||||||
|
targetEnd: entry.end,
|
||||||
|
targetLane: entry.lane,
|
||||||
|
);
|
||||||
|
_state = _state.copyWith(isResizingEntry: true);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Called by drag-drop system when an entry drag ends.
|
/// Update the resize target times and optional lane.
|
||||||
@Deprecated('Use endDrag instead')
|
void updateResizeTarget({
|
||||||
void endEntryDrag() => setDraggingEntry(false);
|
required DateTime targetStart,
|
||||||
|
required DateTime targetEnd,
|
||||||
|
int? targetLane,
|
||||||
|
}) {
|
||||||
|
if (_resizeState == null) return;
|
||||||
|
final newState = _resizeState!.copyWith(
|
||||||
|
targetStart: targetStart,
|
||||||
|
targetEnd: targetEnd,
|
||||||
|
targetLane: targetLane,
|
||||||
|
);
|
||||||
|
if (newState == _resizeState) return;
|
||||||
|
_resizeState = newState;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the resize and clear state.
|
||||||
|
void endResize() {
|
||||||
|
_resizeState = null;
|
||||||
|
_interactionGlobalPosition = null;
|
||||||
|
_state = _state.copyWith(isResizingEntry: false);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the resize (alias for [endResize]).
|
||||||
|
void cancelResize() => endResize();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ class BreadcrumbSegmentChip extends StatelessWidget {
|
|||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: segment.isVisible ? _buildChip(context) : const SizedBox.shrink(),
|
child: segment.isVisible
|
||||||
|
? _buildChip(context)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,14 +47,16 @@ class BreadcrumbSegmentChip extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)),
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
segment.label,
|
segment.label,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(
|
||||||
color: colorScheme.onSurface,
|
context,
|
||||||
),
|
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,77 +1,82 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../constants.dart';
|
|
||||||
import '../models/entry_drag_state.dart';
|
|
||||||
import '../services/layout_coordinate_service.dart';
|
import '../services/layout_coordinate_service.dart';
|
||||||
import '../services/time_scale_service.dart';
|
import '../services/time_scale_service.dart';
|
||||||
import '../state/timeline_viewport_notifier.dart';
|
import '../state/timeline_viewport_notifier.dart';
|
||||||
|
import 'event_pill.dart';
|
||||||
|
import 'event_point.dart';
|
||||||
|
|
||||||
/// A semi-transparent ghost overlay showing where an entry will land.
|
/// A preview overlay showing where an entry will land during drag/resize.
|
||||||
///
|
///
|
||||||
/// Displayed during drag operations to give visual feedback about the
|
/// Renders the actual [EventPill] or [EventPoint] at full opacity so the
|
||||||
/// target position.
|
/// preview looks identical to the real item.
|
||||||
class GhostOverlay extends StatelessWidget {
|
class DragPreview extends StatelessWidget {
|
||||||
const GhostOverlay({
|
const DragPreview({
|
||||||
required this.dragState,
|
required this.targetStart,
|
||||||
|
required this.targetEnd,
|
||||||
|
required this.targetLane,
|
||||||
required this.viewport,
|
required this.viewport,
|
||||||
required this.contentWidth,
|
required this.contentWidth,
|
||||||
required this.laneHeight,
|
required this.laneHeight,
|
||||||
|
required this.color,
|
||||||
|
required this.label,
|
||||||
|
this.hasEnd = true,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final EntryDragState dragState;
|
final DateTime targetStart;
|
||||||
|
final DateTime targetEnd;
|
||||||
|
final int targetLane;
|
||||||
final TimelineViewportNotifier viewport;
|
final TimelineViewportNotifier viewport;
|
||||||
final double contentWidth;
|
final double contentWidth;
|
||||||
final double laneHeight;
|
final double laneHeight;
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Whether this is a range event. When false, renders [EventPoint].
|
||||||
|
final bool hasEnd;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final startX = TimeScaleService.mapTimeToPosition(
|
final startX = TimeScaleService.mapTimeToPosition(
|
||||||
dragState.targetStart,
|
targetStart,
|
||||||
viewport.start,
|
viewport.start,
|
||||||
viewport.end,
|
viewport.end,
|
||||||
);
|
);
|
||||||
final endX = TimeScaleService.mapTimeToPosition(
|
final top = LayoutCoordinateService.laneToY(
|
||||||
dragState.targetEnd,
|
lane: targetLane,
|
||||||
viewport.start,
|
laneHeight: laneHeight,
|
||||||
viewport.end,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use centralized coordinate service to ensure ghost matches pill layout
|
|
||||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
normalizedX: startX,
|
normalizedX: startX,
|
||||||
contentWidth: contentWidth,
|
contentWidth: contentWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasEnd) {
|
||||||
|
return Positioned(
|
||||||
|
left: left.clamp(0.0, double.infinity),
|
||||||
|
top: top,
|
||||||
|
height: laneHeight,
|
||||||
|
child: IgnorePointer(child: EventPoint(color: color, label: label)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final endX = TimeScaleService.mapTimeToPosition(
|
||||||
|
targetEnd,
|
||||||
|
viewport.start,
|
||||||
|
viewport.end,
|
||||||
|
);
|
||||||
final width = LayoutCoordinateService.calculateItemWidth(
|
final width = LayoutCoordinateService.calculateItemWidth(
|
||||||
normalizedWidth: endX - startX,
|
normalizedWidth: endX - startX,
|
||||||
contentWidth: contentWidth,
|
contentWidth: contentWidth,
|
||||||
);
|
);
|
||||||
final top = LayoutCoordinateService.laneToY(
|
|
||||||
lane: dragState.targetLane,
|
|
||||||
laneHeight: laneHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: left.clamp(0.0, double.infinity),
|
left: left.clamp(0.0, double.infinity),
|
||||||
width: width.clamp(0.0, double.infinity),
|
width: width.clamp(0.0, double.infinity),
|
||||||
top: top,
|
top: top,
|
||||||
height: laneHeight,
|
height: laneHeight,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(child: EventPill(color: color, label: label)),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../constants.dart';
|
|
||||||
import '../models/projected_entry.dart';
|
|
||||||
import '../models/timeline_entry.dart';
|
|
||||||
import '../services/layout_coordinate_service.dart';
|
|
||||||
import 'timeline_scope.dart';
|
|
||||||
import 'timeline_view.dart';
|
|
||||||
|
|
||||||
/// A draggable event pill widget.
|
|
||||||
///
|
|
||||||
/// Renders a timeline entry as a pill that can be dragged to move it
|
|
||||||
/// to a new position. Uses Flutter's built-in [Draggable] widget.
|
|
||||||
class DraggableEventPill extends StatelessWidget {
|
|
||||||
const DraggableEventPill({
|
|
||||||
required this.entry,
|
|
||||||
required this.laneHeight,
|
|
||||||
required this.labelBuilder,
|
|
||||||
required this.colorBuilder,
|
|
||||||
required this.contentWidth,
|
|
||||||
required this.enableDrag,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ProjectedEntry entry;
|
|
||||||
final double laneHeight;
|
|
||||||
final EntryLabelBuilder labelBuilder;
|
|
||||||
final EntryColorBuilder colorBuilder;
|
|
||||||
final double contentWidth;
|
|
||||||
final bool enableDrag;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final pill = _buildPill(context);
|
|
||||||
|
|
||||||
// Use centralized coordinate service for consistent positioning
|
|
||||||
final top = LayoutCoordinateService.laneToY(
|
|
||||||
lane: entry.entry.lane,
|
|
||||||
laneHeight: laneHeight,
|
|
||||||
);
|
|
||||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
|
||||||
normalizedX: entry.startX,
|
|
||||||
contentWidth: contentWidth,
|
|
||||||
);
|
|
||||||
final width = LayoutCoordinateService.calculateItemWidth(
|
|
||||||
normalizedWidth: entry.widthX,
|
|
||||||
contentWidth: contentWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
final scope = ZTimelineScope.of(context);
|
|
||||||
|
|
||||||
// Wrap pill in a MouseRegion so hovering shows a pointer cursor.
|
|
||||||
// During an active entry drag, defer to the parent cursor (grabbing).
|
|
||||||
final cursorPill = ListenableBuilder(
|
|
||||||
listenable: scope.interaction,
|
|
||||||
builder: (context, child) => MouseRegion(
|
|
||||||
cursor: scope.interaction.isDraggingEntry
|
|
||||||
? MouseCursor.defer
|
|
||||||
: SystemMouseCursors.click,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
child: pill,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!enableDrag) {
|
|
||||||
return Positioned(
|
|
||||||
top: top,
|
|
||||||
left: left.clamp(0.0, double.infinity),
|
|
||||||
width: width.clamp(0.0, double.infinity),
|
|
||||||
height: laneHeight,
|
|
||||||
child: cursorPill,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Positioned(
|
|
||||||
top: top,
|
|
||||||
left: left.clamp(0.0, double.infinity),
|
|
||||||
width: width.clamp(0.0, double.infinity),
|
|
||||||
height: laneHeight,
|
|
||||||
child: Draggable<TimelineEntry>(
|
|
||||||
data: entry.entry,
|
|
||||||
onDragStarted: () {
|
|
||||||
scope.interaction.beginDrag(entry.entry);
|
|
||||||
},
|
|
||||||
onDraggableCanceled: (_, __) {
|
|
||||||
scope.interaction.cancelDrag();
|
|
||||||
},
|
|
||||||
onDragCompleted: () {
|
|
||||||
// Handled by DragTarget
|
|
||||||
},
|
|
||||||
feedback: DefaultTextStyle(
|
|
||||||
style: Theme.of(context).textTheme.labelMedium ?? const TextStyle(),
|
|
||||||
child: Opacity(
|
|
||||||
opacity: 0.8,
|
|
||||||
child: SizedBox(
|
|
||||||
width: width.clamp(0.0, double.infinity),
|
|
||||||
height: laneHeight,
|
|
||||||
child: pill,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
childWhenDragging: Opacity(opacity: 0.3, child: pill),
|
|
||||||
child: cursorPill,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPill(BuildContext context) {
|
|
||||||
final color = colorBuilder(entry.entry);
|
|
||||||
final onColor =
|
|
||||||
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
|
||||||
? Colors.white
|
|
||||||
: Colors.black87;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: ZTimelineConstants.pillPadding,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color,
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
ZTimelineConstants.pillBorderRadius,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
labelBuilder(entry.entry),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
color: onColor,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../state/timeline_interaction_notifier.dart';
|
||||||
|
import 'timeline_scope.dart';
|
||||||
|
|
||||||
|
/// Builder for full popover content shown on hover.
|
||||||
|
///
|
||||||
|
/// Return `null` to suppress the popover for a given entry.
|
||||||
|
typedef PopoverContentBuilder = Widget? Function(String entryId);
|
||||||
|
|
||||||
|
/// Builder for compact date labels shown during drag/resize.
|
||||||
|
typedef CompactDateBuilder = Widget Function(DateTime start, DateTime end);
|
||||||
|
|
||||||
|
/// Gap between the pill and the popover.
|
||||||
|
const double _kPopoverGap = 6.0;
|
||||||
|
|
||||||
|
/// Maximum popover width.
|
||||||
|
const double _kMaxPopoverWidth = 280.0;
|
||||||
|
|
||||||
|
/// Animation duration.
|
||||||
|
const Duration _kAnimationDuration = Duration(milliseconds: 120);
|
||||||
|
|
||||||
|
/// Wraps a child and manages a single [OverlayEntry] that shows either:
|
||||||
|
/// - A hover popover (full card) when the user hovers over a pill.
|
||||||
|
/// - A compact date popover near the cursor during drag/resize.
|
||||||
|
class EntryPopoverOverlay extends StatefulWidget {
|
||||||
|
const EntryPopoverOverlay({
|
||||||
|
required this.child,
|
||||||
|
this.popoverContentBuilder,
|
||||||
|
this.compactDateBuilder,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final PopoverContentBuilder? popoverContentBuilder;
|
||||||
|
final CompactDateBuilder? compactDateBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EntryPopoverOverlay> createState() => _EntryPopoverOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntryPopoverOverlayState extends State<EntryPopoverOverlay> {
|
||||||
|
OverlayEntry? _overlayEntry;
|
||||||
|
ZTimelineInteractionNotifier? _notifier;
|
||||||
|
|
||||||
|
// Track current mode to know when to rebuild.
|
||||||
|
String? _currentHoveredId;
|
||||||
|
bool _currentIsInteracting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final scope = ZTimelineScope.maybeOf(context);
|
||||||
|
final newNotifier = scope?.interaction;
|
||||||
|
|
||||||
|
if (newNotifier != _notifier) {
|
||||||
|
_notifier?.removeListener(_onInteractionChanged);
|
||||||
|
_notifier = newNotifier;
|
||||||
|
_notifier?.addListener(_onInteractionChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_notifier?.removeListener(_onInteractionChanged);
|
||||||
|
_removeOverlay();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInteractionChanged() {
|
||||||
|
final notifier = _notifier;
|
||||||
|
if (notifier == null) return;
|
||||||
|
|
||||||
|
final hoveredId = notifier.hoveredEntryId;
|
||||||
|
final isInteracting = notifier.isInteracting;
|
||||||
|
final interactionPos = notifier.interactionGlobalPosition;
|
||||||
|
|
||||||
|
// Determine what we should show.
|
||||||
|
final shouldShowHover =
|
||||||
|
hoveredId != null &&
|
||||||
|
!isInteracting &&
|
||||||
|
widget.popoverContentBuilder != null;
|
||||||
|
final shouldShowCompact =
|
||||||
|
isInteracting &&
|
||||||
|
interactionPos != null &&
|
||||||
|
widget.compactDateBuilder != null;
|
||||||
|
|
||||||
|
if (shouldShowHover) {
|
||||||
|
if (_currentHoveredId != hoveredId || _currentIsInteracting) {
|
||||||
|
_removeOverlay();
|
||||||
|
_currentHoveredId = hoveredId;
|
||||||
|
_currentIsInteracting = false;
|
||||||
|
_showHoverPopover(notifier);
|
||||||
|
}
|
||||||
|
} else if (shouldShowCompact) {
|
||||||
|
if (!_currentIsInteracting) {
|
||||||
|
_removeOverlay();
|
||||||
|
_currentIsInteracting = true;
|
||||||
|
_currentHoveredId = null;
|
||||||
|
_showCompactPopover(notifier);
|
||||||
|
} else {
|
||||||
|
// Just mark dirty to reposition.
|
||||||
|
_overlayEntry?.markNeedsBuild();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_overlayEntry != null) {
|
||||||
|
_removeOverlay();
|
||||||
|
_currentHoveredId = null;
|
||||||
|
_currentIsInteracting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showHoverPopover(ZTimelineInteractionNotifier notifier) {
|
||||||
|
_overlayEntry = OverlayEntry(
|
||||||
|
builder: (_) => _HoverPopover(
|
||||||
|
notifier: notifier,
|
||||||
|
contentBuilder: widget.popoverContentBuilder!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Overlay.of(context).insert(_overlayEntry!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCompactPopover(ZTimelineInteractionNotifier notifier) {
|
||||||
|
_overlayEntry = OverlayEntry(
|
||||||
|
builder: (_) => _CompactPopover(
|
||||||
|
notifier: notifier,
|
||||||
|
compactDateBuilder: widget.compactDateBuilder!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Overlay.of(context).insert(_overlayEntry!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeOverlay() {
|
||||||
|
_overlayEntry?.remove();
|
||||||
|
_overlayEntry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hover popover — positioned above the pill rect
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _HoverPopover extends StatelessWidget {
|
||||||
|
const _HoverPopover({required this.notifier, required this.contentBuilder});
|
||||||
|
|
||||||
|
final ZTimelineInteractionNotifier notifier;
|
||||||
|
final PopoverContentBuilder contentBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entryId = notifier.hoveredEntryId;
|
||||||
|
final pillRect = notifier.hoveredPillGlobalRect;
|
||||||
|
if (entryId == null || pillRect == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final content = contentBuilder(entryId);
|
||||||
|
if (content == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return _AnimatedPopoverWrapper(
|
||||||
|
child: CustomSingleChildLayout(
|
||||||
|
delegate: _PopoverLayoutDelegate(pillRect: pillRect),
|
||||||
|
child: IgnorePointer(child: _PopoverCard(child: content)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compact popover — positioned near cursor during drag/resize
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _CompactPopover extends StatelessWidget {
|
||||||
|
const _CompactPopover({
|
||||||
|
required this.notifier,
|
||||||
|
required this.compactDateBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ZTimelineInteractionNotifier notifier;
|
||||||
|
final CompactDateBuilder compactDateBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pos = notifier.interactionGlobalPosition;
|
||||||
|
if (pos == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Determine dates from drag or resize state.
|
||||||
|
DateTime? start;
|
||||||
|
DateTime? end;
|
||||||
|
|
||||||
|
if (notifier.dragState case final drag?) {
|
||||||
|
start = drag.targetStart;
|
||||||
|
final duration = drag.originalEntry.end.difference(
|
||||||
|
drag.originalEntry.start,
|
||||||
|
);
|
||||||
|
end = drag.targetStart.add(duration);
|
||||||
|
} else if (notifier.resizeState case final resize?) {
|
||||||
|
start = resize.targetStart;
|
||||||
|
end = resize.targetEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start == null || end == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return _AnimatedPopoverWrapper(
|
||||||
|
child: CustomSingleChildLayout(
|
||||||
|
delegate: _CursorPopoverDelegate(cursorPosition: pos),
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: _PopoverCard(
|
||||||
|
compact: true,
|
||||||
|
child: compactDateBuilder(start, end),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layout delegates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Positions the popover above the pill, flipping below if not enough room.
|
||||||
|
/// Clamps horizontally to screen bounds.
|
||||||
|
class _PopoverLayoutDelegate extends SingleChildLayoutDelegate {
|
||||||
|
_PopoverLayoutDelegate({required this.pillRect});
|
||||||
|
|
||||||
|
final Rect pillRect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||||
|
return BoxConstraints(
|
||||||
|
maxWidth: math.min(_kMaxPopoverWidth, constraints.maxWidth - 16),
|
||||||
|
maxHeight: constraints.maxHeight * 0.4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Offset getPositionForChild(Size size, Size childSize) {
|
||||||
|
// Try above first.
|
||||||
|
final aboveY = pillRect.top - _kPopoverGap - childSize.height;
|
||||||
|
final belowY = pillRect.bottom + _kPopoverGap;
|
||||||
|
final y = aboveY >= 8 ? aboveY : belowY;
|
||||||
|
|
||||||
|
// Center on pill horizontally, clamped to screen.
|
||||||
|
final idealX = pillRect.center.dx - childSize.width / 2;
|
||||||
|
final x = idealX.clamp(8.0, size.width - childSize.width - 8);
|
||||||
|
|
||||||
|
return Offset(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRelayout(_PopoverLayoutDelegate oldDelegate) {
|
||||||
|
return pillRect != oldDelegate.pillRect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Positions the compact date label above the cursor.
|
||||||
|
class _CursorPopoverDelegate extends SingleChildLayoutDelegate {
|
||||||
|
_CursorPopoverDelegate({required this.cursorPosition});
|
||||||
|
|
||||||
|
final Offset cursorPosition;
|
||||||
|
|
||||||
|
@override
|
||||||
|
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||||
|
return BoxConstraints(
|
||||||
|
maxWidth: math.min(_kMaxPopoverWidth, constraints.maxWidth - 16),
|
||||||
|
maxHeight: 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Offset getPositionForChild(Size size, Size childSize) {
|
||||||
|
const offsetAbove = 24.0;
|
||||||
|
final idealX = cursorPosition.dx - childSize.width / 2;
|
||||||
|
final x = idealX.clamp(8.0, size.width - childSize.width - 8);
|
||||||
|
final y = (cursorPosition.dy - offsetAbove - childSize.height).clamp(
|
||||||
|
8.0,
|
||||||
|
size.height - childSize.height - 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Offset(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRelayout(_CursorPopoverDelegate oldDelegate) {
|
||||||
|
return cursorPosition != oldDelegate.cursorPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared card styling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _PopoverCard extends StatelessWidget {
|
||||||
|
const _PopoverCard({required this.child, this.compact = false});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: compact
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 10, vertical: 6)
|
||||||
|
: const EdgeInsets.all(12),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Animation wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _AnimatedPopoverWrapper extends StatelessWidget {
|
||||||
|
const _AnimatedPopoverWrapper({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0.0, end: 1.0),
|
||||||
|
duration: _kAnimationDuration,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: value,
|
||||||
|
child: Transform.scale(scale: 0.95 + 0.05 * value, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widget_previews.dart';
|
||||||
|
import '../constants.dart';
|
||||||
|
|
||||||
|
/// A standalone event pill widget with a tinted gradient background,
|
||||||
|
/// colored border, indicator dot, and an outer selection ring.
|
||||||
|
class EventPill extends StatefulWidget {
|
||||||
|
const EventPill({
|
||||||
|
required this.color,
|
||||||
|
required this.label,
|
||||||
|
this.isSelected = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventPill> createState() => _EventPillState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventPillState extends State<EventPill> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = widget.color;
|
||||||
|
final isSelected = widget.isSelected;
|
||||||
|
|
||||||
|
final double bgAlphaStart =
|
||||||
|
isSelected ? 0.24 : _isHovered ? 0.18 : 0.08;
|
||||||
|
final double bgAlphaEnd =
|
||||||
|
isSelected ? 0.34 : _isHovered ? 0.28 : 0.16;
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: Container(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
padding: ZTimelineConstants.pillPadding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
color.withValues(alpha: bgAlphaStart),
|
||||||
|
color.withValues(alpha: bgAlphaEnd),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: color,
|
||||||
|
width: ZTimelineConstants.pillInnerBorderWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth < 20) return const SizedBox.shrink();
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (isSelected)
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: color),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name: 'Event pill', brightness: Brightness.light)
|
||||||
|
@Preview(name: 'Event pill', brightness: Brightness.dark)
|
||||||
|
Widget eventPillPreview() =>
|
||||||
|
EventPill(color: Colors.blue, label: 'Sample Event', isSelected: false);
|
||||||
|
|
||||||
|
@Preview(name: 'Event pill - selected', brightness: Brightness.light)
|
||||||
|
@Preview(name: 'Event pill - selected', brightness: Brightness.dark)
|
||||||
|
Widget eventPillSelectedPreview() =>
|
||||||
|
EventPill(color: Colors.blue, label: 'Sample Event', isSelected: true);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../constants.dart';
|
||||||
|
|
||||||
|
/// A point-event widget: small circle with a text label to its right.
|
||||||
|
///
|
||||||
|
/// Used for entries with [TimelineEntry.hasEnd] == false.
|
||||||
|
class EventPoint extends StatelessWidget {
|
||||||
|
const EventPoint({
|
||||||
|
required this.color,
|
||||||
|
required this.label,
|
||||||
|
this.maxTextWidth,
|
||||||
|
this.isSelected = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
|
/// Maximum width for the label text. When provided, text truncates with
|
||||||
|
/// ellipsis. When null the text sizes naturally.
|
||||||
|
final double? maxTextWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final onColor =
|
||||||
|
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black87;
|
||||||
|
|
||||||
|
final textWidget = Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: onColor,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final showText = maxTextWidth == null || maxTextWidth! > 0;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: ZTimelineConstants.pointEventCircleDiameter,
|
||||||
|
height: ZTimelineConstants.pointEventCircleDiameter,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showText) ...[
|
||||||
|
const SizedBox(width: ZTimelineConstants.pointEventCircleTextGap),
|
||||||
|
if (maxTextWidth != null)
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: maxTextWidth!),
|
||||||
|
child: textWidget,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
textWidget,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../models/projected_entry.dart';
|
|
||||||
import '../models/timeline_entry.dart';
|
|
||||||
import '../models/timeline_group.dart';
|
|
||||||
import '../services/entry_placement_service.dart';
|
|
||||||
import '../services/layout_coordinate_service.dart';
|
|
||||||
import '../services/time_scale_service.dart';
|
|
||||||
import '../state/timeline_viewport_notifier.dart';
|
|
||||||
import 'timeline_scope.dart';
|
|
||||||
import 'timeline_view.dart';
|
|
||||||
|
|
||||||
/// A drop target wrapper for a timeline group.
|
|
||||||
///
|
|
||||||
/// Wraps group lanes content and handles drag-and-drop operations.
|
|
||||||
/// The ghost overlay is rendered by the parent widget in the same Stack.
|
|
||||||
class GroupDropTarget extends StatelessWidget {
|
|
||||||
const GroupDropTarget({
|
|
||||||
required this.group,
|
|
||||||
required this.entries,
|
|
||||||
required this.allEntries,
|
|
||||||
required this.viewport,
|
|
||||||
required this.contentWidth,
|
|
||||||
required this.laneHeight,
|
|
||||||
required this.lanesCount,
|
|
||||||
required this.onEntryMoved,
|
|
||||||
required this.child,
|
|
||||||
this.verticalOffset = 0.0,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TimelineGroup group;
|
|
||||||
final List<ProjectedEntry> entries;
|
|
||||||
final List<TimelineEntry> allEntries;
|
|
||||||
final TimelineViewportNotifier viewport;
|
|
||||||
final double contentWidth;
|
|
||||||
final double laneHeight;
|
|
||||||
final int lanesCount;
|
|
||||||
final OnEntryMoved? onEntryMoved;
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
/// Vertical offset from the top of this widget to the top of the lanes area.
|
|
||||||
/// Used to correctly map pointer y-coordinates to lanes when this target
|
|
||||||
/// wraps content above the lanes (e.g. group headers).
|
|
||||||
final double verticalOffset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scope = ZTimelineScope.of(context);
|
|
||||||
|
|
||||||
return DragTarget<TimelineEntry>(
|
|
||||||
builder: (context, candidateData, rejectedData) {
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
onWillAcceptWithDetails: (details) => true,
|
|
||||||
onMove: (details) {
|
|
||||||
final renderBox = context.findRenderObject() as RenderBox?;
|
|
||||||
if (renderBox == null) return;
|
|
||||||
|
|
||||||
final local = renderBox.globalToLocal(details.offset);
|
|
||||||
|
|
||||||
// Use centralized coordinate service for consistent transformations
|
|
||||||
final ratio = LayoutCoordinateService.widgetXToNormalized(
|
|
||||||
widgetX: local.dx,
|
|
||||||
contentWidth: contentWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
final targetStart = TimeScaleService.mapPositionToTime(
|
|
||||||
ratio,
|
|
||||||
viewport.start,
|
|
||||||
viewport.end,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Adjust y to account for content above the lanes (e.g. group header)
|
|
||||||
final adjustedY = local.dy - verticalOffset;
|
|
||||||
|
|
||||||
final rawLane = LayoutCoordinateService.yToLane(
|
|
||||||
y: adjustedY,
|
|
||||||
laneHeight: laneHeight,
|
|
||||||
);
|
|
||||||
final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1;
|
|
||||||
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
|
||||||
|
|
||||||
// Resolve with collision avoidance
|
|
||||||
final resolved = EntryPlacementService.resolvePlacement(
|
|
||||||
entry: details.data,
|
|
||||||
targetGroupId: group.id,
|
|
||||||
targetLane: targetLane,
|
|
||||||
targetStart: targetStart,
|
|
||||||
existingEntries: allEntries,
|
|
||||||
);
|
|
||||||
|
|
||||||
scope.interaction.updateDragTarget(
|
|
||||||
targetGroupId: group.id,
|
|
||||||
targetLane: resolved.lane,
|
|
||||||
targetStart: targetStart,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onAcceptWithDetails: (details) {
|
|
||||||
final dragState = scope.interaction.dragState;
|
|
||||||
if (dragState == null || onEntryMoved == null) {
|
|
||||||
scope.interaction.endDrag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final resolved = EntryPlacementService.resolvePlacement(
|
|
||||||
entry: details.data,
|
|
||||||
targetGroupId: dragState.targetGroupId,
|
|
||||||
targetLane: dragState.targetLane,
|
|
||||||
targetStart: dragState.targetStart,
|
|
||||||
existingEntries: allEntries,
|
|
||||||
);
|
|
||||||
|
|
||||||
onEntryMoved!(
|
|
||||||
details.data,
|
|
||||||
dragState.targetStart,
|
|
||||||
dragState.targetGroupId,
|
|
||||||
resolved.lane,
|
|
||||||
);
|
|
||||||
|
|
||||||
scope.interaction.endDrag();
|
|
||||||
},
|
|
||||||
onLeave: (data) {
|
|
||||||
// Don't clear on leave - entry might move to another group
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../constants.dart';
|
||||||
|
import '../models/entry_resize_state.dart';
|
||||||
|
import '../models/timeline_entry.dart';
|
||||||
|
import '../services/entry_placement_service.dart';
|
||||||
|
import '../services/layout_coordinate_service.dart';
|
||||||
|
import '../services/time_scale_service.dart';
|
||||||
|
import '../services/timeline_group_registry.dart';
|
||||||
|
import '../state/timeline_viewport_notifier.dart';
|
||||||
|
import 'event_pill.dart';
|
||||||
|
import 'event_point.dart';
|
||||||
|
import 'timeline_scope.dart';
|
||||||
|
import 'timeline_view.dart';
|
||||||
|
|
||||||
|
/// Interaction mode determined on pan-down.
|
||||||
|
enum _InteractionMode { resizeStart, resizeEnd, drag }
|
||||||
|
|
||||||
|
/// An interactive event pill that handles both resize and drag-move
|
||||||
|
/// with a single [GestureDetector] using pan gestures.
|
||||||
|
///
|
||||||
|
/// Each pill listens to the [viewport] internally via [AnimatedBuilder],
|
||||||
|
/// so only the [Positioned] wrapper updates during pan — the visual
|
||||||
|
/// content ([EventPill] / [EventPoint]) is in the `child:` slot and
|
||||||
|
/// is NOT rebuilt on every frame.
|
||||||
|
class InteractiveEventPill extends StatefulWidget {
|
||||||
|
const InteractiveEventPill({
|
||||||
|
required this.entry,
|
||||||
|
required this.laneHeight,
|
||||||
|
required this.labelBuilder,
|
||||||
|
required this.colorBuilder,
|
||||||
|
required this.contentWidth,
|
||||||
|
required this.enableDrag,
|
||||||
|
required this.viewport,
|
||||||
|
this.allEntries = const [],
|
||||||
|
this.onEntryResized,
|
||||||
|
this.onEntryMoved,
|
||||||
|
this.onEntrySelected,
|
||||||
|
this.selectedEntryId,
|
||||||
|
this.groupRegistry,
|
||||||
|
this.nextEntryInLane,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineEntry entry;
|
||||||
|
final double laneHeight;
|
||||||
|
final EntryLabelBuilder labelBuilder;
|
||||||
|
final EntryColorBuilder colorBuilder;
|
||||||
|
final double contentWidth;
|
||||||
|
final bool enableDrag;
|
||||||
|
final TimelineViewportNotifier viewport;
|
||||||
|
final List<TimelineEntry> allEntries;
|
||||||
|
final OnEntryResized? onEntryResized;
|
||||||
|
final OnEntryMoved? onEntryMoved;
|
||||||
|
final OnEntrySelected? onEntrySelected;
|
||||||
|
final String? selectedEntryId;
|
||||||
|
final TimelineGroupRegistry? groupRegistry;
|
||||||
|
|
||||||
|
/// Next entry in the same lane, used to compute available width for
|
||||||
|
/// point events.
|
||||||
|
final TimelineEntry? nextEntryInLane;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InteractiveEventPill> createState() => _InteractiveEventPillState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||||
|
final _pointInteractionKey = GlobalKey();
|
||||||
|
_InteractionMode? _mode;
|
||||||
|
double _cumulativeDx = 0;
|
||||||
|
|
||||||
|
/// Normalized offset from the entry's start to the grab point,
|
||||||
|
/// so dragging doesn't snap the entry start to the cursor.
|
||||||
|
double _grabOffsetNormalized = 0;
|
||||||
|
|
||||||
|
/// Compute pill width from an entry's start/end mapped through the current
|
||||||
|
/// viewport. Shared by [_currentPillWidth] and [_buildRangeEntry] to avoid
|
||||||
|
/// drift between the two code paths.
|
||||||
|
double _pillWidthFromEntry(TimelineEntry entry) {
|
||||||
|
final startX = TimeScaleService.mapTimeToPosition(
|
||||||
|
entry.start,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
final endX = TimeScaleService.mapTimeToPosition(
|
||||||
|
entry.end,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
return LayoutCoordinateService.calculateItemWidth(
|
||||||
|
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
|
||||||
|
contentWidth: widget.contentWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute current pill width from the raw entry + viewport.
|
||||||
|
double get _currentPillWidth => _pillWidthFromEntry(widget.entry);
|
||||||
|
|
||||||
|
_InteractionMode _determineMode(Offset localPosition) {
|
||||||
|
if (!widget.entry.hasEnd) return _InteractionMode.drag;
|
||||||
|
|
||||||
|
final pillWidth = _currentPillWidth;
|
||||||
|
const handleWidth = ZTimelineConstants.resizeHandleWidth;
|
||||||
|
|
||||||
|
if (widget.onEntryResized != null && pillWidth >= 16) {
|
||||||
|
if (localPosition.dx <= handleWidth) {
|
||||||
|
return _InteractionMode.resizeStart;
|
||||||
|
}
|
||||||
|
if (localPosition.dx >= pillWidth - handleWidth) {
|
||||||
|
return _InteractionMode.resizeEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _InteractionMode.drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanDown(DragDownDetails details) {
|
||||||
|
_mode = _determineMode(details.localPosition);
|
||||||
|
_grabOffsetNormalized = details.localPosition.dx / widget.contentWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanStart(DragStartDetails details) {
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
final entry = widget.entry;
|
||||||
|
|
||||||
|
_cumulativeDx = 0;
|
||||||
|
|
||||||
|
// Clear hover as safety net when drag/resize begins.
|
||||||
|
scope.interaction.clearHoveredEntry();
|
||||||
|
|
||||||
|
switch (_mode!) {
|
||||||
|
case _InteractionMode.resizeStart:
|
||||||
|
scope.interaction.beginResize(entry, ResizeEdge.start);
|
||||||
|
case _InteractionMode.resizeEnd:
|
||||||
|
scope.interaction.beginResize(entry, ResizeEdge.end);
|
||||||
|
case _InteractionMode.drag:
|
||||||
|
scope.interaction.beginDrag(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanUpdate(DragUpdateDetails details) {
|
||||||
|
switch (_mode!) {
|
||||||
|
case _InteractionMode.resizeStart:
|
||||||
|
case _InteractionMode.resizeEnd:
|
||||||
|
_handleResizeUpdate(details);
|
||||||
|
case _InteractionMode.drag:
|
||||||
|
_handleDragUpdate(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleResizeUpdate(DragUpdateDetails details) {
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
scope.interaction.updateInteractionPosition(details.globalPosition);
|
||||||
|
_cumulativeDx += details.delta.dx;
|
||||||
|
|
||||||
|
final deltaNormalized = _cumulativeDx / widget.contentWidth;
|
||||||
|
final originalEntry = widget.entry;
|
||||||
|
final edge = _mode == _InteractionMode.resizeStart
|
||||||
|
? ResizeEdge.start
|
||||||
|
: ResizeEdge.end;
|
||||||
|
|
||||||
|
DateTime newStart = originalEntry.start;
|
||||||
|
DateTime newEnd = originalEntry.end;
|
||||||
|
|
||||||
|
if (edge == ResizeEdge.start) {
|
||||||
|
final originalStartNorm = TimeScaleService.mapTimeToPosition(
|
||||||
|
originalEntry.start,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
newStart = TimeScaleService.mapPositionToTime(
|
||||||
|
originalStartNorm + deltaNormalized,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
final maxStart = newEnd.subtract(ZTimelineConstants.minResizeDuration);
|
||||||
|
if (newStart.isAfter(maxStart)) {
|
||||||
|
newStart = maxStart;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final originalEndNorm = TimeScaleService.mapTimeToPosition(
|
||||||
|
originalEntry.end,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
newEnd = TimeScaleService.mapPositionToTime(
|
||||||
|
originalEndNorm + deltaNormalized,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
final minEnd = newStart.add(ZTimelineConstants.minResizeDuration);
|
||||||
|
if (newEnd.isBefore(minEnd)) {
|
||||||
|
newEnd = minEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolvedLane = EntryPlacementService.findNearestAvailableLane(
|
||||||
|
entryId: originalEntry.id,
|
||||||
|
targetGroupId: originalEntry.groupId,
|
||||||
|
targetLane: originalEntry.lane,
|
||||||
|
targetStart: newStart,
|
||||||
|
targetEnd: newEnd,
|
||||||
|
existingEntries: widget.allEntries,
|
||||||
|
);
|
||||||
|
|
||||||
|
scope.interaction.updateResizeTarget(
|
||||||
|
targetStart: newStart,
|
||||||
|
targetEnd: newEnd,
|
||||||
|
targetLane: resolvedLane,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragUpdate(DragUpdateDetails details) {
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
scope.interaction.updateInteractionPosition(details.globalPosition);
|
||||||
|
final registry = widget.groupRegistry;
|
||||||
|
if (registry == null) return;
|
||||||
|
|
||||||
|
final hit = registry.hitTest(details.globalPosition);
|
||||||
|
if (hit == null) return;
|
||||||
|
|
||||||
|
final laneHeight = registry.laneHeightFor(hit.groupId) ?? widget.laneHeight;
|
||||||
|
final contentWidth =
|
||||||
|
registry.contentWidthFor(hit.groupId) ?? widget.contentWidth;
|
||||||
|
final lanesCount = registry.lanesCountFor(hit.groupId) ?? 1;
|
||||||
|
|
||||||
|
final ratio = LayoutCoordinateService.widgetXToNormalized(
|
||||||
|
widgetX: hit.localPosition.dx,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
final targetStart = TimeScaleService.mapPositionToTime(
|
||||||
|
ratio - _grabOffsetNormalized,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
|
||||||
|
final rawLane = LayoutCoordinateService.yToLane(
|
||||||
|
y: hit.localPosition.dy,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
);
|
||||||
|
final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1;
|
||||||
|
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
||||||
|
|
||||||
|
final resolved = EntryPlacementService.resolvePlacement(
|
||||||
|
entry: widget.entry,
|
||||||
|
targetGroupId: hit.groupId,
|
||||||
|
targetLane: targetLane,
|
||||||
|
targetStart: targetStart,
|
||||||
|
existingEntries: widget.allEntries,
|
||||||
|
);
|
||||||
|
|
||||||
|
scope.interaction.updateDragTarget(
|
||||||
|
targetGroupId: hit.groupId,
|
||||||
|
targetLane: resolved.lane,
|
||||||
|
targetStart: targetStart,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanEnd(DragEndDetails details) {
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
|
||||||
|
switch (_mode!) {
|
||||||
|
case _InteractionMode.resizeStart:
|
||||||
|
case _InteractionMode.resizeEnd:
|
||||||
|
final resizeState = scope.interaction.resizeState;
|
||||||
|
if (resizeState != null && widget.onEntryResized != null) {
|
||||||
|
widget.onEntryResized!(
|
||||||
|
resizeState.originalEntry,
|
||||||
|
resizeState.targetStart,
|
||||||
|
resizeState.targetEnd,
|
||||||
|
resizeState.targetLane,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scope.interaction.endResize();
|
||||||
|
case _InteractionMode.drag:
|
||||||
|
final dragState = scope.interaction.dragState;
|
||||||
|
if (dragState != null && widget.onEntryMoved != null) {
|
||||||
|
widget.onEntryMoved!(
|
||||||
|
dragState.originalEntry,
|
||||||
|
dragState.targetStart,
|
||||||
|
dragState.targetGroupId,
|
||||||
|
dragState.targetLane,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scope.interaction.endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
_mode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanCancel() {
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
|
||||||
|
switch (_mode) {
|
||||||
|
case _InteractionMode.resizeStart:
|
||||||
|
case _InteractionMode.resizeEnd:
|
||||||
|
scope.interaction.cancelResize();
|
||||||
|
case _InteractionMode.drag:
|
||||||
|
scope.interaction.cancelDrag();
|
||||||
|
case null:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entry = widget.entry;
|
||||||
|
final isPoint = !entry.hasEnd;
|
||||||
|
final color = widget.colorBuilder(entry);
|
||||||
|
final label = widget.labelBuilder(entry);
|
||||||
|
final isSelected = entry.id == widget.selectedEntryId;
|
||||||
|
|
||||||
|
final top = LayoutCoordinateService.laneToY(
|
||||||
|
lane: entry.lane,
|
||||||
|
laneHeight: widget.laneHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPoint) {
|
||||||
|
return _buildPointEntry(entry, color, label, isSelected, top);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildRangeEntry(entry, color, label, isSelected, top);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Range event: EventPill goes in the AnimatedBuilder child: slot
|
||||||
|
/// and is NOT rebuilt during pan — only the Positioned wrapper updates.
|
||||||
|
Widget _buildRangeEntry(
|
||||||
|
TimelineEntry entry,
|
||||||
|
Color color,
|
||||||
|
String label,
|
||||||
|
bool isSelected,
|
||||||
|
double top,
|
||||||
|
) {
|
||||||
|
final pill = EventPill(color: color, label: label, isSelected: isSelected);
|
||||||
|
final hasResizeHandles =
|
||||||
|
entry.hasEnd && widget.onEntryResized != null;
|
||||||
|
|
||||||
|
Widget content;
|
||||||
|
if (!widget.enableDrag) {
|
||||||
|
content = pill;
|
||||||
|
} else {
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
content = ListenableBuilder(
|
||||||
|
listenable: scope.interaction,
|
||||||
|
builder: (context, child) {
|
||||||
|
final entryId = entry.id;
|
||||||
|
final isDragging =
|
||||||
|
scope.interaction.isDraggingEntry &&
|
||||||
|
scope.interaction.dragState?.entryId == entryId;
|
||||||
|
final isResizing =
|
||||||
|
scope.interaction.isResizingEntry &&
|
||||||
|
scope.interaction.resizeState?.entryId == entryId;
|
||||||
|
|
||||||
|
return Opacity(
|
||||||
|
opacity: isDragging || isResizing ? 0.3 : 1.0,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _buildInteractivePill(pill, hasResizeHandles),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: widget.viewport,
|
||||||
|
builder: (context, child) {
|
||||||
|
final startX = TimeScaleService.mapTimeToPosition(
|
||||||
|
entry.start,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
|
normalizedX: startX,
|
||||||
|
contentWidth: widget.contentWidth,
|
||||||
|
);
|
||||||
|
final width = _pillWidthFromEntry(entry);
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: top,
|
||||||
|
left: left,
|
||||||
|
width: width,
|
||||||
|
height: widget.laneHeight,
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point event: maxTextWidth depends on viewport, so EventPoint is
|
||||||
|
/// built inside the AnimatedBuilder. Point events are typically few,
|
||||||
|
/// so this has negligible cost.
|
||||||
|
Widget _buildPointEntry(
|
||||||
|
TimelineEntry entry,
|
||||||
|
Color color,
|
||||||
|
String label,
|
||||||
|
bool isSelected,
|
||||||
|
double top,
|
||||||
|
) {
|
||||||
|
// For point events we need to rebuild EventPoint in the builder because
|
||||||
|
// maxTextWidth depends on the viewport position of the next entry.
|
||||||
|
//
|
||||||
|
// Hoist scope lookup before AnimatedBuilder to avoid re-looking it up on
|
||||||
|
// every animation frame (InheritedWidget lookup is cheap but unnecessary).
|
||||||
|
final scope = widget.enableDrag ? ZTimelineScope.of(context) : null;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: widget.viewport,
|
||||||
|
builder: (context, _) {
|
||||||
|
final startX = TimeScaleService.mapTimeToPosition(
|
||||||
|
entry.start,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
double? nextStartX;
|
||||||
|
if (widget.nextEntryInLane != null) {
|
||||||
|
nextStartX = TimeScaleService.mapTimeToPosition(
|
||||||
|
widget.nextEntryInLane!.start,
|
||||||
|
widget.viewport.start,
|
||||||
|
widget.viewport.end,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
|
normalizedX: startX,
|
||||||
|
contentWidth: widget.contentWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
final availableWidth = nextStartX != null
|
||||||
|
? (nextStartX - startX) * widget.contentWidth
|
||||||
|
: (1.0 - startX) * widget.contentWidth;
|
||||||
|
|
||||||
|
final maxTextWidth = availableWidth -
|
||||||
|
ZTimelineConstants.pointEventCircleDiameter -
|
||||||
|
ZTimelineConstants.pointEventCircleTextGap -
|
||||||
|
ZTimelineConstants.pointEventTextGap;
|
||||||
|
|
||||||
|
final pointWidget = EventPoint(
|
||||||
|
color: color,
|
||||||
|
label: label,
|
||||||
|
maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0,
|
||||||
|
isSelected: isSelected,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!widget.enableDrag) {
|
||||||
|
return Positioned(
|
||||||
|
top: top,
|
||||||
|
left: left,
|
||||||
|
width: availableWidth.clamp(
|
||||||
|
ZTimelineConstants.pointEventCircleDiameter,
|
||||||
|
double.infinity,
|
||||||
|
),
|
||||||
|
height: widget.laneHeight,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: pointWidget,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: top,
|
||||||
|
left: left,
|
||||||
|
width: availableWidth.clamp(
|
||||||
|
ZTimelineConstants.pointEventCircleDiameter,
|
||||||
|
double.infinity,
|
||||||
|
),
|
||||||
|
height: widget.laneHeight,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: scope!.interaction,
|
||||||
|
builder: (context, child) {
|
||||||
|
final entryId = entry.id;
|
||||||
|
final isDragging = scope!.interaction.isDraggingEntry &&
|
||||||
|
scope!.interaction.dragState?.entryId == entryId;
|
||||||
|
|
||||||
|
return Opacity(
|
||||||
|
opacity: isDragging ? 0.3 : 1.0,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _buildInteractivePoint(pointWidget),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoverEnter() {
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null || !box.attached) return;
|
||||||
|
final topLeft = box.localToGlobal(Offset.zero);
|
||||||
|
final rect = topLeft & box.size;
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
scope.interaction.setHoveredEntry(widget.entry.id, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoverExit() {
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
scope.interaction.clearHoveredEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointHoverEnter() {
|
||||||
|
final box =
|
||||||
|
_pointInteractionKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null || !box.attached) return;
|
||||||
|
final topLeft = box.localToGlobal(Offset.zero);
|
||||||
|
final rect = topLeft & box.size;
|
||||||
|
final scope = ZTimelineScope.of(context);
|
||||||
|
scope.interaction.setHoveredEntry(widget.entry.id, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInteractivePoint(Widget pointWidget) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: MouseRegion(
|
||||||
|
key: _pointInteractionKey,
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
opaque: false,
|
||||||
|
hitTestBehavior: HitTestBehavior.deferToChild,
|
||||||
|
onEnter: (_) => _onPointHoverEnter(),
|
||||||
|
onExit: (_) => _onHoverExit(),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => widget.onEntrySelected?.call(widget.entry),
|
||||||
|
onPanDown: _onPanDown,
|
||||||
|
onPanStart: _onPanStart,
|
||||||
|
onPanUpdate: _onPanUpdate,
|
||||||
|
onPanEnd: _onPanEnd,
|
||||||
|
onPanCancel: _onPanCancel,
|
||||||
|
child: pointWidget,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInteractivePill(Widget pill, bool hasResizeHandles) {
|
||||||
|
return MouseRegion(
|
||||||
|
opaque: false,
|
||||||
|
hitTestBehavior: HitTestBehavior.deferToChild,
|
||||||
|
onEnter: (_) => _onHoverEnter(),
|
||||||
|
onExit: (_) => _onHoverExit(),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => widget.onEntrySelected?.call(widget.entry),
|
||||||
|
onPanDown: _onPanDown,
|
||||||
|
onPanStart: _onPanStart,
|
||||||
|
onPanUpdate: _onPanUpdate,
|
||||||
|
onPanEnd: _onPanEnd,
|
||||||
|
onPanCancel: _onPanCancel,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
pill,
|
||||||
|
// Left resize cursor zone
|
||||||
|
if (hasResizeHandles)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: ZTimelineConstants.resizeHandleWidth,
|
||||||
|
child: const MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeColumn,
|
||||||
|
opaque: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Right resize cursor zone
|
||||||
|
if (hasResizeHandles)
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: ZTimelineConstants.resizeHandleWidth,
|
||||||
|
child: const MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeColumn,
|
||||||
|
opaque: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Center click cursor zone (between handles)
|
||||||
|
if (hasResizeHandles)
|
||||||
|
Positioned(
|
||||||
|
left: ZTimelineConstants.resizeHandleWidth,
|
||||||
|
right: ZTimelineConstants.resizeHandleWidth,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: const MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
opaque: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Positioned.fill(
|
||||||
|
child: const MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
opaque: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,10 +98,7 @@ class ZTimelineBreadcrumb extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BreadcrumbSegmentRow extends StatelessWidget {
|
class _BreadcrumbSegmentRow extends StatelessWidget {
|
||||||
const _BreadcrumbSegmentRow({
|
const _BreadcrumbSegmentRow({required this.segments, required this.viewport});
|
||||||
required this.segments,
|
|
||||||
required this.viewport,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<BreadcrumbSegment> segments;
|
final List<BreadcrumbSegment> segments;
|
||||||
final TimelineViewportNotifier viewport;
|
final TimelineViewportNotifier viewport;
|
||||||
@@ -173,9 +170,9 @@ class _ZoomLevelIndicator extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
level.label,
|
level.label,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(
|
||||||
color: colorScheme.onSurfaceVariant,
|
context,
|
||||||
),
|
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ZTimelineInteractor extends StatefulWidget {
|
|||||||
const ZTimelineInteractor({
|
const ZTimelineInteractor({
|
||||||
required this.child,
|
required this.child,
|
||||||
this.autofocus = true,
|
this.autofocus = true,
|
||||||
|
this.onBackgroundTap,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ class ZTimelineInteractor extends StatefulWidget {
|
|||||||
/// Whether to automatically focus this widget for keyboard input.
|
/// Whether to automatically focus this widget for keyboard input.
|
||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
|
|
||||||
|
/// Called when the user taps empty space (not on a pill/entry).
|
||||||
|
final VoidCallback? onBackgroundTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
|
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
|
||||||
}
|
}
|
||||||
@@ -88,7 +92,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
|||||||
// Single-finger pan
|
// Single-finger pan
|
||||||
else if (details.pointerCount == 1 &&
|
else if (details.pointerCount == 1 &&
|
||||||
config.enablePan &&
|
config.enablePan &&
|
||||||
!scope.interaction.isDraggingEntry) {
|
!scope.interaction.isInteracting) {
|
||||||
if (_lastFocalPoint != null) {
|
if (_lastFocalPoint != null) {
|
||||||
final diff = details.focalPoint - _lastFocalPoint!;
|
final diff = details.focalPoint - _lastFocalPoint!;
|
||||||
final ratio = -diff.dx / width;
|
final ratio = -diff.dx / width;
|
||||||
@@ -221,15 +225,17 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
|||||||
listenable: scope.interaction,
|
listenable: scope.interaction,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor:
|
cursor: scope.interaction.isResizingEntry
|
||||||
scope.interaction.isGrabbing ||
|
? SystemMouseCursors.resizeColumn
|
||||||
scope.interaction.isDraggingEntry
|
: scope.interaction.isGrabbing ||
|
||||||
|
scope.interaction.isDraggingEntry
|
||||||
? SystemMouseCursors.grabbing
|
? SystemMouseCursors.grabbing
|
||||||
: SystemMouseCursors.basic,
|
: SystemMouseCursors.basic,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
|
onTap: widget.onBackgroundTap,
|
||||||
onScaleStart: _handleScaleStart,
|
onScaleStart: _handleScaleStart,
|
||||||
onScaleUpdate: _handleScaleUpdate,
|
onScaleUpdate: _handleScaleUpdate,
|
||||||
onScaleEnd: _handleScaleEnd,
|
onScaleEnd: _handleScaleEnd,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/interaction_config.dart';
|
import '../models/interaction_config.dart';
|
||||||
|
import '../services/timeline_group_registry.dart';
|
||||||
import '../state/timeline_interaction_notifier.dart';
|
import '../state/timeline_interaction_notifier.dart';
|
||||||
import '../state/timeline_viewport_notifier.dart';
|
import '../state/timeline_viewport_notifier.dart';
|
||||||
|
|
||||||
@@ -61,11 +62,13 @@ class ZTimelineScope extends StatefulWidget {
|
|||||||
|
|
||||||
class _ZTimelineScopeState extends State<ZTimelineScope> {
|
class _ZTimelineScopeState extends State<ZTimelineScope> {
|
||||||
late final ZTimelineInteractionNotifier _interactionNotifier;
|
late final ZTimelineInteractionNotifier _interactionNotifier;
|
||||||
|
late final TimelineGroupRegistry _groupRegistry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_interactionNotifier = ZTimelineInteractionNotifier();
|
_interactionNotifier = ZTimelineInteractionNotifier();
|
||||||
|
_groupRegistry = TimelineGroupRegistry();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -81,6 +84,7 @@ class _ZTimelineScopeState extends State<ZTimelineScope> {
|
|||||||
viewport: widget.viewport,
|
viewport: widget.viewport,
|
||||||
interaction: _interactionNotifier,
|
interaction: _interactionNotifier,
|
||||||
config: widget.config,
|
config: widget.config,
|
||||||
|
groupRegistry: _groupRegistry,
|
||||||
),
|
),
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
);
|
);
|
||||||
@@ -105,6 +109,7 @@ class ZTimelineScopeData {
|
|||||||
required this.viewport,
|
required this.viewport,
|
||||||
required this.interaction,
|
required this.interaction,
|
||||||
required this.config,
|
required this.config,
|
||||||
|
required this.groupRegistry,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The viewport notifier for domain state (start/end times).
|
/// The viewport notifier for domain state (start/end times).
|
||||||
@@ -116,15 +121,19 @@ class ZTimelineScopeData {
|
|||||||
/// Configuration for interaction behavior.
|
/// Configuration for interaction behavior.
|
||||||
final ZTimelineInteractionConfig config;
|
final ZTimelineInteractionConfig config;
|
||||||
|
|
||||||
|
/// Registry for group lane areas, used for cross-group hit detection.
|
||||||
|
final TimelineGroupRegistry groupRegistry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
return other is ZTimelineScopeData &&
|
return other is ZTimelineScopeData &&
|
||||||
other.viewport == viewport &&
|
other.viewport == viewport &&
|
||||||
other.interaction == interaction &&
|
other.interaction == interaction &&
|
||||||
other.config == config;
|
other.config == config &&
|
||||||
|
other.groupRegistry == groupRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(viewport, interaction, config);
|
int get hashCode => Object.hash(viewport, interaction, config, groupRegistry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ class ZTimelineTieredHeader extends StatelessWidget {
|
|||||||
domainEnd: effectiveViewport.end,
|
domainEnd: effectiveViewport.end,
|
||||||
borderColor: Theme.of(context).colorScheme.outlineVariant,
|
borderColor: Theme.of(context).colorScheme.outlineVariant,
|
||||||
labelColor: Theme.of(context).colorScheme.onSurface,
|
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||||
secondaryLabelColor:
|
secondaryLabelColor: Theme.of(
|
||||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -126,9 +127,9 @@ class _ConfigIndicator extends StatelessWidget {
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Text(
|
child: Text(
|
||||||
'Zoom: $configName',
|
'Zoom: $configName',
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(
|
||||||
color: colorScheme.onSurfaceVariant,
|
context,
|
||||||
),
|
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,10 +156,9 @@ class _TieredHeaderPainter extends CustomPainter {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final borderPaint =
|
final borderPaint = Paint()
|
||||||
Paint()
|
..color = borderColor
|
||||||
..color = borderColor
|
..strokeWidth = 1.0;
|
||||||
..strokeWidth = 1.0;
|
|
||||||
|
|
||||||
// Draw horizontal border between tiers
|
// Draw horizontal border between tiers
|
||||||
canvas.drawLine(
|
canvas.drawLine(
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
import '../models/entry_drag_state.dart';
|
import '../models/entry_drag_state.dart';
|
||||||
import '../models/projected_entry.dart';
|
import '../models/entry_resize_state.dart';
|
||||||
import '../models/timeline_entry.dart';
|
import '../models/timeline_entry.dart';
|
||||||
import '../models/timeline_group.dart';
|
import '../models/timeline_group.dart';
|
||||||
import '../services/timeline_projection_service.dart';
|
|
||||||
import '../state/timeline_viewport_notifier.dart';
|
import '../state/timeline_viewport_notifier.dart';
|
||||||
import 'draggable_event_pill.dart';
|
import 'drag_preview.dart';
|
||||||
import 'ghost_overlay.dart';
|
import 'interactive_event_pill.dart';
|
||||||
import 'group_drop_target.dart';
|
|
||||||
import 'timeline_scope.dart';
|
import 'timeline_scope.dart';
|
||||||
|
|
||||||
typedef EntryLabelBuilder = String Function(TimelineEntry entry);
|
typedef EntryLabelBuilder = String Function(TimelineEntry entry);
|
||||||
@@ -24,8 +22,24 @@ typedef OnEntryMoved =
|
|||||||
int newLane,
|
int newLane,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Callback signature for when an entry is resized via edge drag.
|
||||||
|
typedef OnEntryResized =
|
||||||
|
void Function(
|
||||||
|
TimelineEntry entry,
|
||||||
|
DateTime newStart,
|
||||||
|
DateTime newEnd,
|
||||||
|
int newLane,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Callback signature for when an entry is tapped / selected.
|
||||||
|
typedef OnEntrySelected = void Function(TimelineEntry entry);
|
||||||
|
|
||||||
/// Base timeline view: renders groups with between-group headers and
|
/// Base timeline view: renders groups with between-group headers and
|
||||||
/// lane rows containing event pills.
|
/// lane rows containing event pills.
|
||||||
|
///
|
||||||
|
/// Viewport changes (pan/zoom) are handled by each pill's internal
|
||||||
|
/// [AnimatedBuilder] — the parent tree only rebuilds when [entries]
|
||||||
|
/// or [groups] change.
|
||||||
class ZTimelineView extends StatelessWidget {
|
class ZTimelineView extends StatelessWidget {
|
||||||
const ZTimelineView({
|
const ZTimelineView({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -37,7 +51,10 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
this.laneHeight = ZTimelineConstants.laneHeight,
|
this.laneHeight = ZTimelineConstants.laneHeight,
|
||||||
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
|
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
|
||||||
this.onEntryMoved,
|
this.onEntryMoved,
|
||||||
|
this.onEntryResized,
|
||||||
|
this.onEntrySelected,
|
||||||
this.enableDrag = true,
|
this.enableDrag = true,
|
||||||
|
this.selectedEntryId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TimelineGroup> groups;
|
final List<TimelineGroup> groups;
|
||||||
@@ -54,73 +71,85 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
/// position. The [newLane] is calculated to avoid collisions.
|
/// position. The [newLane] is calculated to avoid collisions.
|
||||||
final OnEntryMoved? onEntryMoved;
|
final OnEntryMoved? onEntryMoved;
|
||||||
|
|
||||||
|
/// Callback invoked when an entry is resized via edge drag.
|
||||||
|
final OnEntryResized? onEntryResized;
|
||||||
|
|
||||||
|
/// Callback invoked when an entry is tapped / selected.
|
||||||
|
final OnEntrySelected? onEntrySelected;
|
||||||
|
|
||||||
/// Whether drag-and-drop is enabled.
|
/// Whether drag-and-drop is enabled.
|
||||||
final bool enableDrag;
|
final bool enableDrag;
|
||||||
|
|
||||||
|
/// ID of the currently selected entry, if any.
|
||||||
|
final String? selectedEntryId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
// Group entries by groupId and sort by lane+start.
|
||||||
animation: viewport,
|
// Runs once per entries/groups change, NOT on every pan frame.
|
||||||
builder: (context, _) {
|
final grouped = <String, List<TimelineEntry>>{};
|
||||||
final projected = const TimelineProjectionService().project(
|
for (final e in entries) {
|
||||||
entries: entries,
|
(grouped[e.groupId] ??= []).add(e);
|
||||||
domainStart: viewport.start,
|
}
|
||||||
domainEnd: viewport.end,
|
for (final list in grouped.values) {
|
||||||
);
|
list.sort((a, b) {
|
||||||
|
final cmp = a.lane.compareTo(b.lane);
|
||||||
|
return cmp != 0 ? cmp : a.start.compareTo(b.start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return LayoutBuilder(
|
// Pre-compute next-entry-in-lane lookup in O(n).
|
||||||
builder: (context, constraints) {
|
final nextEntryInLane = <String, TimelineEntry>{};
|
||||||
final contentWidth = constraints.maxWidth.isFinite
|
for (final list in grouped.values) {
|
||||||
? constraints.maxWidth
|
final lastInLane = <int, TimelineEntry>{};
|
||||||
: ZTimelineConstants.minContentWidth;
|
for (final entry in list) {
|
||||||
|
final prev = lastInLane[entry.lane];
|
||||||
|
if (prev != null) {
|
||||||
|
nextEntryInLane[prev.id] = entry;
|
||||||
|
}
|
||||||
|
lastInLane[entry.lane] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return LayoutBuilder(
|
||||||
itemCount: groups.length,
|
builder: (context, constraints) {
|
||||||
itemBuilder: (context, index) {
|
final contentWidth = constraints.maxWidth.isFinite
|
||||||
final group = groups[index];
|
? constraints.maxWidth
|
||||||
final groupEntries =
|
: ZTimelineConstants.minContentWidth;
|
||||||
projected[group.id] ?? const <ProjectedEntry>[];
|
|
||||||
final lanesCount = _countLanes(groupEntries);
|
|
||||||
|
|
||||||
Widget groupColumn = Column(
|
return ListView.builder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
itemCount: groups.length,
|
||||||
children: [
|
itemBuilder: (context, index) {
|
||||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
final group = groups[index];
|
||||||
_GroupLanes(
|
final groupEntries =
|
||||||
group: group,
|
grouped[group.id] ?? const <TimelineEntry>[];
|
||||||
entries: groupEntries,
|
final lanesCount = _countLanes(groupEntries);
|
||||||
viewport: viewport,
|
|
||||||
lanesCount: lanesCount,
|
|
||||||
laneHeight: laneHeight,
|
|
||||||
colorBuilder: colorBuilder,
|
|
||||||
labelBuilder: labelBuilder,
|
|
||||||
contentWidth: contentWidth,
|
|
||||||
enableDrag: enableDrag,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wrap the entire group (header + lanes) in a DragTarget
|
return Column(
|
||||||
// so dragging over headers doesn't create a dead zone.
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
if (enableDrag && onEntryMoved != null) {
|
children: [
|
||||||
groupColumn = GroupDropTarget(
|
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||||
|
RepaintBoundary(
|
||||||
|
child: _GroupLanes(
|
||||||
group: group,
|
group: group,
|
||||||
entries: groupEntries,
|
entries: groupEntries,
|
||||||
allEntries: entries,
|
allEntries: entries,
|
||||||
viewport: viewport,
|
viewport: viewport,
|
||||||
contentWidth: contentWidth,
|
|
||||||
laneHeight: laneHeight,
|
|
||||||
lanesCount: lanesCount,
|
lanesCount: lanesCount,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
colorBuilder: colorBuilder,
|
||||||
|
labelBuilder: labelBuilder,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
enableDrag: enableDrag,
|
||||||
|
onEntryResized: onEntryResized,
|
||||||
onEntryMoved: onEntryMoved,
|
onEntryMoved: onEntryMoved,
|
||||||
verticalOffset:
|
onEntrySelected: onEntrySelected,
|
||||||
groupHeaderHeight +
|
selectedEntryId: selectedEntryId,
|
||||||
ZTimelineConstants.verticalOuterPadding,
|
nextEntryInLane: nextEntryInLane,
|
||||||
child: groupColumn,
|
groupHeaderHeight: groupHeaderHeight,
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
|
],
|
||||||
return groupColumn;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -128,10 +157,10 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _countLanes(List<ProjectedEntry> entries) {
|
int _countLanes(List<TimelineEntry> entries) {
|
||||||
var maxLane = 0;
|
var maxLane = 0;
|
||||||
for (final e in entries) {
|
for (final e in entries) {
|
||||||
if (e.entry.lane > maxLane) maxLane = e.entry.lane;
|
if (e.lane > maxLane) maxLane = e.lane;
|
||||||
}
|
}
|
||||||
return maxLane.clamp(0, 1000); // basic guard
|
return maxLane.clamp(0, 1000); // basic guard
|
||||||
}
|
}
|
||||||
@@ -162,10 +191,11 @@ class _GroupHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GroupLanes extends StatelessWidget {
|
class _GroupLanes extends StatefulWidget {
|
||||||
const _GroupLanes({
|
const _GroupLanes({
|
||||||
required this.group,
|
required this.group,
|
||||||
required this.entries,
|
required this.entries,
|
||||||
|
required this.allEntries,
|
||||||
required this.viewport,
|
required this.viewport,
|
||||||
required this.lanesCount,
|
required this.lanesCount,
|
||||||
required this.laneHeight,
|
required this.laneHeight,
|
||||||
@@ -173,10 +203,17 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
required this.colorBuilder,
|
required this.colorBuilder,
|
||||||
required this.contentWidth,
|
required this.contentWidth,
|
||||||
required this.enableDrag,
|
required this.enableDrag,
|
||||||
|
required this.nextEntryInLane,
|
||||||
|
required this.groupHeaderHeight,
|
||||||
|
this.onEntryResized,
|
||||||
|
this.onEntryMoved,
|
||||||
|
this.onEntrySelected,
|
||||||
|
this.selectedEntryId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TimelineGroup group;
|
final TimelineGroup group;
|
||||||
final List<ProjectedEntry> entries;
|
final List<TimelineEntry> entries;
|
||||||
|
final List<TimelineEntry> allEntries;
|
||||||
final TimelineViewportNotifier viewport;
|
final TimelineViewportNotifier viewport;
|
||||||
final int lanesCount;
|
final int lanesCount;
|
||||||
final double laneHeight;
|
final double laneHeight;
|
||||||
@@ -184,27 +221,108 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
final EntryColorBuilder colorBuilder;
|
final EntryColorBuilder colorBuilder;
|
||||||
final double contentWidth;
|
final double contentWidth;
|
||||||
final bool enableDrag;
|
final bool enableDrag;
|
||||||
|
final OnEntryResized? onEntryResized;
|
||||||
|
final OnEntryMoved? onEntryMoved;
|
||||||
|
final OnEntrySelected? onEntrySelected;
|
||||||
|
final String? selectedEntryId;
|
||||||
|
final Map<String, TimelineEntry> nextEntryInLane;
|
||||||
|
final double groupHeaderHeight;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_GroupLanes> createState() => _GroupLanesState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupLanesState extends State<_GroupLanes> {
|
||||||
|
final GlobalKey _lanesKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_registerGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_GroupLanes oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.group.id != widget.group.id) {
|
||||||
|
final scope = ZTimelineScope.maybeOf(context);
|
||||||
|
scope?.groupRegistry.unregister(oldWidget.group.id);
|
||||||
|
}
|
||||||
|
if (oldWidget.lanesCount != widget.lanesCount ||
|
||||||
|
oldWidget.laneHeight != widget.laneHeight ||
|
||||||
|
oldWidget.contentWidth != widget.contentWidth ||
|
||||||
|
oldWidget.group.id != widget.group.id) {
|
||||||
|
_registerGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final scope = ZTimelineScope.maybeOf(context);
|
||||||
|
scope?.groupRegistry.unregister(widget.group.id);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerGroup() {
|
||||||
|
final scope = ZTimelineScope.maybeOf(context);
|
||||||
|
if (scope == null || !widget.enableDrag) return;
|
||||||
|
|
||||||
|
scope.groupRegistry.register(
|
||||||
|
widget.group.id,
|
||||||
|
_lanesKey,
|
||||||
|
verticalOffset: ZTimelineConstants.verticalOuterPadding,
|
||||||
|
lanesCount: widget.lanesCount,
|
||||||
|
laneHeight: widget.laneHeight,
|
||||||
|
contentWidth: widget.contentWidth,
|
||||||
|
headerHeight: widget.groupHeaderHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scope = ZTimelineScope.maybeOf(context);
|
final scope = ZTimelineScope.maybeOf(context);
|
||||||
|
|
||||||
|
// Build pill widgets once per build() call (stable between interaction
|
||||||
|
// changes). When the ListenableBuilder fires, the same widget objects
|
||||||
|
// are reused — Flutter matches by identity and skips rebuilding.
|
||||||
|
final pillWidgets = <Widget>[
|
||||||
|
for (final entry in widget.entries)
|
||||||
|
InteractiveEventPill(
|
||||||
|
key: ValueKey(entry.id),
|
||||||
|
entry: entry,
|
||||||
|
laneHeight: widget.laneHeight,
|
||||||
|
labelBuilder: widget.labelBuilder,
|
||||||
|
colorBuilder: widget.colorBuilder,
|
||||||
|
contentWidth: widget.contentWidth,
|
||||||
|
enableDrag: widget.enableDrag,
|
||||||
|
viewport: widget.viewport,
|
||||||
|
allEntries: widget.allEntries,
|
||||||
|
onEntryResized: widget.onEntryResized,
|
||||||
|
onEntryMoved: widget.onEntryMoved,
|
||||||
|
onEntrySelected: widget.onEntrySelected,
|
||||||
|
selectedEntryId: widget.selectedEntryId,
|
||||||
|
groupRegistry: scope?.groupRegistry,
|
||||||
|
nextEntryInLane: widget.nextEntryInLane[entry.id],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
// If no scope (drag not enabled), use static height
|
// If no scope (drag not enabled), use static height
|
||||||
if (scope == null || !enableDrag) {
|
if (scope == null || !widget.enableDrag) {
|
||||||
return _buildContent(context, lanesCount);
|
return _wrapContent(pillWidgets, widget.lanesCount, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen to interaction notifier for drag state changes
|
// Listen to interaction only for height changes + drag preview.
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: scope.interaction,
|
listenable: scope.interaction,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final effectiveLanesCount = _calculateEffectiveLanesCount(
|
final effectiveLanesCount = _calculateEffectiveLanesCount(
|
||||||
actualLanesCount: lanesCount,
|
actualLanesCount: widget.lanesCount,
|
||||||
dragState: scope.interaction.dragState,
|
dragState: scope.interaction.dragState,
|
||||||
groupId: group.id,
|
resizeState: scope.interaction.resizeState,
|
||||||
|
groupId: widget.group.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return _buildContent(context, effectiveLanesCount);
|
return _wrapContent(pillWidgets, effectiveLanesCount, scope);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -212,66 +330,47 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
int _calculateEffectiveLanesCount({
|
int _calculateEffectiveLanesCount({
|
||||||
required int actualLanesCount,
|
required int actualLanesCount,
|
||||||
required EntryDragState? dragState,
|
required EntryDragState? dragState,
|
||||||
|
required EntryResizeState? resizeState,
|
||||||
required String groupId,
|
required String groupId,
|
||||||
}) {
|
}) {
|
||||||
// No drag active - use actual lane count
|
var effective = actualLanesCount;
|
||||||
if (dragState == null) {
|
|
||||||
return actualLanesCount;
|
// Expand for drag target lane
|
||||||
|
if (dragState != null && dragState.targetGroupId == groupId) {
|
||||||
|
if (dragState.targetLane > effective) {
|
||||||
|
effective = dragState.targetLane;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag active but over different group - use actual lane count
|
// Expand for resize target lane
|
||||||
if (dragState.targetGroupId != groupId) {
|
if (resizeState != null && resizeState.originalEntry.groupId == groupId) {
|
||||||
return actualLanesCount;
|
if (resizeState.targetLane > effective) {
|
||||||
|
effective = resizeState.targetLane;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag active over this group - expand to accommodate target lane
|
return effective;
|
||||||
return actualLanesCount > dragState.targetLane
|
|
||||||
? actualLanesCount
|
|
||||||
: dragState.targetLane;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context, int effectiveLanesCount) {
|
Widget _wrapContent(
|
||||||
|
List<Widget> pillWidgets,
|
||||||
|
int effectiveLanesCount,
|
||||||
|
ZTimelineScopeData? scope,
|
||||||
|
) {
|
||||||
final totalHeight =
|
final totalHeight =
|
||||||
effectiveLanesCount * laneHeight +
|
effectiveLanesCount * widget.laneHeight +
|
||||||
(effectiveLanesCount > 0
|
(effectiveLanesCount > 0
|
||||||
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
||||||
: 0);
|
: 0);
|
||||||
final scope = ZTimelineScope.maybeOf(context);
|
|
||||||
|
|
||||||
// The inner Stack with pills and ghost overlay
|
final innerStack = SizedBox(
|
||||||
Widget innerStack = SizedBox(
|
|
||||||
height: totalHeight,
|
height: totalHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Event pills
|
...pillWidgets,
|
||||||
for (final e in entries)
|
// Ghost overlay for drag operations
|
||||||
DraggableEventPill(
|
if (widget.enableDrag && scope != null) _buildDragPreview(scope),
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -281,6 +380,7 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
key: _lanesKey,
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
vertical: ZTimelineConstants.verticalOuterPadding,
|
vertical: ZTimelineConstants.verticalOuterPadding,
|
||||||
),
|
),
|
||||||
@@ -288,4 +388,42 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildDragPreview(ZTimelineScopeData scope) {
|
||||||
|
final dragState = scope.interaction.dragState;
|
||||||
|
final resizeState = scope.interaction.resizeState;
|
||||||
|
|
||||||
|
// Show ghost for resize (entry stays in its own group)
|
||||||
|
if (resizeState != null &&
|
||||||
|
resizeState.originalEntry.groupId == widget.group.id) {
|
||||||
|
return DragPreview(
|
||||||
|
targetStart: resizeState.targetStart,
|
||||||
|
targetEnd: resizeState.targetEnd,
|
||||||
|
targetLane: resizeState.targetLane,
|
||||||
|
viewport: widget.viewport,
|
||||||
|
contentWidth: widget.contentWidth,
|
||||||
|
laneHeight: widget.laneHeight,
|
||||||
|
color: widget.colorBuilder(resizeState.originalEntry),
|
||||||
|
label: widget.labelBuilder(resizeState.originalEntry),
|
||||||
|
hasEnd: resizeState.originalEntry.hasEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show ghost for drag-move
|
||||||
|
if (dragState != null && dragState.targetGroupId == widget.group.id) {
|
||||||
|
return DragPreview(
|
||||||
|
targetStart: dragState.targetStart,
|
||||||
|
targetEnd: dragState.targetEnd,
|
||||||
|
targetLane: dragState.targetLane,
|
||||||
|
viewport: widget.viewport,
|
||||||
|
contentWidth: widget.contentWidth,
|
||||||
|
laneHeight: widget.laneHeight,
|
||||||
|
color: widget.colorBuilder(dragState.originalEntry),
|
||||||
|
label: widget.labelBuilder(dragState.originalEntry),
|
||||||
|
hasEnd: dragState.originalEntry.hasEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ export 'src/constants.dart';
|
|||||||
// Models
|
// Models
|
||||||
export 'src/models/breadcrumb_segment.dart';
|
export 'src/models/breadcrumb_segment.dart';
|
||||||
export 'src/models/entry_drag_state.dart';
|
export 'src/models/entry_drag_state.dart';
|
||||||
|
export 'src/models/entry_resize_state.dart';
|
||||||
export 'src/models/interaction_config.dart';
|
export 'src/models/interaction_config.dart';
|
||||||
export 'src/models/interaction_state.dart';
|
export 'src/models/interaction_state.dart';
|
||||||
export 'src/models/projected_entry.dart';
|
|
||||||
export 'src/models/tier_config.dart';
|
export 'src/models/tier_config.dart';
|
||||||
export 'src/models/tier_section.dart';
|
export 'src/models/tier_section.dart';
|
||||||
export 'src/models/tiered_tick_data.dart';
|
export 'src/models/tiered_tick_data.dart';
|
||||||
@@ -23,17 +23,18 @@ export 'src/services/entry_placement_service.dart';
|
|||||||
export 'src/services/layout_coordinate_service.dart';
|
export 'src/services/layout_coordinate_service.dart';
|
||||||
export 'src/services/tiered_tick_service.dart';
|
export 'src/services/tiered_tick_service.dart';
|
||||||
export 'src/services/time_scale_service.dart';
|
export 'src/services/time_scale_service.dart';
|
||||||
export 'src/services/timeline_projection_service.dart';
|
export 'src/services/timeline_group_registry.dart';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
export 'src/state/timeline_interaction_notifier.dart';
|
export 'src/state/timeline_interaction_notifier.dart';
|
||||||
export 'src/state/timeline_viewport_notifier.dart';
|
export 'src/state/timeline_viewport_notifier.dart';
|
||||||
|
|
||||||
// Widgets
|
// Widgets
|
||||||
export 'src/widgets/breadcrumb_segment_chip.dart';
|
export 'src/widgets/breadcrumb_segment_chip.dart';
|
||||||
export 'src/widgets/draggable_event_pill.dart';
|
export 'src/widgets/entry_popover_overlay.dart';
|
||||||
export 'src/widgets/ghost_overlay.dart';
|
export 'src/widgets/event_pill.dart';
|
||||||
export 'src/widgets/group_drop_target.dart';
|
export 'src/widgets/event_point.dart';
|
||||||
|
export 'src/widgets/drag_preview.dart';
|
||||||
|
export 'src/widgets/interactive_event_pill.dart';
|
||||||
export 'src/widgets/timeline_breadcrumb.dart';
|
export 'src/widgets/timeline_breadcrumb.dart';
|
||||||
export 'src/widgets/timeline_interactor.dart';
|
export 'src/widgets/timeline_interactor.dart';
|
||||||
export 'src/widgets/timeline_scope.dart';
|
export 'src/widgets/timeline_scope.dart';
|
||||||
|
|||||||
205
packages/z-flutter/packages/z_timeline/pubspec.lock
Normal file
205
packages/z-flutter/packages/z_timeline/pubspec.lock
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.0"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.18"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.17.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.9"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.0.2"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.11.0 <4.0.0"
|
||||||
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/// Reusable UTC date anchors for timeline tests.
|
||||||
|
///
|
||||||
|
/// All dates are UTC to avoid timezone flakiness.
|
||||||
|
|
||||||
|
/// Domain start: June 1, 2025 00:00 UTC.
|
||||||
|
final kDomainStart = DateTime.utc(2025, 6, 1);
|
||||||
|
|
||||||
|
/// Domain end: July 1, 2025 00:00 UTC (30-day domain).
|
||||||
|
final kDomainEnd = DateTime.utc(2025, 7, 1);
|
||||||
|
|
||||||
|
/// Midpoint anchor: June 15, 2025 12:00 UTC.
|
||||||
|
final kAnchor = DateTime.utc(2025, 6, 15, 12);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:z_timeline/src/models/entry_drag_state.dart';
|
||||||
|
import 'package:z_timeline/src/models/entry_resize_state.dart';
|
||||||
|
import 'package:z_timeline/src/models/timeline_entry.dart';
|
||||||
|
import 'package:z_timeline/src/models/timeline_group.dart';
|
||||||
|
|
||||||
|
import 'test_constants.dart';
|
||||||
|
|
||||||
|
/// Creates a [TimelineEntry] with sensible defaults, all fields overridable.
|
||||||
|
TimelineEntry makeEntry({
|
||||||
|
String? id,
|
||||||
|
String? groupId,
|
||||||
|
DateTime? start,
|
||||||
|
DateTime? end,
|
||||||
|
int lane = 1,
|
||||||
|
bool hasEnd = true,
|
||||||
|
}) {
|
||||||
|
return TimelineEntry(
|
||||||
|
id: id ?? 'entry-1',
|
||||||
|
groupId: groupId ?? 'group-1',
|
||||||
|
start: start ?? kAnchor,
|
||||||
|
end: end ?? kAnchor.add(const Duration(hours: 2)),
|
||||||
|
lane: lane,
|
||||||
|
hasEnd: hasEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a [TimelineGroup] with sensible defaults.
|
||||||
|
TimelineGroup makeGroup({String? id, String? title}) {
|
||||||
|
return TimelineGroup(id: id ?? 'group-1', title: title ?? 'Group 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an [EntryDragState] with sensible defaults.
|
||||||
|
EntryDragState makeDragState({
|
||||||
|
String? entryId,
|
||||||
|
TimelineEntry? originalEntry,
|
||||||
|
String? targetGroupId,
|
||||||
|
int targetLane = 1,
|
||||||
|
DateTime? targetStart,
|
||||||
|
}) {
|
||||||
|
final entry = originalEntry ?? makeEntry(id: entryId);
|
||||||
|
return EntryDragState(
|
||||||
|
entryId: entry.id,
|
||||||
|
originalEntry: entry,
|
||||||
|
targetGroupId: targetGroupId ?? entry.groupId,
|
||||||
|
targetLane: targetLane,
|
||||||
|
targetStart: targetStart ?? entry.start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an [EntryResizeState] with sensible defaults.
|
||||||
|
EntryResizeState makeResizeState({
|
||||||
|
String? entryId,
|
||||||
|
TimelineEntry? originalEntry,
|
||||||
|
ResizeEdge edge = ResizeEdge.end,
|
||||||
|
DateTime? targetStart,
|
||||||
|
DateTime? targetEnd,
|
||||||
|
int targetLane = 1,
|
||||||
|
}) {
|
||||||
|
final entry = originalEntry ?? makeEntry(id: entryId);
|
||||||
|
return EntryResizeState(
|
||||||
|
entryId: entry.id,
|
||||||
|
originalEntry: entry,
|
||||||
|
edge: edge,
|
||||||
|
targetStart: targetStart ?? entry.start,
|
||||||
|
targetEnd: targetEnd ?? entry.end,
|
||||||
|
targetLane: targetLane,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates N non-overlapping entries in the same group/lane for collision tests.
|
||||||
|
///
|
||||||
|
/// Each entry is 1 hour long, starting 2 hours apart.
|
||||||
|
List<TimelineEntry> makeEntrySequence(
|
||||||
|
int count, {
|
||||||
|
String groupId = 'group-1',
|
||||||
|
int lane = 1,
|
||||||
|
}) {
|
||||||
|
return List.generate(count, (i) {
|
||||||
|
final start = kAnchor.add(Duration(hours: i * 2));
|
||||||
|
return makeEntry(
|
||||||
|
id: 'seq-$i',
|
||||||
|
groupId: groupId,
|
||||||
|
lane: lane,
|
||||||
|
start: start,
|
||||||
|
end: start.add(const Duration(hours: 1)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:z_timeline/src/models/interaction_config.dart';
|
||||||
|
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
|
||||||
|
import 'package:z_timeline/src/widgets/timeline_scope.dart';
|
||||||
|
|
||||||
|
import 'test_constants.dart';
|
||||||
|
|
||||||
|
/// Test harness wrapping a child in MaterialApp > Scaffold > ZTimelineScope.
|
||||||
|
///
|
||||||
|
/// Provides controllable [TimelineViewportNotifier] and
|
||||||
|
/// [ZTimelineInteractionConfig].
|
||||||
|
class TimelineTestHarness extends StatelessWidget {
|
||||||
|
const TimelineTestHarness({
|
||||||
|
required this.viewport,
|
||||||
|
required this.child,
|
||||||
|
this.config = ZTimelineInteractionConfig.defaults,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineViewportNotifier viewport;
|
||||||
|
final ZTimelineInteractionConfig config;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ZTimelineScope(
|
||||||
|
viewport: viewport,
|
||||||
|
config: config,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience extension on [WidgetTester] for pumping a timeline test harness.
|
||||||
|
extension TimelineTestHarnessExtension on WidgetTester {
|
||||||
|
/// Pumps a [TimelineTestHarness] with the given child and optional config.
|
||||||
|
///
|
||||||
|
/// Returns the [TimelineViewportNotifier] for controlling the viewport.
|
||||||
|
Future<TimelineViewportNotifier> pumpTimeline(
|
||||||
|
Widget child, {
|
||||||
|
TimelineViewportNotifier? viewport,
|
||||||
|
ZTimelineInteractionConfig config = ZTimelineInteractionConfig.defaults,
|
||||||
|
}) async {
|
||||||
|
final vp = viewport ??
|
||||||
|
TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
|
||||||
|
await pumpWidget(
|
||||||
|
TimelineTestHarness(viewport: vp, config: config, child: child),
|
||||||
|
);
|
||||||
|
return vp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:z_timeline/src/services/entry_placement_service.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_constants.dart';
|
||||||
|
import '../helpers/test_factories.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('EntryPlacementService', () {
|
||||||
|
group('isPositionAvailable', () {
|
||||||
|
test('empty list is always available', () {
|
||||||
|
final result = EntryPlacementService.isPositionAvailable(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
existingEntries: [],
|
||||||
|
);
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlap in same lane and group is blocked', () {
|
||||||
|
final existing = makeEntry(
|
||||||
|
id: 'existing',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
);
|
||||||
|
final result = EntryPlacementService.isPositionAvailable(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 3)),
|
||||||
|
existingEntries: [existing],
|
||||||
|
);
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different lane is available', () {
|
||||||
|
final existing = makeEntry(
|
||||||
|
id: 'existing',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
);
|
||||||
|
final result = EntryPlacementService.isPositionAvailable(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 2,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
existingEntries: [existing],
|
||||||
|
);
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different group is available', () {
|
||||||
|
final existing = makeEntry(
|
||||||
|
id: 'existing',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
);
|
||||||
|
final result = EntryPlacementService.isPositionAvailable(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-2',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
existingEntries: [existing],
|
||||||
|
);
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self is excluded from collision', () {
|
||||||
|
final existing = makeEntry(
|
||||||
|
id: 'self',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
);
|
||||||
|
final result = EntryPlacementService.isPositionAvailable(
|
||||||
|
entryId: 'self',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
existingEntries: [existing],
|
||||||
|
);
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('touching entries (end == start) do not overlap', () {
|
||||||
|
final existing = makeEntry(
|
||||||
|
id: 'existing',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
);
|
||||||
|
final result = EntryPlacementService.isPositionAvailable(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
existingEntries: [existing],
|
||||||
|
);
|
||||||
|
// end == start means s1.isBefore(e2) && e1.isAfter(s2) — touching
|
||||||
|
// is NOT overlapping because e1 == s2 means e1.isAfter(s2) is false
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('findNearestAvailableLane', () {
|
||||||
|
test('target available returns target', () {
|
||||||
|
final result = EntryPlacementService.findNearestAvailableLane(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
existingEntries: [],
|
||||||
|
);
|
||||||
|
expect(result, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('target blocked finds next available', () {
|
||||||
|
final existing = makeEntry(
|
||||||
|
id: 'blocker',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
);
|
||||||
|
final result = EntryPlacementService.findNearestAvailableLane(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
existingEntries: [existing],
|
||||||
|
);
|
||||||
|
// Lane 1 blocked, tries lane 2 (which is free)
|
||||||
|
expect(result, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expanding search: +1, -1, +2, etc.', () {
|
||||||
|
// Block lanes 3 and 4, target lane 3. Should find lane 2 (3-1).
|
||||||
|
final entries = [
|
||||||
|
makeEntry(
|
||||||
|
id: 'a',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 3,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
),
|
||||||
|
makeEntry(
|
||||||
|
id: 'b',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 4,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final result = EntryPlacementService.findNearestAvailableLane(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 3,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
existingEntries: entries,
|
||||||
|
);
|
||||||
|
// Lane 3 blocked, tries 4 (blocked), then 2 (free)
|
||||||
|
expect(result, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lane 1 minimum enforced (no lane 0)', () {
|
||||||
|
// Block lanes 1 and 2, target lane 1. Should skip 0 and find lane 3.
|
||||||
|
final entries = [
|
||||||
|
makeEntry(
|
||||||
|
id: 'a',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
),
|
||||||
|
makeEntry(
|
||||||
|
id: 'b',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 2,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final result = EntryPlacementService.findNearestAvailableLane(
|
||||||
|
entryId: 'new',
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
existingEntries: entries,
|
||||||
|
);
|
||||||
|
// Lane 1 blocked, tries 2 (blocked), then 0 (skipped, <1), then 3
|
||||||
|
expect(result, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('resolvePlacement', () {
|
||||||
|
test('preserves duration', () {
|
||||||
|
final entry = makeEntry(
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 3)),
|
||||||
|
);
|
||||||
|
final newStart = kAnchor.add(const Duration(hours: 5));
|
||||||
|
final result = EntryPlacementService.resolvePlacement(
|
||||||
|
entry: entry,
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: newStart,
|
||||||
|
existingEntries: [],
|
||||||
|
);
|
||||||
|
final expectedEnd = newStart.add(const Duration(hours: 3));
|
||||||
|
expect(result.end, expectedEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns collision-free lane', () {
|
||||||
|
final existing = makeEntry(
|
||||||
|
id: 'blocker',
|
||||||
|
groupId: 'group-1',
|
||||||
|
lane: 1,
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 4)),
|
||||||
|
);
|
||||||
|
final entry = makeEntry(
|
||||||
|
id: 'mover',
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(hours: 2)),
|
||||||
|
);
|
||||||
|
final result = EntryPlacementService.resolvePlacement(
|
||||||
|
entry: entry,
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
existingEntries: [existing],
|
||||||
|
);
|
||||||
|
expect(result.lane, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:z_timeline/src/constants.dart';
|
||||||
|
import 'package:z_timeline/src/services/layout_coordinate_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('LayoutCoordinateService', () {
|
||||||
|
const contentWidth = 1000.0;
|
||||||
|
const laneHeight = ZTimelineConstants.laneHeight;
|
||||||
|
|
||||||
|
group('normalizedToWidgetX / widgetXToNormalized roundtrip', () {
|
||||||
|
test('0.0 normalized maps to 0.0 widget X', () {
|
||||||
|
final result = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
|
normalizedX: 0.0,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
expect(result, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1.0 normalized maps to contentWidth', () {
|
||||||
|
final result = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
|
normalizedX: 1.0,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
expect(result, contentWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roundtrip normalized → widget → normalized', () {
|
||||||
|
const normalizedX = 0.35;
|
||||||
|
final widgetX = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
|
normalizedX: normalizedX,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
final result = LayoutCoordinateService.widgetXToNormalized(
|
||||||
|
widgetX: widgetX,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
expect(result, closeTo(normalizedX, 0.0001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('widgetXToNormalized', () {
|
||||||
|
test('clamps negative widget X to 0.0', () {
|
||||||
|
final result = LayoutCoordinateService.widgetXToNormalized(
|
||||||
|
widgetX: -50.0,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
expect(result, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps widget X > contentWidth to 1.0', () {
|
||||||
|
final result = LayoutCoordinateService.widgetXToNormalized(
|
||||||
|
widgetX: contentWidth + 100,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
expect(result, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 0.0 when contentWidth is 0', () {
|
||||||
|
final result = LayoutCoordinateService.widgetXToNormalized(
|
||||||
|
widgetX: 50.0,
|
||||||
|
contentWidth: 0.0,
|
||||||
|
);
|
||||||
|
expect(result, 0.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('calculateItemWidth', () {
|
||||||
|
test('normalized width * content width', () {
|
||||||
|
final result = LayoutCoordinateService.calculateItemWidth(
|
||||||
|
normalizedWidth: 0.25,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
expect(result, 250.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps to 0 for negative input', () {
|
||||||
|
final result = LayoutCoordinateService.calculateItemWidth(
|
||||||
|
normalizedWidth: -0.1,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
expect(result, 0.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('laneToY / yToLane', () {
|
||||||
|
test('lane 1 starts at Y=0', () {
|
||||||
|
final y = LayoutCoordinateService.laneToY(
|
||||||
|
lane: 1,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
);
|
||||||
|
expect(y, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lane 2 includes lane height + spacing', () {
|
||||||
|
final y = LayoutCoordinateService.laneToY(
|
||||||
|
lane: 2,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
);
|
||||||
|
expect(y, laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yToLane roundtrip from laneToY', () {
|
||||||
|
for (var lane = 1; lane <= 5; lane++) {
|
||||||
|
final y = LayoutCoordinateService.laneToY(
|
||||||
|
lane: lane,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
);
|
||||||
|
// Add a small offset within the lane to test mid-lane Y
|
||||||
|
final recoveredLane = LayoutCoordinateService.yToLane(
|
||||||
|
y: y + laneHeight / 2,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
);
|
||||||
|
expect(recoveredLane, lane);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yToLane at lane boundary returns correct lane', () {
|
||||||
|
final lane = LayoutCoordinateService.yToLane(
|
||||||
|
y: 0.0,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
);
|
||||||
|
expect(lane, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:z_timeline/src/services/time_scale_service.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_constants.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TimeScaleService', () {
|
||||||
|
group('mapTimeToPosition', () {
|
||||||
|
test('domain start maps to 0.0', () {
|
||||||
|
final result = TimeScaleService.mapTimeToPosition(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
expect(result, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('domain end maps to 1.0', () {
|
||||||
|
final result = TimeScaleService.mapTimeToPosition(
|
||||||
|
kDomainEnd,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
expect(result, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('midpoint maps to ~0.5', () {
|
||||||
|
final mid = kDomainStart.add(
|
||||||
|
kDomainEnd.difference(kDomainStart) ~/ 2,
|
||||||
|
);
|
||||||
|
final result = TimeScaleService.mapTimeToPosition(
|
||||||
|
mid,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
expect(result, closeTo(0.5, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('before domain returns negative', () {
|
||||||
|
final before = kDomainStart.subtract(const Duration(days: 1));
|
||||||
|
final result = TimeScaleService.mapTimeToPosition(
|
||||||
|
before,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
expect(result, lessThan(0.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after domain returns >1', () {
|
||||||
|
final after = kDomainEnd.add(const Duration(days: 1));
|
||||||
|
final result = TimeScaleService.mapTimeToPosition(
|
||||||
|
after,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
expect(result, greaterThan(1.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('mapPositionToTime', () {
|
||||||
|
test('position 0 maps to domain start', () {
|
||||||
|
final result = TimeScaleService.mapPositionToTime(
|
||||||
|
0.0,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.millisecondsSinceEpoch,
|
||||||
|
kDomainStart.millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position 1 maps to domain end', () {
|
||||||
|
final result = TimeScaleService.mapPositionToTime(
|
||||||
|
1.0,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.millisecondsSinceEpoch,
|
||||||
|
kDomainEnd.millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roundtrip with mapTimeToPosition', () {
|
||||||
|
final time = kAnchor;
|
||||||
|
final position = TimeScaleService.mapTimeToPosition(
|
||||||
|
time,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
final result = TimeScaleService.mapPositionToTime(
|
||||||
|
position,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
// Allow 1ms tolerance for rounding
|
||||||
|
expect(
|
||||||
|
result.millisecondsSinceEpoch,
|
||||||
|
closeTo(time.millisecondsSinceEpoch, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('domainDuration', () {
|
||||||
|
test('30-day domain returns correct milliseconds', () {
|
||||||
|
final result = TimeScaleService.domainDuration(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
// June has 30 days
|
||||||
|
const expectedMs = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
expect(result, expectedMs.toDouble());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('calculateZoomedDomain', () {
|
||||||
|
test('factor > 1 shrinks domain (zoom in)', () {
|
||||||
|
final result = TimeScaleService.calculateZoomedDomain(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
factor: 2.0,
|
||||||
|
);
|
||||||
|
final originalDuration = kDomainEnd.difference(kDomainStart);
|
||||||
|
final newDuration = result.end.difference(result.start);
|
||||||
|
expect(
|
||||||
|
newDuration.inMilliseconds,
|
||||||
|
closeTo(originalDuration.inMilliseconds / 2, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('factor < 1 expands domain (zoom out)', () {
|
||||||
|
final result = TimeScaleService.calculateZoomedDomain(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
factor: 0.5,
|
||||||
|
);
|
||||||
|
final originalDuration = kDomainEnd.difference(kDomainStart);
|
||||||
|
final newDuration = result.end.difference(result.start);
|
||||||
|
expect(
|
||||||
|
newDuration.inMilliseconds,
|
||||||
|
closeTo(originalDuration.inMilliseconds * 2, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('factor = 1 leaves domain unchanged', () {
|
||||||
|
final result = TimeScaleService.calculateZoomedDomain(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
factor: 1.0,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.start.millisecondsSinceEpoch,
|
||||||
|
closeTo(kDomainStart.millisecondsSinceEpoch, 1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.end.millisecondsSinceEpoch,
|
||||||
|
closeTo(kDomainEnd.millisecondsSinceEpoch, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus position is preserved', () {
|
||||||
|
const focusPosition = 0.25;
|
||||||
|
final focusTime = TimeScaleService.mapPositionToTime(
|
||||||
|
focusPosition,
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = TimeScaleService.calculateZoomedDomain(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
factor: 2.0,
|
||||||
|
focusPosition: focusPosition,
|
||||||
|
);
|
||||||
|
|
||||||
|
final newFocusPosition = TimeScaleService.mapTimeToPosition(
|
||||||
|
focusTime,
|
||||||
|
result.start,
|
||||||
|
result.end,
|
||||||
|
);
|
||||||
|
expect(newFocusPosition, closeTo(focusPosition, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('calculatePannedDomain', () {
|
||||||
|
test('positive ratio shifts forward', () {
|
||||||
|
final result = TimeScaleService.calculatePannedDomain(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
ratio: 0.1,
|
||||||
|
);
|
||||||
|
expect(result.start.isAfter(kDomainStart), isTrue);
|
||||||
|
expect(result.end.isAfter(kDomainEnd), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative ratio shifts backward', () {
|
||||||
|
final result = TimeScaleService.calculatePannedDomain(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
ratio: -0.1,
|
||||||
|
);
|
||||||
|
expect(result.start.isBefore(kDomainStart), isTrue);
|
||||||
|
expect(result.end.isBefore(kDomainEnd), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duration is preserved', () {
|
||||||
|
final originalDuration = kDomainEnd.difference(kDomainStart);
|
||||||
|
final result = TimeScaleService.calculatePannedDomain(
|
||||||
|
kDomainStart,
|
||||||
|
kDomainEnd,
|
||||||
|
ratio: 0.3,
|
||||||
|
);
|
||||||
|
final newDuration = result.end.difference(result.start);
|
||||||
|
expect(
|
||||||
|
newDuration.inMilliseconds,
|
||||||
|
closeTo(originalDuration.inMilliseconds, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:z_timeline/src/models/entry_resize_state.dart';
|
||||||
|
import 'package:z_timeline/src/state/timeline_interaction_notifier.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_constants.dart';
|
||||||
|
import '../helpers/test_factories.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ZTimelineInteractionNotifier', () {
|
||||||
|
late ZTimelineInteractionNotifier notifier;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
notifier = ZTimelineInteractionNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
notifier.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('initial state', () {
|
||||||
|
test('all flags are false', () {
|
||||||
|
expect(notifier.isGrabbing, isFalse);
|
||||||
|
expect(notifier.isDraggingEntry, isFalse);
|
||||||
|
expect(notifier.isResizingEntry, isFalse);
|
||||||
|
expect(notifier.isInteracting, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all states are null', () {
|
||||||
|
expect(notifier.dragState, isNull);
|
||||||
|
expect(notifier.resizeState, isNull);
|
||||||
|
expect(notifier.hoveredEntryId, isNull);
|
||||||
|
expect(notifier.hoveredPillGlobalRect, isNull);
|
||||||
|
expect(notifier.interactionGlobalPosition, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('hover', () {
|
||||||
|
test('setHoveredEntry sets id and rect and notifies', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
const rect = Rect.fromLTWH(10, 20, 100, 30);
|
||||||
|
notifier.setHoveredEntry('entry-1', rect);
|
||||||
|
|
||||||
|
expect(notifier.hoveredEntryId, 'entry-1');
|
||||||
|
expect(notifier.hoveredPillGlobalRect, rect);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate setHoveredEntry is no-op', () {
|
||||||
|
const rect = Rect.fromLTWH(10, 20, 100, 30);
|
||||||
|
notifier.setHoveredEntry('entry-1', rect);
|
||||||
|
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.setHoveredEntry('entry-1', rect);
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearHoveredEntry clears and notifies', () {
|
||||||
|
notifier.setHoveredEntry('entry-1', const Rect.fromLTWH(0, 0, 50, 20));
|
||||||
|
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.clearHoveredEntry();
|
||||||
|
expect(notifier.hoveredEntryId, isNull);
|
||||||
|
expect(notifier.hoveredPillGlobalRect, isNull);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearHoveredEntry when already null is no-op', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.clearHoveredEntry();
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('grabbing', () {
|
||||||
|
test('setGrabbing(true) toggles and notifies', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.setGrabbing(true);
|
||||||
|
expect(notifier.isGrabbing, isTrue);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setGrabbing(false) toggles and notifies', () {
|
||||||
|
notifier.setGrabbing(true);
|
||||||
|
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.setGrabbing(false);
|
||||||
|
expect(notifier.isGrabbing, isFalse);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate setGrabbing is no-op', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.setGrabbing(false); // already false
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('drag lifecycle', () {
|
||||||
|
test('beginDrag sets dragState and isDraggingEntry and clears hover', () {
|
||||||
|
notifier.setHoveredEntry('entry-1', const Rect.fromLTWH(0, 0, 50, 20));
|
||||||
|
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
final entry = makeEntry(id: 'entry-1');
|
||||||
|
notifier.beginDrag(entry);
|
||||||
|
|
||||||
|
expect(notifier.isDraggingEntry, isTrue);
|
||||||
|
expect(notifier.dragState, isNotNull);
|
||||||
|
expect(notifier.dragState!.entryId, 'entry-1');
|
||||||
|
expect(notifier.hoveredEntryId, isNull);
|
||||||
|
expect(notifyCount, greaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateDragTarget updates target', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginDrag(entry);
|
||||||
|
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
final newStart = kAnchor.add(const Duration(hours: 5));
|
||||||
|
notifier.updateDragTarget(
|
||||||
|
targetGroupId: 'group-2',
|
||||||
|
targetLane: 3,
|
||||||
|
targetStart: newStart,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifier.dragState!.targetGroupId, 'group-2');
|
||||||
|
expect(notifier.dragState!.targetLane, 3);
|
||||||
|
expect(notifier.dragState!.targetStart, newStart);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateDragTarget is no-op without active drag', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.updateDragTarget(
|
||||||
|
targetGroupId: 'group-1',
|
||||||
|
targetLane: 1,
|
||||||
|
targetStart: kAnchor,
|
||||||
|
);
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endDrag clears all drag state', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginDrag(entry);
|
||||||
|
notifier.updateInteractionPosition(const Offset(100, 200));
|
||||||
|
|
||||||
|
notifier.endDrag();
|
||||||
|
|
||||||
|
expect(notifier.isDraggingEntry, isFalse);
|
||||||
|
expect(notifier.dragState, isNull);
|
||||||
|
expect(notifier.interactionGlobalPosition, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('resize lifecycle', () {
|
||||||
|
test(
|
||||||
|
'beginResize sets resizeState and isResizingEntry and clears hover',
|
||||||
|
() {
|
||||||
|
notifier.setHoveredEntry(
|
||||||
|
'entry-1',
|
||||||
|
const Rect.fromLTWH(0, 0, 50, 20),
|
||||||
|
);
|
||||||
|
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
final entry = makeEntry(id: 'entry-1');
|
||||||
|
notifier.beginResize(entry, ResizeEdge.start);
|
||||||
|
|
||||||
|
expect(notifier.isResizingEntry, isTrue);
|
||||||
|
expect(notifier.resizeState, isNotNull);
|
||||||
|
expect(notifier.resizeState!.edge, ResizeEdge.start);
|
||||||
|
expect(notifier.hoveredEntryId, isNull);
|
||||||
|
expect(notifyCount, greaterThan(0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('updateResizeTarget updates targets', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginResize(entry, ResizeEdge.end);
|
||||||
|
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
final newStart = kAnchor.subtract(const Duration(hours: 1));
|
||||||
|
final newEnd = kAnchor.add(const Duration(hours: 5));
|
||||||
|
notifier.updateResizeTarget(
|
||||||
|
targetStart: newStart,
|
||||||
|
targetEnd: newEnd,
|
||||||
|
targetLane: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifier.resizeState!.targetStart, newStart);
|
||||||
|
expect(notifier.resizeState!.targetEnd, newEnd);
|
||||||
|
expect(notifier.resizeState!.targetLane, 2);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateResizeTarget is no-op without active resize', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.updateResizeTarget(
|
||||||
|
targetStart: kAnchor,
|
||||||
|
targetEnd: kAnchor.add(const Duration(hours: 1)),
|
||||||
|
);
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endResize clears all resize state', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginResize(entry, ResizeEdge.end);
|
||||||
|
notifier.updateInteractionPosition(const Offset(100, 200));
|
||||||
|
|
||||||
|
notifier.endResize();
|
||||||
|
|
||||||
|
expect(notifier.isResizingEntry, isFalse);
|
||||||
|
expect(notifier.resizeState, isNull);
|
||||||
|
expect(notifier.interactionGlobalPosition, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('cancelDrag / cancelResize', () {
|
||||||
|
test('cancelDrag clears same as endDrag', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginDrag(entry);
|
||||||
|
|
||||||
|
notifier.cancelDrag();
|
||||||
|
|
||||||
|
expect(notifier.isDraggingEntry, isFalse);
|
||||||
|
expect(notifier.dragState, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelResize clears same as endResize', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginResize(entry, ResizeEdge.start);
|
||||||
|
|
||||||
|
notifier.cancelResize();
|
||||||
|
|
||||||
|
expect(notifier.isResizingEntry, isFalse);
|
||||||
|
expect(notifier.resizeState, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isInteracting', () {
|
||||||
|
test('true during drag', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginDrag(entry);
|
||||||
|
expect(notifier.isInteracting, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('true during resize', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginResize(entry, ResizeEdge.end);
|
||||||
|
expect(notifier.isInteracting, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false when idle', () {
|
||||||
|
expect(notifier.isInteracting, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false after ending drag', () {
|
||||||
|
final entry = makeEntry();
|
||||||
|
notifier.beginDrag(entry);
|
||||||
|
notifier.endDrag();
|
||||||
|
expect(notifier.isInteracting, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_constants.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TimelineViewportNotifier', () {
|
||||||
|
late TimelineViewportNotifier notifier;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
notifier = TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
notifier.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('construction', () {
|
||||||
|
test('stores start and end correctly', () {
|
||||||
|
expect(notifier.start, kDomainStart);
|
||||||
|
expect(notifier.end, kDomainEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asserts on invalid domain (start >= end)', () {
|
||||||
|
expect(
|
||||||
|
() => TimelineViewportNotifier(start: kDomainEnd, end: kDomainStart),
|
||||||
|
throwsAssertionError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('setDomain', () {
|
||||||
|
test('updates and notifies', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
final newStart = kDomainStart.add(const Duration(days: 1));
|
||||||
|
final newEnd = kDomainEnd.subtract(const Duration(days: 1));
|
||||||
|
notifier.setDomain(newStart, newEnd);
|
||||||
|
|
||||||
|
expect(notifier.start, newStart);
|
||||||
|
expect(notifier.end, newEnd);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-op if unchanged', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.setDomain(kDomainStart, kDomainEnd);
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid domain (start >= end)', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.setDomain(kDomainEnd, kDomainStart);
|
||||||
|
expect(notifier.start, kDomainStart);
|
||||||
|
expect(notifier.end, kDomainEnd);
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects equal start and end', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.setDomain(kDomainStart, kDomainStart);
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('zoom', () {
|
||||||
|
test('factor > 1 shrinks domain', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
final originalDuration = notifier.end.difference(notifier.start);
|
||||||
|
notifier.zoom(2.0);
|
||||||
|
final newDuration = notifier.end.difference(notifier.start);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
newDuration.inMilliseconds,
|
||||||
|
closeTo(originalDuration.inMilliseconds / 2, 1),
|
||||||
|
);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('factor < 1 expands domain', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
final originalDuration = notifier.end.difference(notifier.start);
|
||||||
|
notifier.zoom(0.5);
|
||||||
|
final newDuration = notifier.end.difference(notifier.start);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
newDuration.inMilliseconds,
|
||||||
|
closeTo(originalDuration.inMilliseconds * 2, 1),
|
||||||
|
);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('pan', () {
|
||||||
|
test('shifts domain forward', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.pan(0.1);
|
||||||
|
expect(notifier.start.isAfter(kDomainStart), isTrue);
|
||||||
|
expect(notifier.end.isAfter(kDomainEnd), isTrue);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shifts domain backward', () {
|
||||||
|
notifier.pan(-0.1);
|
||||||
|
expect(notifier.start.isBefore(kDomainStart), isTrue);
|
||||||
|
expect(notifier.end.isBefore(kDomainEnd), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves duration', () {
|
||||||
|
final originalDuration = notifier.end.difference(notifier.start);
|
||||||
|
notifier.pan(0.25);
|
||||||
|
final newDuration = notifier.end.difference(notifier.start);
|
||||||
|
expect(
|
||||||
|
newDuration.inMilliseconds,
|
||||||
|
closeTo(originalDuration.inMilliseconds, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('listener counting', () {
|
||||||
|
test('exactly one notification per valid mutation', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
notifier.pan(0.1);
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
|
||||||
|
notifier.zoom(1.5);
|
||||||
|
expect(notifyCount, 2);
|
||||||
|
|
||||||
|
final newStart = notifier.start.add(const Duration(days: 1));
|
||||||
|
final newEnd = notifier.end.add(const Duration(days: 1));
|
||||||
|
notifier.setDomain(newStart, newEnd);
|
||||||
|
expect(notifyCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero notifications for no-ops', () {
|
||||||
|
var notifyCount = 0;
|
||||||
|
notifier.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
// setDomain with same values
|
||||||
|
notifier.setDomain(kDomainStart, kDomainEnd);
|
||||||
|
// setDomain with invalid values
|
||||||
|
notifier.setDomain(kDomainEnd, kDomainStart);
|
||||||
|
|
||||||
|
expect(notifyCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:z_timeline/src/constants.dart';
|
||||||
|
import 'package:z_timeline/src/models/entry_resize_state.dart';
|
||||||
|
import 'package:z_timeline/src/models/timeline_entry.dart';
|
||||||
|
import 'package:z_timeline/src/services/layout_coordinate_service.dart';
|
||||||
|
import 'package:z_timeline/src/services/time_scale_service.dart';
|
||||||
|
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
|
||||||
|
import 'package:z_timeline/src/widgets/event_pill.dart';
|
||||||
|
import 'package:z_timeline/src/widgets/interactive_event_pill.dart';
|
||||||
|
import 'package:z_timeline/src/widgets/timeline_scope.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_constants.dart';
|
||||||
|
import '../helpers/test_factories.dart';
|
||||||
|
import '../helpers/timeline_test_harness.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('InteractiveEventPill', () {
|
||||||
|
const contentWidth = 800.0;
|
||||||
|
const laneHeight = ZTimelineConstants.laneHeight;
|
||||||
|
|
||||||
|
late TimelineViewportNotifier viewport;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
viewport = TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
viewport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Helper to pump an InteractiveEventPill in a test harness.
|
||||||
|
Future<void> pumpPill(
|
||||||
|
WidgetTester tester, {
|
||||||
|
TimelineEntry? entry,
|
||||||
|
bool enableDrag = true,
|
||||||
|
List<TimelineEntry> allEntries = const [],
|
||||||
|
void Function(TimelineEntry, DateTime, String, int)? onEntryMoved,
|
||||||
|
void Function(TimelineEntry, DateTime, DateTime, int)? onEntryResized,
|
||||||
|
}) async {
|
||||||
|
final testEntry = entry ?? makeEntry();
|
||||||
|
|
||||||
|
await tester.pumpTimeline(
|
||||||
|
SizedBox(
|
||||||
|
width: contentWidth,
|
||||||
|
height: 200,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
InteractiveEventPill(
|
||||||
|
entry: testEntry,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
labelBuilder: (e) => e.id,
|
||||||
|
colorBuilder: (_) => Colors.blue,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
enableDrag: enableDrag,
|
||||||
|
viewport: viewport,
|
||||||
|
allEntries: allEntries,
|
||||||
|
onEntryMoved: onEntryMoved,
|
||||||
|
onEntryResized: onEntryResized,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewport: viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('rendering', () {
|
||||||
|
testWidgets('pill appears at correct position', (tester) async {
|
||||||
|
final testEntry = makeEntry(lane: 2);
|
||||||
|
|
||||||
|
await pumpPill(tester, entry: testEntry);
|
||||||
|
|
||||||
|
final positioned = tester.widget<Positioned>(
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byType(EventPill),
|
||||||
|
matching: find.byType(Positioned),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
final expectedTop = LayoutCoordinateService.laneToY(
|
||||||
|
lane: 2,
|
||||||
|
laneHeight: laneHeight,
|
||||||
|
);
|
||||||
|
final startX = TimeScaleService.mapTimeToPosition(
|
||||||
|
testEntry.start,
|
||||||
|
viewport.start,
|
||||||
|
viewport.end,
|
||||||
|
);
|
||||||
|
final endX = TimeScaleService.mapTimeToPosition(
|
||||||
|
testEntry.end,
|
||||||
|
viewport.start,
|
||||||
|
viewport.end,
|
||||||
|
);
|
||||||
|
final expectedLeft = LayoutCoordinateService.normalizedToWidgetX(
|
||||||
|
normalizedX: startX,
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
final expectedWidth = LayoutCoordinateService.calculateItemWidth(
|
||||||
|
normalizedWidth: (endX - startX).clamp(0.0, double.infinity),
|
||||||
|
contentWidth: contentWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(positioned.top, expectedTop);
|
||||||
|
expect(positioned.left, closeTo(expectedLeft, 0.1));
|
||||||
|
expect(positioned.width, closeTo(expectedWidth, 0.1));
|
||||||
|
expect(positioned.height, laneHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('enableDrag=false still renders pill', (tester) async {
|
||||||
|
await pumpPill(tester, enableDrag: false);
|
||||||
|
|
||||||
|
expect(find.byType(EventPill), findsOneWidget);
|
||||||
|
// No GestureDetector when drag is disabled
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(InteractiveEventPill),
|
||||||
|
matching: find.byType(GestureDetector),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('hover', () {
|
||||||
|
testWidgets('mouse enter calls setHoveredEntry', (tester) async {
|
||||||
|
await pumpPill(tester);
|
||||||
|
|
||||||
|
final pillFinder = find.byType(EventPill);
|
||||||
|
final gesture = await tester.createGesture(
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await gesture.addPointer(location: Offset.zero);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.moveTo(tester.getCenter(pillFinder));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final scope = ZTimelineScope.of(
|
||||||
|
tester.element(find.byType(InteractiveEventPill)),
|
||||||
|
);
|
||||||
|
expect(scope.interaction.hoveredEntryId, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('mouse exit calls clearHoveredEntry', (tester) async {
|
||||||
|
await pumpPill(tester);
|
||||||
|
|
||||||
|
final pillFinder = find.byType(EventPill);
|
||||||
|
final gesture = await tester.createGesture(
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await gesture.addPointer(location: Offset.zero);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Enter
|
||||||
|
await gesture.moveTo(tester.getCenter(pillFinder));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Exit
|
||||||
|
await gesture.moveTo(Offset.zero);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final scope = ZTimelineScope.of(
|
||||||
|
tester.element(find.byType(InteractiveEventPill)),
|
||||||
|
);
|
||||||
|
expect(scope.interaction.hoveredEntryId, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('drag flow', () {
|
||||||
|
testWidgets('pan from center triggers beginDrag', (tester) async {
|
||||||
|
final testEntry = makeEntry();
|
||||||
|
|
||||||
|
await pumpPill(tester, entry: testEntry);
|
||||||
|
|
||||||
|
final pillCenter = tester.getCenter(find.byType(EventPill));
|
||||||
|
|
||||||
|
// Simulate pan gesture
|
||||||
|
await tester.timedDragFrom(
|
||||||
|
pillCenter,
|
||||||
|
const Offset(20, 0),
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// After drag ends, state should be cleared
|
||||||
|
final scope = ZTimelineScope.of(
|
||||||
|
tester.element(find.byType(InteractiveEventPill)),
|
||||||
|
);
|
||||||
|
expect(scope.interaction.isDraggingEntry, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('resize', () {
|
||||||
|
// Resize tests need a pill wide enough for resize handles (>= 16px).
|
||||||
|
// With a 30-day viewport and 800px contentWidth, a 7-day entry gives
|
||||||
|
// ~186px — well above the 16px threshold.
|
||||||
|
TimelineEntry makeWideEntry() => makeEntry(
|
||||||
|
start: kAnchor,
|
||||||
|
end: kAnchor.add(const Duration(days: 7)),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('pan from left edge triggers resize start', (tester) async {
|
||||||
|
final testEntry = makeWideEntry();
|
||||||
|
|
||||||
|
TimelineEntry? resizedEntry;
|
||||||
|
await pumpPill(
|
||||||
|
tester,
|
||||||
|
entry: testEntry,
|
||||||
|
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||||
|
resizedEntry = entry;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the pill's left edge position (within first 6px)
|
||||||
|
final pillRect = tester.getRect(find.byType(EventPill));
|
||||||
|
final leftEdge = Offset(pillRect.left + 3, pillRect.center.dy);
|
||||||
|
|
||||||
|
await tester.timedDragFrom(
|
||||||
|
leftEdge,
|
||||||
|
const Offset(-20, 0),
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(resizedEntry, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pan from right edge triggers resize end', (tester) async {
|
||||||
|
final testEntry = makeWideEntry();
|
||||||
|
|
||||||
|
TimelineEntry? resizedEntry;
|
||||||
|
await pumpPill(
|
||||||
|
tester,
|
||||||
|
entry: testEntry,
|
||||||
|
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||||
|
resizedEntry = entry;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final pillRect = tester.getRect(find.byType(EventPill));
|
||||||
|
final rightEdge = Offset(pillRect.right - 3, pillRect.center.dy);
|
||||||
|
|
||||||
|
await tester.timedDragFrom(
|
||||||
|
rightEdge,
|
||||||
|
const Offset(20, 0),
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(resizedEntry, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('narrow pill (< 16px) always uses drag mode', (tester) async {
|
||||||
|
// Make a very narrow pill (< 16px) — start close to end
|
||||||
|
final narrowStart = kAnchor;
|
||||||
|
final viewDuration =
|
||||||
|
kDomainEnd.difference(kDomainStart).inMilliseconds;
|
||||||
|
// 15px out of 800px contentWidth → 15/800 = 0.01875 of normalized
|
||||||
|
final narrowDurationMs = (0.01875 * viewDuration).round();
|
||||||
|
final narrowEnd = narrowStart.add(
|
||||||
|
Duration(milliseconds: narrowDurationMs),
|
||||||
|
);
|
||||||
|
final testEntry = makeEntry(start: narrowStart, end: narrowEnd);
|
||||||
|
|
||||||
|
TimelineEntry? resizedEntry;
|
||||||
|
await pumpPill(
|
||||||
|
tester,
|
||||||
|
entry: testEntry,
|
||||||
|
onEntryResized: (entry, newStart, newEnd, newLane) {
|
||||||
|
resizedEntry = entry;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final pillCenter = tester.getCenter(find.byType(EventPill));
|
||||||
|
await tester.timedDragFrom(
|
||||||
|
pillCenter,
|
||||||
|
const Offset(20, 0),
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Should not have triggered resize
|
||||||
|
expect(resizedEntry, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('opacity', () {
|
||||||
|
testWidgets('pill at 1.0 opacity when idle', (tester) async {
|
||||||
|
await pumpPill(tester);
|
||||||
|
|
||||||
|
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||||
|
expect(opacity.opacity, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pill at 0.3 opacity during drag', (tester) async {
|
||||||
|
final testEntry = makeEntry();
|
||||||
|
await pumpPill(tester, entry: testEntry);
|
||||||
|
|
||||||
|
// Start a drag by triggering beginDrag on the notifier directly
|
||||||
|
final scope = ZTimelineScope.of(
|
||||||
|
tester.element(find.byType(InteractiveEventPill)),
|
||||||
|
);
|
||||||
|
scope.interaction.beginDrag(testEntry);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||||
|
expect(opacity.opacity, 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pill at 0.3 opacity during resize', (tester) async {
|
||||||
|
final testEntry = makeEntry();
|
||||||
|
await pumpPill(
|
||||||
|
tester,
|
||||||
|
entry: testEntry,
|
||||||
|
onEntryResized: (_, _, _, _) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final scope = ZTimelineScope.of(
|
||||||
|
tester.element(find.byType(InteractiveEventPill)),
|
||||||
|
);
|
||||||
|
scope.interaction.beginResize(testEntry, ResizeEdge.end);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||||
|
expect(opacity.opacity, 0.3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { cpSync, readFileSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
cpSync,
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { resolve, dirname } from "node:path";
|
import { resolve, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const src = resolve(__dirname, "../build/web");
|
const src = resolve(__dirname, "../build/web");
|
||||||
@@ -9,6 +17,22 @@ const dest = resolve(__dirname, "../../../apps/web/public/flutter");
|
|||||||
cpSync(src, dest, { recursive: true });
|
cpSync(src, dest, { recursive: true });
|
||||||
console.log(`Copied Flutter build: ${src} → ${dest}`);
|
console.log(`Copied Flutter build: ${src} → ${dest}`);
|
||||||
|
|
||||||
|
// Copy flutter.js.map from the Flutter SDK (not included in build output)
|
||||||
|
const flutterBin = execFileSync("which", ["flutter"], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
const sdkBinDir = dirname(realpathSync(flutterBin));
|
||||||
|
const flutterJsMap = resolve(
|
||||||
|
sdkBinDir,
|
||||||
|
"cache/flutter_web_sdk/flutter_js/flutter.js.map",
|
||||||
|
);
|
||||||
|
if (existsSync(flutterJsMap)) {
|
||||||
|
copyFileSync(flutterJsMap, resolve(dest, "flutter.js.map"));
|
||||||
|
console.log("Copied flutter.js.map from SDK");
|
||||||
|
} else {
|
||||||
|
console.warn(`flutter.js.map not found at ${flutterJsMap}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract buildConfig from flutter_bootstrap.js so the React app can fetch it
|
// Extract buildConfig from flutter_bootstrap.js so the React app can fetch it
|
||||||
const bootstrap = readFileSync(resolve(dest, "flutter_bootstrap.js"), "utf-8");
|
const bootstrap = readFileSync(resolve(dest, "flutter_bootstrap.js"), "utf-8");
|
||||||
const match = bootstrap.match(/_flutter\.buildConfig\s*=\s*({.*?});/);
|
const match = bootstrap.match(/_flutter\.buildConfig\s*=\s*({.*?});/);
|
||||||
|
|||||||
@@ -1,46 +1,410 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<!--
|
|
||||||
If you are serving your web app in a path other than the root, change the
|
|
||||||
href value below to reflect the base path you are serving from.
|
|
||||||
|
|
||||||
The path provided below has to start and end with a slash "/" in order for
|
|
||||||
it to work correctly.
|
|
||||||
|
|
||||||
For more details:
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
|
||||||
|
|
||||||
This is a placeholder for base href that will be replaced by the value of
|
|
||||||
the `--base-href` argument provided to `flutter build`.
|
|
||||||
-->
|
|
||||||
<base href="$FLUTTER_BASE_HREF">
|
<base href="$FLUTTER_BASE_HREF">
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="A new Flutter project.">
|
<meta name="description" content="Zendegi Timeline - Dev Mode">
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="z_flutter">
|
<meta name="apple-mobile-web-app-title" content="z_flutter">
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
<title>Zendegi Timeline (Dev)</title>
|
||||||
<title>z_flutter</title>
|
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
#toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #16213e;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#toolbar .group { display: flex; align-items: center; gap: 4px; }
|
||||||
|
#toolbar label { color: #8899aa; }
|
||||||
|
#toolbar button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#toolbar button:hover { background: #0f3460; }
|
||||||
|
#toolbar .sep { width: 1px; height: 20px; background: #0f3460; }
|
||||||
|
#event-log {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #0d1117;
|
||||||
|
border-top: 1px solid #0f3460;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
#event-log .entry { padding: 2px 0; border-bottom: 1px solid #161b22; }
|
||||||
|
#event-log .type { color: #58a6ff; }
|
||||||
|
#event-log .time { color: #6e7681; margin-right: 8px; }
|
||||||
|
#event-log .payload { color: #8b949e; }
|
||||||
|
#flutter-container { flex: 1; min-height: 0; position: relative; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<div id="toolbar">
|
||||||
You can customize the "flutter_bootstrap.js" script.
|
<div class="group">
|
||||||
This is useful to provide a custom configuration to the Flutter loader
|
<label>Theme:</label>
|
||||||
or to give the user feedback during the initialization process.
|
<button id="btn-light">Light</button>
|
||||||
|
<button id="btn-dark">Dark</button>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="group">
|
||||||
|
<label>Data:</label>
|
||||||
|
<button id="btn-few">Few items</button>
|
||||||
|
<button id="btn-many">Many items</button>
|
||||||
|
<button id="btn-empty">Empty</button>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="group">
|
||||||
|
<button id="btn-push">Push state</button>
|
||||||
|
<button id="btn-clear-log">Clear log</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
For more details:
|
<div id="flutter-container"></div>
|
||||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
<div id="event-log"></div>
|
||||||
-->
|
|
||||||
|
<script>
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test data generators
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeId() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(year, month, day) {
|
||||||
|
return new Date(year, month - 1, day).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFewItems() {
|
||||||
|
const g1 = makeId();
|
||||||
|
const g2 = makeId();
|
||||||
|
const g3 = makeId();
|
||||||
|
|
||||||
|
const items = {};
|
||||||
|
const addItem = (groupId, title, start, end, lane, desc) => {
|
||||||
|
const id = makeId();
|
||||||
|
items[id] = {
|
||||||
|
id, groupId, title, description: desc ?? null,
|
||||||
|
start, end: end ?? null, lane,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Work group
|
||||||
|
addItem(g1, "Project Alpha", iso(2026, 1, 5), iso(2026, 3, 15), 1, "Main project");
|
||||||
|
addItem(g1, "Project Beta", iso(2026, 2, 10), iso(2026, 5, 20), 2, "Secondary project");
|
||||||
|
addItem(g1, "Code Review", iso(2026, 3, 1), iso(2026, 3, 5), 1);
|
||||||
|
addItem(g1, "Sprint Planning", iso(2026, 1, 15), null, 3); // point event
|
||||||
|
|
||||||
|
// Personal group
|
||||||
|
addItem(g2, "Vacation", iso(2026, 4, 1), iso(2026, 4, 14), 1, "Spring break");
|
||||||
|
addItem(g2, "Birthday", iso(2026, 6, 12), null, 1); // point event
|
||||||
|
addItem(g2, "Move apartments", iso(2026, 3, 20), iso(2026, 3, 25), 2);
|
||||||
|
|
||||||
|
// Learning group
|
||||||
|
addItem(g3, "Flutter course", iso(2026, 1, 1), iso(2026, 2, 28), 1);
|
||||||
|
addItem(g3, "Rust book", iso(2026, 2, 15), iso(2026, 4, 30), 2);
|
||||||
|
addItem(g3, "Conference talk", iso(2026, 5, 10), null, 1); // point event
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeline: { id: makeId(), title: "My Timeline" },
|
||||||
|
groups: {
|
||||||
|
[g1]: { id: g1, title: "Work", sortOrder: 0 },
|
||||||
|
[g2]: { id: g2, title: "Personal", sortOrder: 1 },
|
||||||
|
[g3]: { id: g3, title: "Learning", sortOrder: 2 },
|
||||||
|
},
|
||||||
|
items,
|
||||||
|
groupOrder: [g1, g2, g3],
|
||||||
|
selectedItemId: null,
|
||||||
|
darkMode: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManyItems() {
|
||||||
|
const groupCount = 5;
|
||||||
|
const groupIds = Array.from({ length: groupCount }, makeId);
|
||||||
|
const groupNames = ["Engineering", "Design", "Marketing", "Operations", "Research"];
|
||||||
|
const groups = {};
|
||||||
|
for (let i = 0; i < groupCount; i++) {
|
||||||
|
groups[groupIds[i]] = { id: groupIds[i], title: groupNames[i], sortOrder: i };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = {};
|
||||||
|
const baseDate = new Date(2026, 0, 1);
|
||||||
|
let itemIndex = 0;
|
||||||
|
for (const gId of groupIds) {
|
||||||
|
for (let lane = 1; lane <= 3; lane++) {
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
const id = makeId();
|
||||||
|
const startOffset = j * 45 + lane * 5 + Math.floor(Math.random() * 10);
|
||||||
|
const duration = 14 + Math.floor(Math.random() * 30);
|
||||||
|
const start = new Date(baseDate);
|
||||||
|
start.setDate(start.getDate() + startOffset);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(end.getDate() + duration);
|
||||||
|
const isPoint = Math.random() < 0.15;
|
||||||
|
items[id] = {
|
||||||
|
id, groupId: gId,
|
||||||
|
title: `Task ${++itemIndex}`,
|
||||||
|
description: null,
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: isPoint ? null : end.toISOString(),
|
||||||
|
lane,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeline: { id: makeId(), title: "Large Timeline" },
|
||||||
|
groups, items,
|
||||||
|
groupOrder: groupIds,
|
||||||
|
selectedItemId: null,
|
||||||
|
darkMode: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmpty() {
|
||||||
|
const g1 = makeId();
|
||||||
|
return {
|
||||||
|
timeline: { id: makeId(), title: "Empty Timeline" },
|
||||||
|
groups: { [g1]: { id: g1, title: "Untitled Group", sortOrder: 0 } },
|
||||||
|
items: {},
|
||||||
|
groupOrder: [g1],
|
||||||
|
selectedItemId: null,
|
||||||
|
darkMode: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bridge state
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
let currentState = buildFewItems();
|
||||||
|
let updateStateCallback = null;
|
||||||
|
|
||||||
|
window.__zendegi__ = {
|
||||||
|
getState: () => JSON.stringify(currentState),
|
||||||
|
onEvent: (json) => {
|
||||||
|
const event = JSON.parse(json);
|
||||||
|
logEvent(event);
|
||||||
|
handleEvent(event);
|
||||||
|
},
|
||||||
|
set updateState(cb) { updateStateCallback = cb; },
|
||||||
|
get updateState() { return updateStateCallback; },
|
||||||
|
};
|
||||||
|
|
||||||
|
function pushState(state) {
|
||||||
|
currentState = state;
|
||||||
|
if (updateStateCallback) {
|
||||||
|
updateStateCallback(JSON.stringify(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Event handling
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function handleEvent(event) {
|
||||||
|
switch (event.type) {
|
||||||
|
case "content_height":
|
||||||
|
// No-op in dev page — flex layout handles sizing.
|
||||||
|
// The event is still logged via logEvent().
|
||||||
|
break;
|
||||||
|
case "entry_moved": {
|
||||||
|
const { entryId, newStart, newEnd, newGroupId, newLane } = event.payload;
|
||||||
|
const item = currentState.items[entryId];
|
||||||
|
if (item) {
|
||||||
|
currentState.items[entryId] = {
|
||||||
|
...item,
|
||||||
|
start: newStart,
|
||||||
|
end: newEnd,
|
||||||
|
groupId: newGroupId,
|
||||||
|
lane: newLane,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "entry_resized": {
|
||||||
|
const { entryId, newStart, newEnd, groupId, lane } = event.payload;
|
||||||
|
const item = currentState.items[entryId];
|
||||||
|
if (item) {
|
||||||
|
currentState.items[entryId] = {
|
||||||
|
...item,
|
||||||
|
start: newStart,
|
||||||
|
end: newEnd,
|
||||||
|
groupId: groupId,
|
||||||
|
lane: lane,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "item_selected":
|
||||||
|
currentState.selectedItemId = event.payload.itemId;
|
||||||
|
pushState(currentState);
|
||||||
|
break;
|
||||||
|
case "item_deselected":
|
||||||
|
currentState.selectedItemId = null;
|
||||||
|
pushState(currentState);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Event log
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function logEvent(event) {
|
||||||
|
const log = document.getElementById("event-log");
|
||||||
|
const entry = document.createElement("div");
|
||||||
|
entry.className = "entry";
|
||||||
|
const now = new Date().toLocaleTimeString("en-GB", { hour12: false });
|
||||||
|
const payloadStr = event.payload ? " " + JSON.stringify(event.payload) : "";
|
||||||
|
entry.innerHTML =
|
||||||
|
`<span class="time">${now}</span>` +
|
||||||
|
`<span class="type">${event.type}</span>` +
|
||||||
|
`<span class="payload">${payloadStr}</span>`;
|
||||||
|
log.appendChild(entry);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Toolbar actions
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setPageTheme(isDark) {
|
||||||
|
const body = document.body;
|
||||||
|
const toolbar = document.getElementById("toolbar");
|
||||||
|
const eventLog = document.getElementById("event-log");
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
body.style.background = "#1a1a2e";
|
||||||
|
body.style.color = "#e0e0e0";
|
||||||
|
toolbar.style.background = "#16213e";
|
||||||
|
toolbar.style.borderBottomColor = "#0f3460";
|
||||||
|
eventLog.style.background = "#0d1117";
|
||||||
|
eventLog.style.borderTopColor = "#0f3460";
|
||||||
|
for (const btn of toolbar.querySelectorAll("button")) {
|
||||||
|
btn.style.background = "#1a1a2e";
|
||||||
|
btn.style.color = "#e0e0e0";
|
||||||
|
btn.style.borderColor = "#0f3460";
|
||||||
|
}
|
||||||
|
for (const sep of toolbar.querySelectorAll(".sep")) {
|
||||||
|
sep.style.background = "#0f3460";
|
||||||
|
}
|
||||||
|
for (const lbl of toolbar.querySelectorAll("label")) {
|
||||||
|
lbl.style.color = "#8899aa";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.style.background = "#f0f0f0";
|
||||||
|
body.style.color = "#333";
|
||||||
|
toolbar.style.background = "#e0e0e0";
|
||||||
|
toolbar.style.borderBottomColor = "#ccc";
|
||||||
|
eventLog.style.background = "#f5f5f5";
|
||||||
|
eventLog.style.borderTopColor = "#ccc";
|
||||||
|
for (const btn of toolbar.querySelectorAll("button")) {
|
||||||
|
btn.style.background = "#fff";
|
||||||
|
btn.style.color = "#333";
|
||||||
|
btn.style.borderColor = "#ccc";
|
||||||
|
}
|
||||||
|
for (const sep of toolbar.querySelectorAll(".sep")) {
|
||||||
|
sep.style.background = "#ccc";
|
||||||
|
}
|
||||||
|
for (const lbl of toolbar.querySelectorAll("label")) {
|
||||||
|
lbl.style.color = "#666";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("btn-dark").addEventListener("click", () => {
|
||||||
|
currentState.darkMode = true;
|
||||||
|
setPageTheme(true);
|
||||||
|
pushState(currentState);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-light").addEventListener("click", () => {
|
||||||
|
currentState.darkMode = false;
|
||||||
|
setPageTheme(false);
|
||||||
|
pushState(currentState);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-few").addEventListener("click", () => {
|
||||||
|
const state = buildFewItems();
|
||||||
|
state.darkMode = currentState.darkMode;
|
||||||
|
pushState(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-many").addEventListener("click", () => {
|
||||||
|
const state = buildManyItems();
|
||||||
|
state.darkMode = currentState.darkMode;
|
||||||
|
pushState(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-empty").addEventListener("click", () => {
|
||||||
|
const state = buildEmpty();
|
||||||
|
state.darkMode = currentState.darkMode;
|
||||||
|
pushState(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-push").addEventListener("click", () => {
|
||||||
|
pushState(currentState);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-clear-log").addEventListener("click", () => {
|
||||||
|
document.getElementById("event-log").innerHTML = "";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Intercept Flutter bootstrap to render into #flutter-container
|
||||||
|
window._flutter = {};
|
||||||
|
let _loaderInstance = null;
|
||||||
|
Object.defineProperty(window._flutter, 'loader', {
|
||||||
|
set(loader) {
|
||||||
|
const origLoad = loader.load.bind(loader);
|
||||||
|
loader.load = (opts = {}) => origLoad({
|
||||||
|
...opts,
|
||||||
|
onEntrypointLoaded: async (engineInitializer) => {
|
||||||
|
const appRunner = await engineInitializer.initializeEngine({
|
||||||
|
hostElement: document.getElementById('flutter-container'),
|
||||||
|
});
|
||||||
|
appRunner.runApp();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
_loaderInstance = loader;
|
||||||
|
},
|
||||||
|
get() { return _loaderInstance; },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user