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
|
||||
**/.claude/**
|
||||
**/public/flutter/**
|
||||
**/z-timeline/**
|
||||
**/z-flutter/**
|
||||
|
||||
@@ -28,60 +28,52 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type FlutterViewProps = {
|
||||
state: FlutterTimelineState;
|
||||
onEvent: (event: FlutterEvent) => void;
|
||||
className?: string;
|
||||
height?: number;
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level singleton state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FlutterView({
|
||||
state,
|
||||
onEvent,
|
||||
className,
|
||||
height,
|
||||
}: FlutterViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
||||
"loading"
|
||||
);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
let flutterContainer: HTMLDivElement | null = null;
|
||||
let engineReady = false;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
const onEventRef = useRef(onEvent);
|
||||
onEventRef.current = onEvent;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let pollingTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
// 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(stateRef.current),
|
||||
getState: () => JSON.stringify(currentStateRef),
|
||||
onEvent: (json: string) => {
|
||||
const event = JSON.parse(json) as FlutterEvent;
|
||||
onEventRef.current(event);
|
||||
currentOnEventRef?.(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
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"]')) {
|
||||
// Script tag exists but hasn't finished — wait for _flutter to appear
|
||||
pollingTimeout = setTimeout(() => {
|
||||
clearInterval(pollingInterval);
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject(
|
||||
new Error("Timed out waiting for flutter.js to initialize")
|
||||
new Error("Timed out waiting for flutter.js to initialize"),
|
||||
);
|
||||
}, 10_000);
|
||||
pollingInterval = setInterval(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (window._flutter) {
|
||||
clearInterval(pollingInterval);
|
||||
clearTimeout(pollingTimeout);
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
@@ -105,7 +97,13 @@ export function FlutterView({
|
||||
window._flutter!.buildConfig = await res.json();
|
||||
}
|
||||
|
||||
try {
|
||||
// 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/",
|
||||
@@ -113,27 +111,81 @@ export function FlutterView({
|
||||
},
|
||||
onEntrypointLoaded: async (engineInitializer) => {
|
||||
const appRunner = await engineInitializer.initializeEngine({
|
||||
hostElement: container,
|
||||
hostElement: flutterContainer!,
|
||||
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"));
|
||||
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 = {
|
||||
state: FlutterTimelineState;
|
||||
onEvent: (event: FlutterEvent) => void;
|
||||
className?: string;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export function FlutterView({
|
||||
state,
|
||||
onEvent,
|
||||
className,
|
||||
height,
|
||||
}: FlutterViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
||||
engineReady ? "ready" : "loading",
|
||||
);
|
||||
|
||||
// Keep module-level refs in sync
|
||||
currentStateRef = state;
|
||||
currentOnEventRef = onEvent;
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = containerRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
if (engineReady && flutterContainer) {
|
||||
// Engine already running — reparent immediately, no loading flash
|
||||
wrapper.appendChild(flutterContainer);
|
||||
setStatus("ready");
|
||||
// Push latest state so Flutter renders the correct timeline
|
||||
window.__zendegi__?.updateState?.(JSON.stringify(currentStateRef));
|
||||
} else {
|
||||
// First mount — initialize engine
|
||||
setStatus("loading");
|
||||
initFlutter()
|
||||
.then(() => {
|
||||
// Guard: component may have unmounted while awaiting
|
||||
if (!containerRef.current || !flutterContainer) return;
|
||||
wrapper.appendChild(flutterContainer);
|
||||
setStatus("ready");
|
||||
// Push current state after engine is ready
|
||||
window.__zendegi__?.updateState?.(JSON.stringify(currentStateRef));
|
||||
})
|
||||
.catch(() => setStatus("error"));
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(pollingInterval);
|
||||
clearTimeout(pollingTimeout);
|
||||
container.replaceChildren();
|
||||
delete (window as Partial<Window>).__zendegi__;
|
||||
// Detach but don't destroy the Flutter container
|
||||
if (flutterContainer?.parentElement === wrapper) {
|
||||
wrapper.removeChild(flutterContainer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "./ui/button";
|
||||
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
|
||||
import { inputClasses } from "./ui/input";
|
||||
import {
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
@@ -12,63 +13,101 @@ import {
|
||||
DrawerPortal,
|
||||
DrawerRoot,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
DrawerViewport,
|
||||
} 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";
|
||||
|
||||
export default function ItemFormDrawer({
|
||||
timelineGroupId,
|
||||
timelineId,
|
||||
}: {
|
||||
timelineGroupId: string;
|
||||
type ItemFormDrawerProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
timelineId: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
groups: Record<string, NormalizedGroup | undefined>;
|
||||
groupOrder: Array<string>;
|
||||
editItem?: NormalizedItem | null;
|
||||
};
|
||||
|
||||
export default function ItemFormDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
timelineId,
|
||||
groups,
|
||||
groupOrder,
|
||||
editItem,
|
||||
}: ItemFormDrawerProps) {
|
||||
const isEdit = !!editItem;
|
||||
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({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
start: new Date().toISOString().split("T")[0],
|
||||
end: "",
|
||||
},
|
||||
defaultValues: getDefaultValues(),
|
||||
onSubmit: async ({ value }) => {
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateTimelineItem({
|
||||
data: {
|
||||
id: editItem.id,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
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 || undefined,
|
||||
timelineGroupId,
|
||||
end: value.end || null,
|
||||
timelineGroupId: value.groupId,
|
||||
},
|
||||
});
|
||||
toast.success("Item created");
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["timeline", timelineId],
|
||||
});
|
||||
setOpen(false);
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
toast.success("Item created");
|
||||
} catch (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 (
|
||||
<DrawerRoot open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger render={<Button variant="outline" size="sm" />}>
|
||||
Add Item
|
||||
</DrawerTrigger>
|
||||
<DrawerRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerPortal>
|
||||
<DrawerViewport>
|
||||
<DrawerPopup>
|
||||
<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" />}>
|
||||
Close
|
||||
</DrawerClose>
|
||||
@@ -122,6 +161,26 @@ export default function ItemFormDrawer({
|
||||
)}
|
||||
</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">
|
||||
{(field) => (
|
||||
<FieldRoot>
|
||||
@@ -168,7 +227,13 @@ export default function ItemFormDrawer({
|
||||
type="submit"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Creating..." : "Create Item"}
|
||||
{state.isSubmitting
|
||||
? isEdit
|
||||
? "Saving..."
|
||||
: "Creating..."
|
||||
: isEdit
|
||||
? "Save Changes"
|
||||
: "Create Item"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
|
||||
@@ -26,7 +26,14 @@ const { viewport, popup, title, description, close, content } = drawerStyles();
|
||||
type DrawerRootProps = React.ComponentProps<typeof BaseDrawer.Root>;
|
||||
|
||||
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>;
|
||||
|
||||
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)),
|
||||
end: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) => (s ? new Date(s) : undefined)),
|
||||
.nullable()
|
||||
.transform((s) => (s ? new Date(s) : null)),
|
||||
timelineGroupId: z.string().uuid(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -17,6 +17,8 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
|
||||
.transform((s) => (s ? new Date(s) : null)),
|
||||
timelineGroupId: z.string().uuid(),
|
||||
lane: z.number().int().min(1),
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.handler(async ({ data }) => {
|
||||
@@ -27,6 +29,10 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
|
||||
end: data.end,
|
||||
timelineGroupId: data.timelineGroupId,
|
||||
lane: data.lane,
|
||||
...(data.title !== undefined && { title: data.title }),
|
||||
...(data.description !== undefined && {
|
||||
description: data.description,
|
||||
}),
|
||||
})
|
||||
.where(eq(timelineItem.id, data.id))
|
||||
.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.
|
||||
*/
|
||||
export function assignLane(
|
||||
existingItems: { start: Date; end: Date | null; lane: number }[],
|
||||
existingItems: Array<{ start: Date; end: Date | null; lane: number }>,
|
||||
newStart: Date,
|
||||
newEnd: Date | null
|
||||
): number {
|
||||
|
||||
@@ -55,4 +55,14 @@ export type FlutterEvent =
|
||||
newGroupId: string;
|
||||
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.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TimelinesRouteImport } from './routes/timelines'
|
||||
import { Route as TimelineRouteImport } from './routes/timeline'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as DemoRouteImport } from './routes/demo'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
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 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 DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.well-known]/oauth-protected-resource'
|
||||
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 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({
|
||||
id: '/timeline',
|
||||
path: '/timeline',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoRoute = DemoRouteImport.update({
|
||||
id: '/demo',
|
||||
path: '/demo',
|
||||
@@ -54,16 +45,30 @@ const ConsentRoute = ConsentRouteImport.update({
|
||||
path: '/consent',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
const DefaultRoute = DefaultRouteImport.update({
|
||||
id: '/_default',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DefaultIndexRoute = DefaultIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
getParentRoute: () => DefaultRoute,
|
||||
} as any)
|
||||
const TimelineTimelineIdRoute = TimelineTimelineIdRouteImport.update({
|
||||
id: '/$timelineId',
|
||||
path: '/$timelineId',
|
||||
getParentRoute: () => TimelineRoute,
|
||||
} 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 =
|
||||
DotwellKnownOpenidConfigurationRouteImport.update({
|
||||
id: '/.well-known/openid-configuration',
|
||||
@@ -100,50 +105,51 @@ const DotwellKnownOauthAuthorizationServerApiAuthRoute =
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/': typeof DefaultIndexRoute
|
||||
'/consent': typeof ConsentRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/timeline': typeof TimelineRouteWithChildren
|
||||
'/timelines': typeof TimelinesRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/login': typeof DefaultLoginRoute
|
||||
'/timelines': typeof DefaultTimelinesRoute
|
||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/consent': typeof ConsentRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/timeline': typeof TimelineRouteWithChildren
|
||||
'/timelines': typeof TimelinesRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/login': typeof DefaultLoginRoute
|
||||
'/timelines': typeof DefaultTimelinesRoute
|
||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||
'/': typeof DefaultIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/_default': typeof DefaultRouteWithChildren
|
||||
'/consent': typeof ConsentRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/timeline': typeof TimelineRouteWithChildren
|
||||
'/timelines': typeof TimelinesRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/_default/login': typeof DefaultLoginRoute
|
||||
'/_default/timelines': typeof DefaultTimelinesRoute
|
||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||
'/_default/': typeof DefaultIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
@@ -155,58 +161,57 @@ export interface FileRouteTypes {
|
||||
| '/consent'
|
||||
| '/dashboard'
|
||||
| '/demo'
|
||||
| '/login'
|
||||
| '/timeline'
|
||||
| '/timelines'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/oauth-protected-resource'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/login'
|
||||
| '/timelines'
|
||||
| '/timeline/$timelineId'
|
||||
| '/api/auth/$'
|
||||
| '/api/mcp/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/consent'
|
||||
| '/dashboard'
|
||||
| '/demo'
|
||||
| '/login'
|
||||
| '/timeline'
|
||||
| '/timelines'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/oauth-protected-resource'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/login'
|
||||
| '/timelines'
|
||||
| '/timeline/$timelineId'
|
||||
| '/'
|
||||
| '/api/auth/$'
|
||||
| '/api/mcp/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_default'
|
||||
| '/consent'
|
||||
| '/dashboard'
|
||||
| '/demo'
|
||||
| '/login'
|
||||
| '/timeline'
|
||||
| '/timelines'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/oauth-protected-resource'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/_default/login'
|
||||
| '/_default/timelines'
|
||||
| '/timeline/$timelineId'
|
||||
| '/_default/'
|
||||
| '/api/auth/$'
|
||||
| '/api/mcp/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DefaultRoute: typeof DefaultRouteWithChildren
|
||||
ConsentRoute: typeof ConsentRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
DemoRoute: typeof DemoRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
TimelineRoute: typeof TimelineRouteWithChildren
|
||||
TimelinesRoute: typeof TimelinesRoute
|
||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRoute
|
||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
||||
@@ -216,13 +221,6 @@ export interface RootRouteChildren {
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/timelines': {
|
||||
id: '/timelines'
|
||||
path: '/timelines'
|
||||
fullPath: '/timelines'
|
||||
preLoaderRoute: typeof TimelinesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/timeline': {
|
||||
id: '/timeline'
|
||||
path: '/timeline'
|
||||
@@ -230,13 +228,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof TimelineRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo': {
|
||||
id: '/demo'
|
||||
path: '/demo'
|
||||
@@ -258,12 +249,19 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ConsentRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
'/_default': {
|
||||
id: '/_default'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof DefaultRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_default/': {
|
||||
id: '/_default/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
preLoaderRoute: typeof DefaultIndexRouteImport
|
||||
parentRoute: typeof DefaultRoute
|
||||
}
|
||||
'/timeline/$timelineId': {
|
||||
id: '/timeline/$timelineId'
|
||||
@@ -272,6 +270,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof TimelineTimelineIdRouteImport
|
||||
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': {
|
||||
id: '/.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 {
|
||||
TimelineTimelineIdRoute: typeof TimelineTimelineIdRoute
|
||||
}
|
||||
@@ -345,13 +372,11 @@ const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DefaultRoute: DefaultRouteWithChildren,
|
||||
ConsentRoute: ConsentRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
DemoRoute: DemoRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
TimelineRoute: TimelineRouteWithChildren,
|
||||
TimelinesRoute: TimelinesRoute,
|
||||
DotwellKnownOauthAuthorizationServerRoute:
|
||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||
DotwellKnownOauthProtectedResourceRoute:
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
createRootRouteWithContext,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
|
||||
import Header from "../components/header";
|
||||
import appCss from "../index.css?url";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@@ -47,7 +45,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
|
||||
|
||||
function RootDocument() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<HeadContent />
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
@@ -55,7 +53,6 @@ function RootDocument() {
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<div className="grid min-h-svh grid-rows-[auto_1fr]">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
FieldRoot,
|
||||
} from "@/components/ui/field";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
export const Route = createFileRoute("/_default/")({
|
||||
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 { createFileRoute } from "@tanstack/react-router";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Moon, Plus, Sun } from "lucide-react";
|
||||
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
|
||||
import { timelineQueryOptions } from "@/functions/get-timeline";
|
||||
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 { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
export const Route = createFileRoute("/timeline/$timelineId")({
|
||||
@@ -20,9 +25,15 @@ function RouteComponent() {
|
||||
const { timelineId } = Route.useParams();
|
||||
const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
|
||||
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(
|
||||
() => ({
|
||||
@@ -57,23 +68,71 @@ function RouteComponent() {
|
||||
break;
|
||||
case "item_selected":
|
||||
setSelectedItemId(event.payload.itemId);
|
||||
setDrawerOpen(true);
|
||||
break;
|
||||
case "item_deselected":
|
||||
setSelectedItemId(null);
|
||||
setDrawerOpen(false);
|
||||
break;
|
||||
case "entry_moved":
|
||||
entryMoved.mutate(event.payload);
|
||||
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 (
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl font-serif font-bold mb-6 mx-4">
|
||||
{timeline.title}
|
||||
</h1>
|
||||
<div className="flex flex-row items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
state={flutterState}
|
||||
@@ -81,6 +140,15 @@ function RouteComponent() {
|
||||
className="overflow-hidden"
|
||||
height={flutterHeight}
|
||||
/>
|
||||
|
||||
<ItemFormDrawer
|
||||
open={drawerOpen}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
timelineId={timelineId}
|
||||
groups={timeline.groups}
|
||||
groupOrder={timeline.groupOrder}
|
||||
editItem={editItem}
|
||||
/>
|
||||
</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";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths(), tailwindcss(), tanstackStart(), viteReact()],
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
tailwindcss(),
|
||||
tanstackStart({
|
||||
server: { entry: "./entry-server.ts" },
|
||||
}),
|
||||
viteReact(),
|
||||
],
|
||||
server: {
|
||||
port: 3001,
|
||||
headers: {
|
||||
"Cross-Origin-Embedder-Policy": "credentialless",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ interface ItemForLane {
|
||||
}
|
||||
|
||||
function assignLane(
|
||||
existing: ItemForLane[],
|
||||
existing: Array<ItemForLane>,
|
||||
newStart: Date,
|
||||
newEnd: Date | null
|
||||
): number {
|
||||
@@ -57,7 +57,7 @@ async function main() {
|
||||
|
||||
if (items.length === 0) continue;
|
||||
|
||||
const assigned: ItemForLane[] = [];
|
||||
const assigned: Array<ItemForLane> = [];
|
||||
let updated = 0;
|
||||
|
||||
for (const item of items) {
|
||||
|
||||
@@ -125,6 +125,14 @@ class _MainAppState extends State<MainApp> {
|
||||
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(
|
||||
TimelineEntry entry,
|
||||
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() {
|
||||
// Start with the fixed chrome heights.
|
||||
var totalHeight = _tieredHeaderHeight + _breadcrumbHeight;
|
||||
@@ -213,6 +262,74 @@ class _MainAppState extends State<MainApp> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final viewport = _viewport;
|
||||
@@ -228,12 +345,16 @@ class _MainAppState extends State<MainApp> {
|
||||
? const Center(child: Text('Waiting for state...'))
|
||||
: ZTimelineScope(
|
||||
viewport: viewport,
|
||||
child: EntryPopoverOverlay(
|
||||
popoverContentBuilder: _buildPopoverContent,
|
||||
compactDateBuilder: _buildCompactDate,
|
||||
child: Column(
|
||||
children: [
|
||||
const ZTimelineBreadcrumb(),
|
||||
const ZTimelineTieredHeader(),
|
||||
Expanded(
|
||||
child: ZTimelineInteractor(
|
||||
onBackgroundTap: _onBackgroundTap,
|
||||
child: ZTimelineView(
|
||||
groups: _groups,
|
||||
entries: _entries,
|
||||
@@ -242,6 +363,9 @@ class _MainAppState extends State<MainApp> {
|
||||
colorBuilder: _colorForEntry,
|
||||
enableDrag: true,
|
||||
onEntryMoved: _onEntryMoved,
|
||||
onEntryResized: _onEntryResized,
|
||||
onEntrySelected: _onEntrySelected,
|
||||
selectedEntryId: _state?.selectedItemId,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -249,6 +373,7 @@ class _MainAppState extends State<MainApp> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"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 {
|
||||
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
|
||||
static const double laneHeight = 28.0;
|
||||
static const double laneHeight = pillHeight;
|
||||
static const double groupHeaderHeight = 34.0;
|
||||
|
||||
// Spacing
|
||||
@@ -19,6 +31,15 @@ class ZTimelineConstants {
|
||||
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
|
||||
static const double minContentWidth = 1200.0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'timeline_entry.dart';
|
||||
|
||||
/// Which edge of the pill is being resized.
|
||||
enum ResizeEdge { start, end }
|
||||
|
||||
/// Immutable state tracking during resize operations.
|
||||
///
|
||||
/// Captures the entry being resized, which edge, and the target bounds.
|
||||
@immutable
|
||||
class EntryResizeState {
|
||||
const EntryResizeState({
|
||||
required this.entryId,
|
||||
required this.originalEntry,
|
||||
required this.edge,
|
||||
required this.targetStart,
|
||||
required this.targetEnd,
|
||||
required this.targetLane,
|
||||
});
|
||||
|
||||
/// The ID of the entry being resized.
|
||||
final String entryId;
|
||||
|
||||
/// The original entry (for reverting / reference).
|
||||
final TimelineEntry originalEntry;
|
||||
|
||||
/// Which edge the user is dragging.
|
||||
final ResizeEdge edge;
|
||||
|
||||
/// The current target start time.
|
||||
final DateTime targetStart;
|
||||
|
||||
/// The current target end time.
|
||||
final DateTime targetEnd;
|
||||
|
||||
/// The resolved lane (collision-aware).
|
||||
final int targetLane;
|
||||
|
||||
EntryResizeState copyWith({
|
||||
String? entryId,
|
||||
TimelineEntry? originalEntry,
|
||||
ResizeEdge? edge,
|
||||
DateTime? targetStart,
|
||||
DateTime? targetEnd,
|
||||
int? targetLane,
|
||||
}) {
|
||||
return EntryResizeState(
|
||||
entryId: entryId ?? this.entryId,
|
||||
originalEntry: originalEntry ?? this.originalEntry,
|
||||
edge: edge ?? this.edge,
|
||||
targetStart: targetStart ?? this.targetStart,
|
||||
targetEnd: targetEnd ?? this.targetEnd,
|
||||
targetLane: targetLane ?? this.targetLane,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is EntryResizeState &&
|
||||
other.entryId == entryId &&
|
||||
other.originalEntry == originalEntry &&
|
||||
other.edge == edge &&
|
||||
other.targetStart == targetStart &&
|
||||
other.targetEnd == targetEnd &&
|
||||
other.targetLane == targetLane;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
entryId,
|
||||
originalEntry,
|
||||
edge,
|
||||
targetStart,
|
||||
targetEnd,
|
||||
targetLane,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'EntryResizeState('
|
||||
'entryId: $entryId, '
|
||||
'edge: $edge, '
|
||||
'targetStart: $targetStart, '
|
||||
'targetEnd: $targetEnd, '
|
||||
'targetLane: $targetLane)';
|
||||
}
|
||||
@@ -9,22 +9,27 @@ class ZTimelineInteractionState {
|
||||
const ZTimelineInteractionState({
|
||||
this.isGrabbing = false,
|
||||
this.isDraggingEntry = false,
|
||||
this.isResizingEntry = false,
|
||||
});
|
||||
|
||||
/// Whether the user is actively panning (for cursor feedback).
|
||||
final bool isGrabbing;
|
||||
|
||||
/// Whether an entry is being dragged (disables pan gesture).
|
||||
/// This will be used by future drag-and-drop functionality.
|
||||
final bool isDraggingEntry;
|
||||
|
||||
/// Whether an entry is being resized (disables pan gesture).
|
||||
final bool isResizingEntry;
|
||||
|
||||
ZTimelineInteractionState copyWith({
|
||||
bool? isGrabbing,
|
||||
bool? isDraggingEntry,
|
||||
bool? isResizingEntry,
|
||||
}) {
|
||||
return ZTimelineInteractionState(
|
||||
isGrabbing: isGrabbing ?? this.isGrabbing,
|
||||
isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry,
|
||||
isResizingEntry: isResizingEntry ?? this.isResizingEntry,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,9 +38,10 @@ class ZTimelineInteractionState {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ZTimelineInteractionState &&
|
||||
other.isGrabbing == isGrabbing &&
|
||||
other.isDraggingEntry == isDraggingEntry;
|
||||
other.isDraggingEntry == isDraggingEntry &&
|
||||
other.isResizingEntry == isResizingEntry;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isGrabbing, isDraggingEntry);
|
||||
int get hashCode => Object.hash(isGrabbing, isDraggingEntry, isResizingEntry);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
///
|
||||
/// 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.
|
||||
/// What gets passed to [Positioned] widgets.
|
||||
@@ -35,8 +35,7 @@ class LayoutCoordinateService {
|
||||
required double normalizedWidth,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
return (normalizedWidth * contentWidth)
|
||||
.clamp(0.0, double.infinity);
|
||||
return (normalizedWidth * contentWidth).clamp(0.0, double.infinity);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -62,8 +61,7 @@ class LayoutCoordinateService {
|
||||
required double widgetX,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
final adjustedX =
|
||||
(widgetX).clamp(0.0, contentWidth);
|
||||
final adjustedX = (widgetX).clamp(0.0, contentWidth);
|
||||
return contentWidth == 0 ? 0.0 : adjustedX / contentWidth;
|
||||
}
|
||||
|
||||
@@ -75,10 +73,7 @@ class LayoutCoordinateService {
|
||||
///
|
||||
/// Used by all positioned elements (pills, ghost overlay) within the
|
||||
/// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0.
|
||||
static double laneToY({
|
||||
required int lane,
|
||||
required double laneHeight,
|
||||
}) {
|
||||
static double laneToY({required int lane, required double laneHeight}) {
|
||||
return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||
}
|
||||
|
||||
@@ -86,10 +81,7 @@ class LayoutCoordinateService {
|
||||
///
|
||||
/// Used by drop targets to determine which lane the cursor is over.
|
||||
/// The Y coordinate should be relative to the timeline Stack.
|
||||
static int yToLane({
|
||||
required double y,
|
||||
required double laneHeight,
|
||||
}) {
|
||||
static int yToLane({required double y, required double laneHeight}) {
|
||||
final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing;
|
||||
return (y / laneStep).floor() + 1;
|
||||
}
|
||||
|
||||
@@ -286,8 +286,10 @@ class TieredTickService {
|
||||
|
||||
// Start one period before to ensure we cover partial sections at the edge
|
||||
var current = alignToUnit(startUtc, unit);
|
||||
final oneBeforeStart =
|
||||
alignToUnit(current.subtract(const Duration(milliseconds: 1)), unit);
|
||||
final oneBeforeStart = alignToUnit(
|
||||
current.subtract(const Duration(milliseconds: 1)),
|
||||
unit,
|
||||
);
|
||||
current = oneBeforeStart;
|
||||
|
||||
while (current.isBefore(endUtc)) {
|
||||
|
||||
@@ -0,0 +1,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 '../models/entry_drag_state.dart';
|
||||
import '../models/entry_resize_state.dart';
|
||||
import '../models/interaction_state.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
|
||||
@@ -13,6 +16,14 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
|
||||
ZTimelineInteractionState _state = const ZTimelineInteractionState();
|
||||
EntryDragState? _dragState;
|
||||
EntryResizeState? _resizeState;
|
||||
|
||||
// -- Hover state --
|
||||
String? _hoveredEntryId;
|
||||
Rect? _hoveredPillGlobalRect;
|
||||
|
||||
// -- Interaction cursor position (during drag/resize) --
|
||||
Offset? _interactionGlobalPosition;
|
||||
|
||||
/// The current interaction state.
|
||||
ZTimelineInteractionState get state => _state;
|
||||
@@ -20,12 +31,52 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// The current drag state, or null if no drag is active.
|
||||
EntryDragState? get dragState => _dragState;
|
||||
|
||||
/// The current resize state, or null if no resize is active.
|
||||
EntryResizeState? get resizeState => _resizeState;
|
||||
|
||||
/// Whether the user is actively panning (for cursor feedback).
|
||||
bool get isGrabbing => _state.isGrabbing;
|
||||
|
||||
/// Whether an entry is being dragged (disables pan gesture).
|
||||
bool get isDraggingEntry => _state.isDraggingEntry;
|
||||
|
||||
/// Whether an entry is being resized.
|
||||
bool get isResizingEntry => _state.isResizingEntry;
|
||||
|
||||
/// Whether any entry interaction (drag or resize) is active.
|
||||
bool get isInteracting => isDraggingEntry || isResizingEntry;
|
||||
|
||||
/// 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.
|
||||
void setGrabbing(bool value) {
|
||||
if (_state.isGrabbing == value) return;
|
||||
@@ -43,7 +94,10 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// Begin dragging an entry.
|
||||
///
|
||||
/// Sets drag state and marks [isDraggingEntry] as true.
|
||||
/// Clears hover state since the user is now dragging.
|
||||
void beginDrag(TimelineEntry entry) {
|
||||
_hoveredEntryId = null;
|
||||
_hoveredPillGlobalRect = null;
|
||||
_dragState = EntryDragState(
|
||||
entryId: entry.id,
|
||||
originalEntry: entry,
|
||||
@@ -76,17 +130,56 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// End the drag and clear state.
|
||||
void endDrag() {
|
||||
_dragState = null;
|
||||
_interactionGlobalPosition = null;
|
||||
setDraggingEntry(false);
|
||||
}
|
||||
|
||||
/// Cancel the drag (alias for [endDrag]).
|
||||
void cancelDrag() => endDrag();
|
||||
|
||||
/// Called by drag-drop system when an entry drag starts.
|
||||
@Deprecated('Use beginDrag instead')
|
||||
void beginEntryDrag() => setDraggingEntry(true);
|
||||
/// Begin resizing an entry edge.
|
||||
///
|
||||
/// 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.
|
||||
@Deprecated('Use endDrag instead')
|
||||
void endEntryDrag() => setDraggingEntry(false);
|
||||
/// Update the resize target times and optional lane.
|
||||
void updateResizeTarget({
|
||||
required DateTime targetStart,
|
||||
required DateTime targetEnd,
|
||||
int? targetLane,
|
||||
}) {
|
||||
if (_resizeState == null) return;
|
||||
final newState = _resizeState!.copyWith(
|
||||
targetStart: targetStart,
|
||||
targetEnd: targetEnd,
|
||||
targetLane: targetLane,
|
||||
);
|
||||
if (newState == _resizeState) return;
|
||||
_resizeState = newState;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// End the resize and clear state.
|
||||
void endResize() {
|
||||
_resizeState = null;
|
||||
_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),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: segment.isVisible ? _buildChip(context) : const SizedBox.shrink(),
|
||||
child: segment.isVisible
|
||||
? _buildChip(context)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -45,14 +47,16 @@ class BreadcrumbSegmentChip extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
segment.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,77 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/entry_drag_state.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'event_pill.dart';
|
||||
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
|
||||
/// target position.
|
||||
class GhostOverlay extends StatelessWidget {
|
||||
const GhostOverlay({
|
||||
required this.dragState,
|
||||
/// Renders the actual [EventPill] or [EventPoint] at full opacity so the
|
||||
/// preview looks identical to the real item.
|
||||
class DragPreview extends StatelessWidget {
|
||||
const DragPreview({
|
||||
required this.targetStart,
|
||||
required this.targetEnd,
|
||||
required this.targetLane,
|
||||
required this.viewport,
|
||||
required this.contentWidth,
|
||||
required this.laneHeight,
|
||||
required this.color,
|
||||
required this.label,
|
||||
this.hasEnd = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final EntryDragState dragState;
|
||||
final DateTime targetStart;
|
||||
final DateTime targetEnd;
|
||||
final int targetLane;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final double contentWidth;
|
||||
final double laneHeight;
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
/// Whether this is a range event. When false, renders [EventPoint].
|
||||
final bool hasEnd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
dragState.targetStart,
|
||||
targetStart,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
final endX = TimeScaleService.mapTimeToPosition(
|
||||
dragState.targetEnd,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: targetLane,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
|
||||
// Use centralized coordinate service to ensure ghost matches pill layout
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: startX,
|
||||
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(
|
||||
normalizedWidth: endX - startX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: dragState.targetLane,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Positioned(
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
top: top,
|
||||
height: laneHeight,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
),
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.6),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: IgnorePointer(child: EventPill(color: color, label: label)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
const _BreadcrumbSegmentRow({
|
||||
required this.segments,
|
||||
required this.viewport,
|
||||
});
|
||||
const _BreadcrumbSegmentRow({required this.segments, required this.viewport});
|
||||
|
||||
final List<BreadcrumbSegment> segments;
|
||||
final TimelineViewportNotifier viewport;
|
||||
@@ -173,9 +170,9 @@ class _ZoomLevelIndicator extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
level.label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class ZTimelineInteractor extends StatefulWidget {
|
||||
const ZTimelineInteractor({
|
||||
required this.child,
|
||||
this.autofocus = true,
|
||||
this.onBackgroundTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -36,6 +37,9 @@ class ZTimelineInteractor extends StatefulWidget {
|
||||
/// Whether to automatically focus this widget for keyboard input.
|
||||
final bool autofocus;
|
||||
|
||||
/// Called when the user taps empty space (not on a pill/entry).
|
||||
final VoidCallback? onBackgroundTap;
|
||||
|
||||
@override
|
||||
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
|
||||
}
|
||||
@@ -88,7 +92,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||
// Single-finger pan
|
||||
else if (details.pointerCount == 1 &&
|
||||
config.enablePan &&
|
||||
!scope.interaction.isDraggingEntry) {
|
||||
!scope.interaction.isInteracting) {
|
||||
if (_lastFocalPoint != null) {
|
||||
final diff = details.focalPoint - _lastFocalPoint!;
|
||||
final ratio = -diff.dx / width;
|
||||
@@ -221,8 +225,9 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||
listenable: scope.interaction,
|
||||
builder: (context, child) {
|
||||
return MouseRegion(
|
||||
cursor:
|
||||
scope.interaction.isGrabbing ||
|
||||
cursor: scope.interaction.isResizingEntry
|
||||
? SystemMouseCursors.resizeColumn
|
||||
: scope.interaction.isGrabbing ||
|
||||
scope.interaction.isDraggingEntry
|
||||
? SystemMouseCursors.grabbing
|
||||
: SystemMouseCursors.basic,
|
||||
@@ -230,6 +235,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: widget.onBackgroundTap,
|
||||
onScaleStart: _handleScaleStart,
|
||||
onScaleUpdate: _handleScaleUpdate,
|
||||
onScaleEnd: _handleScaleEnd,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/interaction_config.dart';
|
||||
import '../services/timeline_group_registry.dart';
|
||||
import '../state/timeline_interaction_notifier.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
|
||||
@@ -61,11 +62,13 @@ class ZTimelineScope extends StatefulWidget {
|
||||
|
||||
class _ZTimelineScopeState extends State<ZTimelineScope> {
|
||||
late final ZTimelineInteractionNotifier _interactionNotifier;
|
||||
late final TimelineGroupRegistry _groupRegistry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_interactionNotifier = ZTimelineInteractionNotifier();
|
||||
_groupRegistry = TimelineGroupRegistry();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -81,6 +84,7 @@ class _ZTimelineScopeState extends State<ZTimelineScope> {
|
||||
viewport: widget.viewport,
|
||||
interaction: _interactionNotifier,
|
||||
config: widget.config,
|
||||
groupRegistry: _groupRegistry,
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
@@ -105,6 +109,7 @@ class ZTimelineScopeData {
|
||||
required this.viewport,
|
||||
required this.interaction,
|
||||
required this.config,
|
||||
required this.groupRegistry,
|
||||
});
|
||||
|
||||
/// The viewport notifier for domain state (start/end times).
|
||||
@@ -116,15 +121,19 @@ class ZTimelineScopeData {
|
||||
/// Configuration for interaction behavior.
|
||||
final ZTimelineInteractionConfig config;
|
||||
|
||||
/// Registry for group lane areas, used for cross-group hit detection.
|
||||
final TimelineGroupRegistry groupRegistry;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ZTimelineScopeData &&
|
||||
other.viewport == viewport &&
|
||||
other.interaction == interaction &&
|
||||
other.config == config;
|
||||
other.config == config &&
|
||||
other.groupRegistry == groupRegistry;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(viewport, interaction, config);
|
||||
int get hashCode => Object.hash(viewport, interaction, config, groupRegistry);
|
||||
}
|
||||
|
||||
@@ -97,8 +97,9 @@ class ZTimelineTieredHeader extends StatelessWidget {
|
||||
domainEnd: effectiveViewport.end,
|
||||
borderColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||
secondaryLabelColor:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
secondaryLabelColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -126,9 +127,9 @@ class _ConfigIndicator extends StatelessWidget {
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
'Zoom: $configName',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -155,8 +156,7 @@ class _TieredHeaderPainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final borderPaint =
|
||||
Paint()
|
||||
final borderPaint = Paint()
|
||||
..color = borderColor
|
||||
..strokeWidth = 1.0;
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.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_group.dart';
|
||||
import '../services/timeline_projection_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'draggable_event_pill.dart';
|
||||
import 'ghost_overlay.dart';
|
||||
import 'group_drop_target.dart';
|
||||
import 'drag_preview.dart';
|
||||
import 'interactive_event_pill.dart';
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
typedef EntryLabelBuilder = String Function(TimelineEntry entry);
|
||||
@@ -24,8 +22,24 @@ typedef OnEntryMoved =
|
||||
int newLane,
|
||||
);
|
||||
|
||||
/// Callback signature for when an entry is resized via edge drag.
|
||||
typedef OnEntryResized =
|
||||
void Function(
|
||||
TimelineEntry entry,
|
||||
DateTime newStart,
|
||||
DateTime newEnd,
|
||||
int newLane,
|
||||
);
|
||||
|
||||
/// 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
|
||||
/// 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 {
|
||||
const ZTimelineView({
|
||||
super.key,
|
||||
@@ -37,7 +51,10 @@ class ZTimelineView extends StatelessWidget {
|
||||
this.laneHeight = ZTimelineConstants.laneHeight,
|
||||
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
|
||||
this.onEntryMoved,
|
||||
this.onEntryResized,
|
||||
this.onEntrySelected,
|
||||
this.enableDrag = true,
|
||||
this.selectedEntryId,
|
||||
});
|
||||
|
||||
final List<TimelineGroup> groups;
|
||||
@@ -54,19 +71,45 @@ class ZTimelineView extends StatelessWidget {
|
||||
/// position. The [newLane] is calculated to avoid collisions.
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
|
||||
/// Callback invoked when an entry is resized via edge drag.
|
||||
final OnEntryResized? onEntryResized;
|
||||
|
||||
/// Callback invoked when an entry is tapped / selected.
|
||||
final OnEntrySelected? onEntrySelected;
|
||||
|
||||
/// Whether drag-and-drop is enabled.
|
||||
final bool enableDrag;
|
||||
|
||||
/// ID of the currently selected entry, if any.
|
||||
final String? selectedEntryId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: viewport,
|
||||
builder: (context, _) {
|
||||
final projected = const TimelineProjectionService().project(
|
||||
entries: entries,
|
||||
domainStart: viewport.start,
|
||||
domainEnd: viewport.end,
|
||||
);
|
||||
// Group entries by groupId and sort by lane+start.
|
||||
// Runs once per entries/groups change, NOT on every pan frame.
|
||||
final grouped = <String, List<TimelineEntry>>{};
|
||||
for (final e in entries) {
|
||||
(grouped[e.groupId] ??= []).add(e);
|
||||
}
|
||||
for (final list in grouped.values) {
|
||||
list.sort((a, b) {
|
||||
final cmp = a.lane.compareTo(b.lane);
|
||||
return cmp != 0 ? cmp : a.start.compareTo(b.start);
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-compute next-entry-in-lane lookup in O(n).
|
||||
final nextEntryInLane = <String, TimelineEntry>{};
|
||||
for (final list in grouped.values) {
|
||||
final lastInLane = <int, TimelineEntry>{};
|
||||
for (final entry in list) {
|
||||
final prev = lastInLane[entry.lane];
|
||||
if (prev != null) {
|
||||
nextEntryInLane[prev.id] = entry;
|
||||
}
|
||||
lastInLane[entry.lane] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -79,16 +122,18 @@ class ZTimelineView extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final group = groups[index];
|
||||
final groupEntries =
|
||||
projected[group.id] ?? const <ProjectedEntry>[];
|
||||
grouped[group.id] ?? const <TimelineEntry>[];
|
||||
final lanesCount = _countLanes(groupEntries);
|
||||
|
||||
Widget groupColumn = Column(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||
_GroupLanes(
|
||||
RepaintBoundary(
|
||||
child: _GroupLanes(
|
||||
group: group,
|
||||
entries: groupEntries,
|
||||
allEntries: entries,
|
||||
viewport: viewport,
|
||||
lanesCount: lanesCount,
|
||||
laneHeight: laneHeight,
|
||||
@@ -96,42 +141,26 @@ class ZTimelineView extends StatelessWidget {
|
||||
labelBuilder: labelBuilder,
|
||||
contentWidth: contentWidth,
|
||||
enableDrag: enableDrag,
|
||||
onEntryResized: onEntryResized,
|
||||
onEntryMoved: onEntryMoved,
|
||||
onEntrySelected: onEntrySelected,
|
||||
selectedEntryId: selectedEntryId,
|
||||
nextEntryInLane: nextEntryInLane,
|
||||
groupHeaderHeight: groupHeaderHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Wrap the entire group (header + lanes) in a DragTarget
|
||||
// so dragging over headers doesn't create a dead zone.
|
||||
if (enableDrag && onEntryMoved != null) {
|
||||
groupColumn = GroupDropTarget(
|
||||
group: group,
|
||||
entries: groupEntries,
|
||||
allEntries: entries,
|
||||
viewport: viewport,
|
||||
contentWidth: contentWidth,
|
||||
laneHeight: laneHeight,
|
||||
lanesCount: lanesCount,
|
||||
onEntryMoved: onEntryMoved,
|
||||
verticalOffset:
|
||||
groupHeaderHeight +
|
||||
ZTimelineConstants.verticalOuterPadding,
|
||||
child: groupColumn,
|
||||
);
|
||||
}
|
||||
|
||||
return groupColumn;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _countLanes(List<ProjectedEntry> entries) {
|
||||
int _countLanes(List<TimelineEntry> entries) {
|
||||
var maxLane = 0;
|
||||
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
|
||||
}
|
||||
@@ -162,10 +191,11 @@ class _GroupHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupLanes extends StatelessWidget {
|
||||
class _GroupLanes extends StatefulWidget {
|
||||
const _GroupLanes({
|
||||
required this.group,
|
||||
required this.entries,
|
||||
required this.allEntries,
|
||||
required this.viewport,
|
||||
required this.lanesCount,
|
||||
required this.laneHeight,
|
||||
@@ -173,10 +203,17 @@ class _GroupLanes extends StatelessWidget {
|
||||
required this.colorBuilder,
|
||||
required this.contentWidth,
|
||||
required this.enableDrag,
|
||||
required this.nextEntryInLane,
|
||||
required this.groupHeaderHeight,
|
||||
this.onEntryResized,
|
||||
this.onEntryMoved,
|
||||
this.onEntrySelected,
|
||||
this.selectedEntryId,
|
||||
});
|
||||
|
||||
final TimelineGroup group;
|
||||
final List<ProjectedEntry> entries;
|
||||
final List<TimelineEntry> entries;
|
||||
final List<TimelineEntry> allEntries;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final int lanesCount;
|
||||
final double laneHeight;
|
||||
@@ -184,27 +221,108 @@ class _GroupLanes extends StatelessWidget {
|
||||
final EntryColorBuilder colorBuilder;
|
||||
final double contentWidth;
|
||||
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
|
||||
Widget build(BuildContext 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 (scope == null || !enableDrag) {
|
||||
return _buildContent(context, lanesCount);
|
||||
if (scope == null || !widget.enableDrag) {
|
||||
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(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
final effectiveLanesCount = _calculateEffectiveLanesCount(
|
||||
actualLanesCount: lanesCount,
|
||||
actualLanesCount: widget.lanesCount,
|
||||
dragState: scope.interaction.dragState,
|
||||
groupId: group.id,
|
||||
resizeState: scope.interaction.resizeState,
|
||||
groupId: widget.group.id,
|
||||
);
|
||||
|
||||
return _buildContent(context, effectiveLanesCount);
|
||||
return _wrapContent(pillWidgets, effectiveLanesCount, scope);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -212,66 +330,47 @@ class _GroupLanes extends StatelessWidget {
|
||||
int _calculateEffectiveLanesCount({
|
||||
required int actualLanesCount,
|
||||
required EntryDragState? dragState,
|
||||
required EntryResizeState? resizeState,
|
||||
required String groupId,
|
||||
}) {
|
||||
// No drag active - use actual lane count
|
||||
if (dragState == null) {
|
||||
return actualLanesCount;
|
||||
var effective = actualLanesCount;
|
||||
|
||||
// Expand for drag target lane
|
||||
if (dragState != null && dragState.targetGroupId == groupId) {
|
||||
if (dragState.targetLane > effective) {
|
||||
effective = dragState.targetLane;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag active but over different group - use actual lane count
|
||||
if (dragState.targetGroupId != groupId) {
|
||||
return actualLanesCount;
|
||||
// Expand for resize target lane
|
||||
if (resizeState != null && resizeState.originalEntry.groupId == groupId) {
|
||||
if (resizeState.targetLane > effective) {
|
||||
effective = resizeState.targetLane;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag active over this group - expand to accommodate target lane
|
||||
return actualLanesCount > dragState.targetLane
|
||||
? actualLanesCount
|
||||
: dragState.targetLane;
|
||||
return effective;
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, int effectiveLanesCount) {
|
||||
Widget _wrapContent(
|
||||
List<Widget> pillWidgets,
|
||||
int effectiveLanesCount,
|
||||
ZTimelineScopeData? scope,
|
||||
) {
|
||||
final totalHeight =
|
||||
effectiveLanesCount * laneHeight +
|
||||
effectiveLanesCount * widget.laneHeight +
|
||||
(effectiveLanesCount > 0
|
||||
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
||||
: 0);
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
|
||||
// The inner Stack with pills and ghost overlay
|
||||
Widget innerStack = SizedBox(
|
||||
final innerStack = SizedBox(
|
||||
height: totalHeight,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Event pills
|
||||
for (final e in entries)
|
||||
DraggableEventPill(
|
||||
entry: e,
|
||||
laneHeight: laneHeight,
|
||||
labelBuilder: labelBuilder,
|
||||
colorBuilder: colorBuilder,
|
||||
contentWidth: contentWidth,
|
||||
enableDrag: enableDrag,
|
||||
),
|
||||
|
||||
// Ghost overlay (rendered in same coordinate space as pills)
|
||||
if (enableDrag && scope != null)
|
||||
ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
final dragState = scope.interaction.dragState;
|
||||
if (dragState == null || dragState.targetGroupId != group.id) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return GhostOverlay(
|
||||
dragState: dragState,
|
||||
viewport: viewport,
|
||||
contentWidth: contentWidth,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
},
|
||||
),
|
||||
...pillWidgets,
|
||||
// Ghost overlay for drag operations
|
||||
if (widget.enableDrag && scope != null) _buildDragPreview(scope),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -281,6 +380,7 @@ class _GroupLanes extends StatelessWidget {
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
key: _lanesKey,
|
||||
padding: EdgeInsets.symmetric(
|
||||
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
|
||||
export 'src/models/breadcrumb_segment.dart';
|
||||
export 'src/models/entry_drag_state.dart';
|
||||
export 'src/models/entry_resize_state.dart';
|
||||
export 'src/models/interaction_config.dart';
|
||||
export 'src/models/interaction_state.dart';
|
||||
export 'src/models/projected_entry.dart';
|
||||
export 'src/models/tier_config.dart';
|
||||
export 'src/models/tier_section.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/tiered_tick_service.dart';
|
||||
export 'src/services/time_scale_service.dart';
|
||||
export 'src/services/timeline_projection_service.dart';
|
||||
|
||||
export 'src/services/timeline_group_registry.dart';
|
||||
// State
|
||||
export 'src/state/timeline_interaction_notifier.dart';
|
||||
export 'src/state/timeline_viewport_notifier.dart';
|
||||
|
||||
// Widgets
|
||||
export 'src/widgets/breadcrumb_segment_chip.dart';
|
||||
export 'src/widgets/draggable_event_pill.dart';
|
||||
export 'src/widgets/ghost_overlay.dart';
|
||||
export 'src/widgets/group_drop_target.dart';
|
||||
export 'src/widgets/entry_popover_overlay.dart';
|
||||
export 'src/widgets/event_pill.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_interactor.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 { fileURLToPath } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const src = resolve(__dirname, "../build/web");
|
||||
@@ -9,6 +17,22 @@ const dest = resolve(__dirname, "../../../apps/web/public/flutter");
|
||||
cpSync(src, dest, { recursive: true });
|
||||
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
|
||||
const bootstrap = readFileSync(resolve(dest, "flutter_bootstrap.js"), "utf-8");
|
||||
const match = bootstrap.match(/_flutter\.buildConfig\s*=\s*({.*?});/);
|
||||
|
||||
@@ -1,46 +1,410 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="description" content="Zendegi Timeline - Dev Mode">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="z_flutter">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>z_flutter</title>
|
||||
<title>Zendegi Timeline (Dev)</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#toolbar .group { display: flex; align-items: center; gap: 4px; }
|
||||
#toolbar label { color: #8899aa; }
|
||||
#toolbar button {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
#toolbar button:hover { background: #0f3460; }
|
||||
#toolbar .sep { width: 1px; height: 20px; background: #0f3460; }
|
||||
#event-log {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
background: #0d1117;
|
||||
border-top: 1px solid #0f3460;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
z-index: 100;
|
||||
}
|
||||
#event-log .entry { padding: 2px 0; border-bottom: 1px solid #161b22; }
|
||||
#event-log .type { color: #58a6ff; }
|
||||
#event-log .time { color: #6e7681; margin-right: 8px; }
|
||||
#event-log .payload { color: #8b949e; }
|
||||
#flutter-container { flex: 1; min-height: 0; position: relative; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
<div id="toolbar">
|
||||
<div class="group">
|
||||
<label>Theme:</label>
|
||||
<button id="btn-light">Light</button>
|
||||
<button id="btn-dark">Dark</button>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="group">
|
||||
<label>Data:</label>
|
||||
<button id="btn-few">Few items</button>
|
||||
<button id="btn-many">Many items</button>
|
||||
<button id="btn-empty">Empty</button>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="group">
|
||||
<button id="btn-push">Push state</button>
|
||||
<button id="btn-clear-log">Clear log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<div id="flutter-container"></div>
|
||||
<div id="event-log"></div>
|
||||
|
||||
<script>
|
||||
// -----------------------------------------------------------------------
|
||||
// Test data generators
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function makeId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function iso(year, month, day) {
|
||||
return new Date(year, month - 1, day).toISOString();
|
||||
}
|
||||
|
||||
function buildFewItems() {
|
||||
const g1 = makeId();
|
||||
const g2 = makeId();
|
||||
const g3 = makeId();
|
||||
|
||||
const items = {};
|
||||
const addItem = (groupId, title, start, end, lane, desc) => {
|
||||
const id = makeId();
|
||||
items[id] = {
|
||||
id, groupId, title, description: desc ?? null,
|
||||
start, end: end ?? null, lane,
|
||||
};
|
||||
};
|
||||
|
||||
// Work group
|
||||
addItem(g1, "Project Alpha", iso(2026, 1, 5), iso(2026, 3, 15), 1, "Main project");
|
||||
addItem(g1, "Project Beta", iso(2026, 2, 10), iso(2026, 5, 20), 2, "Secondary project");
|
||||
addItem(g1, "Code Review", iso(2026, 3, 1), iso(2026, 3, 5), 1);
|
||||
addItem(g1, "Sprint Planning", iso(2026, 1, 15), null, 3); // point event
|
||||
|
||||
// Personal group
|
||||
addItem(g2, "Vacation", iso(2026, 4, 1), iso(2026, 4, 14), 1, "Spring break");
|
||||
addItem(g2, "Birthday", iso(2026, 6, 12), null, 1); // point event
|
||||
addItem(g2, "Move apartments", iso(2026, 3, 20), iso(2026, 3, 25), 2);
|
||||
|
||||
// Learning group
|
||||
addItem(g3, "Flutter course", iso(2026, 1, 1), iso(2026, 2, 28), 1);
|
||||
addItem(g3, "Rust book", iso(2026, 2, 15), iso(2026, 4, 30), 2);
|
||||
addItem(g3, "Conference talk", iso(2026, 5, 10), null, 1); // point event
|
||||
|
||||
return {
|
||||
timeline: { id: makeId(), title: "My Timeline" },
|
||||
groups: {
|
||||
[g1]: { id: g1, title: "Work", sortOrder: 0 },
|
||||
[g2]: { id: g2, title: "Personal", sortOrder: 1 },
|
||||
[g3]: { id: g3, title: "Learning", sortOrder: 2 },
|
||||
},
|
||||
items,
|
||||
groupOrder: [g1, g2, g3],
|
||||
selectedItemId: null,
|
||||
darkMode: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildManyItems() {
|
||||
const groupCount = 5;
|
||||
const groupIds = Array.from({ length: groupCount }, makeId);
|
||||
const groupNames = ["Engineering", "Design", "Marketing", "Operations", "Research"];
|
||||
const groups = {};
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
groups[groupIds[i]] = { id: groupIds[i], title: groupNames[i], sortOrder: i };
|
||||
}
|
||||
|
||||
const items = {};
|
||||
const baseDate = new Date(2026, 0, 1);
|
||||
let itemIndex = 0;
|
||||
for (const gId of groupIds) {
|
||||
for (let lane = 1; lane <= 3; lane++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const id = makeId();
|
||||
const startOffset = j * 45 + lane * 5 + Math.floor(Math.random() * 10);
|
||||
const duration = 14 + Math.floor(Math.random() * 30);
|
||||
const start = new Date(baseDate);
|
||||
start.setDate(start.getDate() + startOffset);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + duration);
|
||||
const isPoint = Math.random() < 0.15;
|
||||
items[id] = {
|
||||
id, groupId: gId,
|
||||
title: `Task ${++itemIndex}`,
|
||||
description: null,
|
||||
start: start.toISOString(),
|
||||
end: isPoint ? null : end.toISOString(),
|
||||
lane,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timeline: { id: makeId(), title: "Large Timeline" },
|
||||
groups, items,
|
||||
groupOrder: groupIds,
|
||||
selectedItemId: null,
|
||||
darkMode: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmpty() {
|
||||
const g1 = makeId();
|
||||
return {
|
||||
timeline: { id: makeId(), title: "Empty Timeline" },
|
||||
groups: { [g1]: { id: g1, title: "Untitled Group", sortOrder: 0 } },
|
||||
items: {},
|
||||
groupOrder: [g1],
|
||||
selectedItemId: null,
|
||||
darkMode: true,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bridge state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let currentState = buildFewItems();
|
||||
let updateStateCallback = null;
|
||||
|
||||
window.__zendegi__ = {
|
||||
getState: () => JSON.stringify(currentState),
|
||||
onEvent: (json) => {
|
||||
const event = JSON.parse(json);
|
||||
logEvent(event);
|
||||
handleEvent(event);
|
||||
},
|
||||
set updateState(cb) { updateStateCallback = cb; },
|
||||
get updateState() { return updateStateCallback; },
|
||||
};
|
||||
|
||||
function pushState(state) {
|
||||
currentState = state;
|
||||
if (updateStateCallback) {
|
||||
updateStateCallback(JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function handleEvent(event) {
|
||||
switch (event.type) {
|
||||
case "content_height":
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user