Compare commits

..

10 Commits

Author SHA1 Message Date
6c872624d2 pill design and performance 2026-03-15 19:32:33 +01:00
c39dcb6164 preload flutter 2026-03-08 09:36:43 +01:00
addd78d057 nicer list 2026-03-07 18:14:46 +01:00
9d015c2e2c add and edit 2026-03-07 14:47:42 +01:00
724980fd31 add timeline header 2026-03-07 08:14:32 +01:00
dc524cad24 event 2026-03-06 14:38:34 +01:00
44ffe219b1 update demo page 2026-03-05 19:18:54 +01:00
b2c88dc7cd add flutter tests 2026-03-05 18:57:32 +01:00
acb2878ed6 add popover 2026-03-05 17:04:00 +01:00
765aa83fb6 resize 2026-03-04 14:17:19 +01:00
57 changed files with 4706 additions and 827 deletions

View File

@@ -2,4 +2,4 @@
pnpm-lock.yaml pnpm-lock.yaml
**/.claude/** **/.claude/**
**/public/flutter/** **/public/flutter/**
**/z-timeline/** **/z-flutter/**

View File

@@ -28,60 +28,52 @@ declare global {
} }
} }
type FlutterViewProps = { // ---------------------------------------------------------------------------
state: FlutterTimelineState; // Module-level singleton state
onEvent: (event: FlutterEvent) => void; // ---------------------------------------------------------------------------
className?: string;
height?: number;
};
export function FlutterView({ let flutterContainer: HTMLDivElement | null = null;
state, let engineReady = false;
onEvent, let initPromise: Promise<void> | null = null;
className,
height,
}: FlutterViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading"
);
const stateRef = useRef(state);
stateRef.current = state;
const onEventRef = useRef(onEvent); // Module-level refs so bridge closures always read latest values
onEventRef.current = onEvent; let currentStateRef: FlutterTimelineState | null = null;
let currentOnEventRef: ((event: FlutterEvent) => void) | null = null;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let pollingInterval: ReturnType<typeof setInterval> | undefined;
let pollingTimeout: ReturnType<typeof setTimeout> | undefined;
function ensureBridge() {
if (window.__zendegi__) return;
window.__zendegi__ = { window.__zendegi__ = {
getState: () => JSON.stringify(stateRef.current), getState: () => JSON.stringify(currentStateRef),
onEvent: (json: string) => { onEvent: (json: string) => {
const event = JSON.parse(json) as FlutterEvent; 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 // Load flutter.js if not already loaded
if (!window._flutter) { if (!window._flutter) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
if (document.querySelector('script[src="/flutter/flutter.js"]')) { if (document.querySelector('script[src="/flutter/flutter.js"]')) {
// Script tag exists but hasn't finished — wait for _flutter to appear const timeout = setTimeout(() => {
pollingTimeout = setTimeout(() => { clearInterval(interval);
clearInterval(pollingInterval);
reject( reject(
new Error("Timed out waiting for flutter.js to initialize") new Error("Timed out waiting for flutter.js to initialize"),
); );
}, 10_000); }, 10_000);
pollingInterval = setInterval(() => { const interval = setInterval(() => {
if (window._flutter) { if (window._flutter) {
clearInterval(pollingInterval); clearInterval(interval);
clearTimeout(pollingTimeout); clearTimeout(timeout);
resolve(); resolve();
} }
}, 50); }, 50);
@@ -105,7 +97,13 @@ export function FlutterView({
window._flutter!.buildConfig = await res.json(); 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({ await window._flutter!.loader.load({
config: { config: {
entrypointBaseUrl: "/flutter/", entrypointBaseUrl: "/flutter/",
@@ -113,27 +111,81 @@ export function FlutterView({
}, },
onEntrypointLoaded: async (engineInitializer) => { onEntrypointLoaded: async (engineInitializer) => {
const appRunner = await engineInitializer.initializeEngine({ const appRunner = await engineInitializer.initializeEngine({
hostElement: container, hostElement: flutterContainer!,
assetBase: "/flutter/", assetBase: "/flutter/",
}); });
appRunner.runApp(); appRunner.runApp();
setStatus("ready");
}, },
}); });
} catch (error) {
throw new Error( engineReady = true;
`Flutter engine initialization failed: ${error instanceof Error ? error.message : error}` })().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;
}; };
init().catch(() => setStatus("error")); 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 () => { return () => {
clearInterval(pollingInterval); // Detach but don't destroy the Flutter container
clearTimeout(pollingTimeout); if (flutterContainer?.parentElement === wrapper) {
container.replaceChildren(); wrapper.removeChild(flutterContainer);
delete (window as Partial<Window>).__zendegi__; }
}; };
}, []); }, []);

View File

@@ -1,10 +1,11 @@
import { useState } from "react"; import { useCallback, useEffect } from "react";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field"; import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
import { inputClasses } from "./ui/input";
import { import {
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
@@ -12,63 +13,101 @@ import {
DrawerPortal, DrawerPortal,
DrawerRoot, DrawerRoot,
DrawerTitle, DrawerTitle,
DrawerTrigger,
DrawerViewport, DrawerViewport,
} from "./ui/drawer"; } from "./ui/drawer";
import type { NormalizedGroup, NormalizedItem } from "@/functions/get-timeline";
import { updateTimelineItem } from "@/functions/update-timeline-item";
import { createTimelineItem } from "@/functions/create-timeline-item"; import { createTimelineItem } from "@/functions/create-timeline-item";
export default function ItemFormDrawer({ type ItemFormDrawerProps = {
timelineGroupId, open: boolean;
timelineId, onOpenChange: (open: boolean) => void;
}: {
timelineGroupId: string;
timelineId: string; timelineId: string;
}) { groups: Record<string, NormalizedGroup | undefined>;
const [open, setOpen] = useState(false); groupOrder: Array<string>;
editItem?: NormalizedItem | null;
};
export default function ItemFormDrawer({
open,
onOpenChange,
timelineId,
groups,
groupOrder,
editItem,
}: ItemFormDrawerProps) {
const isEdit = !!editItem;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const getDefaultValues = useCallback(
() => ({
title: editItem?.title ?? "",
description: editItem?.description ?? "",
start: editItem
? editItem.start.toISOString().split("T")[0]
: new Date().toISOString().split("T")[0],
end: editItem?.end ? editItem.end.toISOString().split("T")[0] : "",
groupId: (editItem?.groupId ?? groupOrder[0]) || "",
}),
[editItem, groupOrder]
);
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: getDefaultValues(),
title: "",
description: "",
start: new Date().toISOString().split("T")[0],
end: "",
},
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
try { try {
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({ await createTimelineItem({
data: { data: {
title: value.title, title: value.title,
description: value.description, description: value.description,
start: value.start, start: value.start,
end: value.end || undefined, end: value.end || null,
timelineGroupId, timelineGroupId: value.groupId,
}, },
}); });
toast.success("Item created");
}
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["timeline", timelineId], queryKey: ["timeline", timelineId],
}); });
setOpen(false); onOpenChange(false);
form.reset(); form.reset();
toast.success("Item created");
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "Failed to create item" error instanceof Error
? error.message
: `Failed to ${isEdit ? "update" : "create"} item`
); );
} }
}, },
}); });
useEffect(() => {
if (!open) return;
form.reset(getDefaultValues());
}, [open, getDefaultValues, form]);
return ( return (
<DrawerRoot open={open} onOpenChange={setOpen}> <DrawerRoot open={open} onOpenChange={onOpenChange}>
<DrawerTrigger render={<Button variant="outline" size="sm" />}>
Add Item
</DrawerTrigger>
<DrawerPortal> <DrawerPortal>
<DrawerViewport> <DrawerViewport>
<DrawerPopup> <DrawerPopup>
<div className="flex items-center justify-between border-b border-border p-4"> <div className="flex items-center justify-between border-b border-border p-4">
<DrawerTitle>New Item</DrawerTitle> <DrawerTitle>{isEdit ? "Edit Item" : "New Item"}</DrawerTitle>
<DrawerClose render={<Button variant="outline" size="sm" />}> <DrawerClose render={<Button variant="outline" size="sm" />}>
Close Close
</DrawerClose> </DrawerClose>
@@ -122,6 +161,26 @@ export default function ItemFormDrawer({
)} )}
</form.Field> </form.Field>
<form.Field name="groupId">
{(field) => (
<FieldRoot>
<FieldLabel>Group</FieldLabel>
<select
className={inputClasses}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
>
{groupOrder.map((id) => (
<option key={id} value={id}>
{groups[id]?.title ?? id}
</option>
))}
</select>
</FieldRoot>
)}
</form.Field>
<form.Field name="start"> <form.Field name="start">
{(field) => ( {(field) => (
<FieldRoot> <FieldRoot>
@@ -168,7 +227,13 @@ export default function ItemFormDrawer({
type="submit" type="submit"
disabled={!state.canSubmit || state.isSubmitting} disabled={!state.canSubmit || state.isSubmitting}
> >
{state.isSubmitting ? "Creating..." : "Create Item"} {state.isSubmitting
? isEdit
? "Saving..."
: "Creating..."
: isEdit
? "Save Changes"
: "Create Item"}
</Button> </Button>
)} )}
</form.Subscribe> </form.Subscribe>

View File

@@ -26,7 +26,14 @@ const { viewport, popup, title, description, close, content } = drawerStyles();
type DrawerRootProps = React.ComponentProps<typeof BaseDrawer.Root>; type DrawerRootProps = React.ComponentProps<typeof BaseDrawer.Root>;
function DrawerRoot(props: DrawerRootProps) { function DrawerRoot(props: DrawerRootProps) {
return <BaseDrawer.Root modal={false} swipeDirection="right" {...props} />; return (
<BaseDrawer.Root
modal={false}
swipeDirection="right"
disablePointerDismissal
{...props}
/>
);
} }
type DrawerTriggerProps = React.ComponentProps<typeof BaseDrawer.Trigger>; type DrawerTriggerProps = React.ComponentProps<typeof BaseDrawer.Trigger>;

View 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;
},
};

View File

@@ -15,8 +15,8 @@ export const createTimelineItem = createServerFn({ method: "POST" })
start: z.string().transform((s) => new Date(s)), start: z.string().transform((s) => new Date(s)),
end: z end: z
.string() .string()
.optional() .nullable()
.transform((s) => (s ? new Date(s) : undefined)), .transform((s) => (s ? new Date(s) : null)),
timelineGroupId: z.string().uuid(), timelineGroupId: z.string().uuid(),
}) })
) )

View File

@@ -17,6 +17,8 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
.transform((s) => (s ? new Date(s) : null)), .transform((s) => (s ? new Date(s) : null)),
timelineGroupId: z.string().uuid(), timelineGroupId: z.string().uuid(),
lane: z.number().int().min(1), lane: z.number().int().min(1),
title: z.string().min(1).optional(),
description: z.string().optional(),
}) })
) )
.handler(async ({ data }) => { .handler(async ({ data }) => {
@@ -27,6 +29,10 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
end: data.end, end: data.end,
timelineGroupId: data.timelineGroupId, timelineGroupId: data.timelineGroupId,
lane: data.lane, lane: data.lane,
...(data.title !== undefined && { title: data.title }),
...(data.description !== undefined && {
description: data.description,
}),
}) })
.where(eq(timelineItem.id, data.id)) .where(eq(timelineItem.id, data.id))
.returning(); .returning();

View 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 });
},
});
}

View File

@@ -6,7 +6,7 @@
* lanes on item creation. * lanes on item creation.
*/ */
export function assignLane( export function assignLane(
existingItems: { start: Date; end: Date | null; lane: number }[], existingItems: Array<{ start: Date; end: Date | null; lane: number }>,
newStart: Date, newStart: Date,
newEnd: Date | null newEnd: Date | null
): number { ): number {

View File

@@ -55,4 +55,14 @@ export type FlutterEvent =
newGroupId: string; newGroupId: string;
newLane: number; newLane: number;
}; };
}
| {
type: "entry_resized";
payload: {
entryId: string;
newStart: string;
newEnd: string | null;
groupId: string;
lane: number;
};
}; };

View File

@@ -9,14 +9,15 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as TimelinesRouteImport } from './routes/timelines'
import { Route as TimelineRouteImport } from './routes/timeline' import { Route as TimelineRouteImport } from './routes/timeline'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DemoRouteImport } from './routes/demo' import { Route as DemoRouteImport } from './routes/demo'
import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as ConsentRouteImport } from './routes/consent' import { Route as ConsentRouteImport } from './routes/consent'
import { Route as IndexRouteImport } from './routes/index' import { Route as DefaultRouteImport } from './routes/_default'
import { Route as DefaultIndexRouteImport } from './routes/_default.index'
import { Route as TimelineTimelineIdRouteImport } from './routes/timeline.$timelineId' import { Route as TimelineTimelineIdRouteImport } from './routes/timeline.$timelineId'
import { Route as DefaultTimelinesRouteImport } from './routes/_default.timelines'
import { Route as DefaultLoginRouteImport } from './routes/_default.login'
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.well-known]/openid-configuration' import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.well-known]/openid-configuration'
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.well-known]/oauth-protected-resource' import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.well-known]/oauth-protected-resource'
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.well-known]/oauth-authorization-server' import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.well-known]/oauth-authorization-server'
@@ -24,21 +25,11 @@ import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.well-known]/oauth-authorization-server.api.auth' import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.well-known]/oauth-authorization-server.api.auth'
const TimelinesRoute = TimelinesRouteImport.update({
id: '/timelines',
path: '/timelines',
getParentRoute: () => rootRouteImport,
} as any)
const TimelineRoute = TimelineRouteImport.update({ const TimelineRoute = TimelineRouteImport.update({
id: '/timeline', id: '/timeline',
path: '/timeline', path: '/timeline',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const DemoRoute = DemoRouteImport.update({ const DemoRoute = DemoRouteImport.update({
id: '/demo', id: '/demo',
path: '/demo', path: '/demo',
@@ -54,16 +45,30 @@ const ConsentRoute = ConsentRouteImport.update({
path: '/consent', path: '/consent',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const IndexRoute = IndexRouteImport.update({ const DefaultRoute = DefaultRouteImport.update({
id: '/_default',
getParentRoute: () => rootRouteImport,
} as any)
const DefaultIndexRoute = DefaultIndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => DefaultRoute,
} as any) } as any)
const TimelineTimelineIdRoute = TimelineTimelineIdRouteImport.update({ const TimelineTimelineIdRoute = TimelineTimelineIdRouteImport.update({
id: '/$timelineId', id: '/$timelineId',
path: '/$timelineId', path: '/$timelineId',
getParentRoute: () => TimelineRoute, getParentRoute: () => TimelineRoute,
} as any) } as any)
const DefaultTimelinesRoute = DefaultTimelinesRouteImport.update({
id: '/timelines',
path: '/timelines',
getParentRoute: () => DefaultRoute,
} as any)
const DefaultLoginRoute = DefaultLoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => DefaultRoute,
} as any)
const DotwellKnownOpenidConfigurationRoute = const DotwellKnownOpenidConfigurationRoute =
DotwellKnownOpenidConfigurationRouteImport.update({ DotwellKnownOpenidConfigurationRouteImport.update({
id: '/.well-known/openid-configuration', id: '/.well-known/openid-configuration',
@@ -100,50 +105,51 @@ const DotwellKnownOauthAuthorizationServerApiAuthRoute =
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof DefaultIndexRoute
'/consent': typeof ConsentRoute '/consent': typeof ConsentRoute
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/demo': typeof DemoRoute '/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/timeline': typeof TimelineRouteWithChildren '/timeline': typeof TimelineRouteWithChildren
'/timelines': typeof TimelinesRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/login': typeof DefaultLoginRoute
'/timelines': typeof DefaultTimelinesRoute
'/timeline/$timelineId': typeof TimelineTimelineIdRoute '/timeline/$timelineId': typeof TimelineTimelineIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/mcp/$': typeof ApiMcpSplatRoute '/api/mcp/$': typeof ApiMcpSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute '/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute
'/consent': typeof ConsentRoute '/consent': typeof ConsentRoute
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/demo': typeof DemoRoute '/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/timeline': typeof TimelineRouteWithChildren '/timeline': typeof TimelineRouteWithChildren
'/timelines': typeof TimelinesRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/login': typeof DefaultLoginRoute
'/timelines': typeof DefaultTimelinesRoute
'/timeline/$timelineId': typeof TimelineTimelineIdRoute '/timeline/$timelineId': typeof TimelineTimelineIdRoute
'/': typeof DefaultIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/mcp/$': typeof ApiMcpSplatRoute '/api/mcp/$': typeof ApiMcpSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute '/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/_default': typeof DefaultRouteWithChildren
'/consent': typeof ConsentRoute '/consent': typeof ConsentRoute
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/demo': typeof DemoRoute '/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/timeline': typeof TimelineRouteWithChildren '/timeline': typeof TimelineRouteWithChildren
'/timelines': typeof TimelinesRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/_default/login': typeof DefaultLoginRoute
'/_default/timelines': typeof DefaultTimelinesRoute
'/timeline/$timelineId': typeof TimelineTimelineIdRoute '/timeline/$timelineId': typeof TimelineTimelineIdRoute
'/_default/': typeof DefaultIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/mcp/$': typeof ApiMcpSplatRoute '/api/mcp/$': typeof ApiMcpSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute '/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
@@ -155,58 +161,57 @@ export interface FileRouteTypes {
| '/consent' | '/consent'
| '/dashboard' | '/dashboard'
| '/demo' | '/demo'
| '/login'
| '/timeline' | '/timeline'
| '/timelines'
| '/.well-known/oauth-authorization-server' | '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource' | '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration' | '/.well-known/openid-configuration'
| '/login'
| '/timelines'
| '/timeline/$timelineId' | '/timeline/$timelineId'
| '/api/auth/$' | '/api/auth/$'
| '/api/mcp/$' | '/api/mcp/$'
| '/.well-known/oauth-authorization-server/api/auth' | '/.well-known/oauth-authorization-server/api/auth'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/'
| '/consent' | '/consent'
| '/dashboard' | '/dashboard'
| '/demo' | '/demo'
| '/login'
| '/timeline' | '/timeline'
| '/timelines'
| '/.well-known/oauth-authorization-server' | '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource' | '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration' | '/.well-known/openid-configuration'
| '/login'
| '/timelines'
| '/timeline/$timelineId' | '/timeline/$timelineId'
| '/'
| '/api/auth/$' | '/api/auth/$'
| '/api/mcp/$' | '/api/mcp/$'
| '/.well-known/oauth-authorization-server/api/auth' | '/.well-known/oauth-authorization-server/api/auth'
id: id:
| '__root__' | '__root__'
| '/' | '/_default'
| '/consent' | '/consent'
| '/dashboard' | '/dashboard'
| '/demo' | '/demo'
| '/login'
| '/timeline' | '/timeline'
| '/timelines'
| '/.well-known/oauth-authorization-server' | '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource' | '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration' | '/.well-known/openid-configuration'
| '/_default/login'
| '/_default/timelines'
| '/timeline/$timelineId' | '/timeline/$timelineId'
| '/_default/'
| '/api/auth/$' | '/api/auth/$'
| '/api/mcp/$' | '/api/mcp/$'
| '/.well-known/oauth-authorization-server/api/auth' | '/.well-known/oauth-authorization-server/api/auth'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute DefaultRoute: typeof DefaultRouteWithChildren
ConsentRoute: typeof ConsentRoute ConsentRoute: typeof ConsentRoute
DashboardRoute: typeof DashboardRoute DashboardRoute: typeof DashboardRoute
DemoRoute: typeof DemoRoute DemoRoute: typeof DemoRoute
LoginRoute: typeof LoginRoute
TimelineRoute: typeof TimelineRouteWithChildren TimelineRoute: typeof TimelineRouteWithChildren
TimelinesRoute: typeof TimelinesRoute
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRoute DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRoute
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
@@ -216,13 +221,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/timelines': {
id: '/timelines'
path: '/timelines'
fullPath: '/timelines'
preLoaderRoute: typeof TimelinesRouteImport
parentRoute: typeof rootRouteImport
}
'/timeline': { '/timeline': {
id: '/timeline' id: '/timeline'
path: '/timeline' path: '/timeline'
@@ -230,13 +228,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TimelineRouteImport preLoaderRoute: typeof TimelineRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/demo': { '/demo': {
id: '/demo' id: '/demo'
path: '/demo' path: '/demo'
@@ -258,12 +249,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ConsentRouteImport preLoaderRoute: typeof ConsentRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/': { '/_default': {
id: '/' id: '/_default'
path: ''
fullPath: '/'
preLoaderRoute: typeof DefaultRouteImport
parentRoute: typeof rootRouteImport
}
'/_default/': {
id: '/_default/'
path: '/' path: '/'
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof DefaultIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof DefaultRoute
} }
'/timeline/$timelineId': { '/timeline/$timelineId': {
id: '/timeline/$timelineId' id: '/timeline/$timelineId'
@@ -272,6 +270,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TimelineTimelineIdRouteImport preLoaderRoute: typeof TimelineTimelineIdRouteImport
parentRoute: typeof TimelineRoute parentRoute: typeof TimelineRoute
} }
'/_default/timelines': {
id: '/_default/timelines'
path: '/timelines'
fullPath: '/timelines'
preLoaderRoute: typeof DefaultTimelinesRouteImport
parentRoute: typeof DefaultRoute
}
'/_default/login': {
id: '/_default/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof DefaultLoginRouteImport
parentRoute: typeof DefaultRoute
}
'/.well-known/openid-configuration': { '/.well-known/openid-configuration': {
id: '/.well-known/openid-configuration' id: '/.well-known/openid-configuration'
path: '/.well-known/openid-configuration' path: '/.well-known/openid-configuration'
@@ -317,6 +329,21 @@ declare module '@tanstack/react-router' {
} }
} }
interface DefaultRouteChildren {
DefaultLoginRoute: typeof DefaultLoginRoute
DefaultTimelinesRoute: typeof DefaultTimelinesRoute
DefaultIndexRoute: typeof DefaultIndexRoute
}
const DefaultRouteChildren: DefaultRouteChildren = {
DefaultLoginRoute: DefaultLoginRoute,
DefaultTimelinesRoute: DefaultTimelinesRoute,
DefaultIndexRoute: DefaultIndexRoute,
}
const DefaultRouteWithChildren =
DefaultRoute._addFileChildren(DefaultRouteChildren)
interface TimelineRouteChildren { interface TimelineRouteChildren {
TimelineTimelineIdRoute: typeof TimelineTimelineIdRoute TimelineTimelineIdRoute: typeof TimelineTimelineIdRoute
} }
@@ -345,13 +372,11 @@ const DotwellKnownOauthAuthorizationServerRouteWithChildren =
) )
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, DefaultRoute: DefaultRouteWithChildren,
ConsentRoute: ConsentRoute, ConsentRoute: ConsentRoute,
DashboardRoute: DashboardRoute, DashboardRoute: DashboardRoute,
DemoRoute: DemoRoute, DemoRoute: DemoRoute,
LoginRoute: LoginRoute,
TimelineRoute: TimelineRouteWithChildren, TimelineRoute: TimelineRouteWithChildren,
TimelinesRoute: TimelinesRoute,
DotwellKnownOauthAuthorizationServerRoute: DotwellKnownOauthAuthorizationServerRoute:
DotwellKnownOauthAuthorizationServerRouteWithChildren, DotwellKnownOauthAuthorizationServerRouteWithChildren,
DotwellKnownOauthProtectedResourceRoute: DotwellKnownOauthProtectedResourceRoute:

View File

@@ -5,8 +5,6 @@ import {
createRootRouteWithContext, createRootRouteWithContext,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import Header from "../components/header";
import appCss from "../index.css?url"; import appCss from "../index.css?url";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
@@ -47,7 +45,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
function RootDocument() { function RootDocument() {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<head> <head>
<HeadContent /> <HeadContent />
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
@@ -55,7 +53,6 @@ function RootDocument() {
<body> <body>
<ThemeProvider> <ThemeProvider>
<div className="grid min-h-svh grid-rows-[auto_1fr]"> <div className="grid min-h-svh grid-rows-[auto_1fr]">
<Header />
<Outlet /> <Outlet />
</div> </div>
<Toaster richColors /> <Toaster richColors />

View File

@@ -12,7 +12,7 @@ import {
FieldRoot, FieldRoot,
} from "@/components/ui/field"; } from "@/components/ui/field";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/_default/")({
component: HomeComponent, component: HomeComponent,
}); });

View 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>
);
}

View 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>
);
}

View File

@@ -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)} />
);
}

View File

@@ -1,10 +1,15 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { ArrowLeft, Moon, Plus, Sun } from "lucide-react";
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge"; import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
import { timelineQueryOptions } from "@/functions/get-timeline"; import { timelineQueryOptions } from "@/functions/get-timeline";
import { FlutterView } from "@/components/flutter-view"; import { FlutterView } from "@/components/flutter-view";
import { Button } from "@/components/ui/button";
import UserMenu from "@/components/user-menu";
import ItemFormDrawer from "@/components/item-form-drawer";
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation"; import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
import { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
export const Route = createFileRoute("/timeline/$timelineId")({ export const Route = createFileRoute("/timeline/$timelineId")({
@@ -20,9 +25,15 @@ function RouteComponent() {
const { timelineId } = Route.useParams(); const { timelineId } = Route.useParams();
const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId)); const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
const [selectedItemId, setSelectedItemId] = useState<string | null>(null); const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [flutterHeight, setFlutterHeight] = useState<number | undefined>(); const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
const entryMoved = useEntryMovedMutation(timelineId); const entryMoved = useEntryMovedMutation(timelineId);
const { theme } = useTheme(); const entryResized = useEntryResizedMutation(timelineId);
const { theme, toggleTheme } = useTheme();
const editItem = selectedItemId
? (timeline.items[selectedItemId] ?? null)
: null;
const flutterState: FlutterTimelineState = useMemo( const flutterState: FlutterTimelineState = useMemo(
() => ({ () => ({
@@ -57,23 +68,71 @@ function RouteComponent() {
break; break;
case "item_selected": case "item_selected":
setSelectedItemId(event.payload.itemId); setSelectedItemId(event.payload.itemId);
setDrawerOpen(true);
break; break;
case "item_deselected": case "item_deselected":
setSelectedItemId(null); setSelectedItemId(null);
setDrawerOpen(false);
break; break;
case "entry_moved": case "entry_moved":
entryMoved.mutate(event.payload); entryMoved.mutate(event.payload);
break; break;
case "entry_resized":
entryResized.mutate(event.payload);
break;
} }
}, },
[entryMoved] [entryMoved, entryResized]
); );
const handleDrawerOpenChange = useCallback((open: boolean) => {
setDrawerOpen(open);
if (!open) {
setSelectedItemId(null);
}
}, []);
const handleAddItem = useCallback(() => {
setSelectedItemId(null);
setDrawerOpen(true);
}, []);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-3xl font-serif font-bold mb-6 mx-4"> <div className="flex flex-row items-center justify-between px-2 py-1">
{timeline.title} <div className="flex items-center gap-2">
</h1> <Link
to="/timelines"
className="text-muted-foreground hover:text-foreground inline-flex size-8 items-center justify-center rounded-md transition-colors"
aria-label="Back to timelines"
>
<ArrowLeft className="size-4" />
</Link>
<h1 className="text-lg font-serif font-bold">{timeline.title}</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleAddItem}>
<Plus className="size-4" />
Add Item
</Button>
<button
type="button"
onClick={toggleTheme}
className="text-muted-foreground hover:text-foreground inline-flex size-8 items-center justify-center rounded-md transition-colors"
aria-label={
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
}
>
{theme === "dark" ? (
<Sun className="size-4" />
) : (
<Moon className="size-4" />
)}
</button>
<UserMenu />
</div>
</div>
<hr />
<FlutterView <FlutterView
state={flutterState} state={flutterState}
@@ -81,6 +140,15 @@ function RouteComponent() {
className="overflow-hidden" className="overflow-hidden"
height={flutterHeight} height={flutterHeight}
/> />
<ItemFormDrawer
open={drawerOpen}
onOpenChange={handleDrawerOpenChange}
timelineId={timelineId}
groups={timeline.groups}
groupOrder={timeline.groupOrder}
editItem={editItem}
/>
</div> </div>
); );
} }

View File

@@ -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>
);
}

View File

@@ -5,8 +5,19 @@ import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ export default defineConfig({
plugins: [tsconfigPaths(), tailwindcss(), tanstackStart(), viteReact()], plugins: [
tsconfigPaths(),
tailwindcss(),
tanstackStart({
server: { entry: "./entry-server.ts" },
}),
viteReact(),
],
server: { server: {
port: 3001, port: 3001,
headers: {
"Cross-Origin-Embedder-Policy": "credentialless",
"Cross-Origin-Opener-Policy": "same-origin",
},
}, },
}); });

View File

@@ -24,7 +24,7 @@ interface ItemForLane {
} }
function assignLane( function assignLane(
existing: ItemForLane[], existing: Array<ItemForLane>,
newStart: Date, newStart: Date,
newEnd: Date | null newEnd: Date | null
): number { ): number {
@@ -57,7 +57,7 @@ async function main() {
if (items.length === 0) continue; if (items.length === 0) continue;
const assigned: ItemForLane[] = []; const assigned: Array<ItemForLane> = [];
let updated = 0; let updated = 0;
for (const item of items) { for (const item of items) {

View File

@@ -125,6 +125,14 @@ class _MainAppState extends State<MainApp> {
return (start: earliest.subtract(padding), end: latest.add(padding)); return (start: earliest.subtract(padding), end: latest.add(padding));
} }
void _onEntrySelected(TimelineEntry entry) {
emitEvent('item_selected', {'itemId': entry.id});
}
void _onBackgroundTap() {
emitEvent('item_deselected');
}
void _onEntryMoved( void _onEntryMoved(
TimelineEntry entry, TimelineEntry entry,
DateTime newStart, DateTime newStart,
@@ -169,6 +177,47 @@ class _MainAppState extends State<MainApp> {
}); });
} }
void _onEntryResized(
TimelineEntry entry,
DateTime newStart,
DateTime newEnd,
int newLane,
) {
// Optimistic update -- apply locally before the host round-trips.
if (_state case final state?) {
final oldItem = state.items[entry.id];
if (oldItem != null) {
final updatedItems = Map<String, TimelineItemData>.of(state.items);
updatedItems[entry.id] = TimelineItemData(
id: oldItem.id,
groupId: oldItem.groupId,
title: oldItem.title,
description: oldItem.description,
start: newStart.toIso8601String(),
end: entry.hasEnd ? newEnd.toIso8601String() : null,
lane: newLane,
);
final updatedState = TimelineState(
timeline: state.timeline,
groups: state.groups,
items: updatedItems,
groupOrder: state.groupOrder,
selectedItemId: state.selectedItemId,
darkMode: state.darkMode,
);
_applyState(updatedState);
}
}
emitEvent('entry_resized', <String, Object?>{
'entryId': entry.id,
'newStart': newStart.toIso8601String(),
'newEnd': entry.hasEnd ? newEnd.toIso8601String() : null,
'groupId': entry.groupId,
'lane': newLane,
});
}
void _emitContentHeight() { void _emitContentHeight() {
// Start with the fixed chrome heights. // Start with the fixed chrome heights.
var totalHeight = _tieredHeaderHeight + _breadcrumbHeight; var totalHeight = _tieredHeaderHeight + _breadcrumbHeight;
@@ -213,6 +262,74 @@ class _MainAppState extends State<MainApp> {
return _groupColors[groupIndex % _groupColors.length]; return _groupColors[groupIndex % _groupColors.length];
} }
static const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
String _formatDate(DateTime d) {
return '${_months[d.month - 1]} ${d.day}, ${d.year}';
}
String _formatDateRange(DateTime start, DateTime? end) {
final s = _formatDate(start);
if (end == null) return s;
final e = _formatDate(end);
return s == e ? s : '$s $e';
}
Widget? _buildPopoverContent(String entryId) {
final item = _state?.items[entryId];
if (item == null) return null;
final start = DateTime.parse(item.start);
final end = item.end != null ? DateTime.parse(item.end!) : null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.description != null && item.description!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.description!,
style: const TextStyle(fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 4),
Text(
_formatDateRange(start, end),
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
],
);
}
Widget _buildCompactDate(DateTime start, DateTime end) {
return Text(
_formatDateRange(start, end),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final viewport = _viewport; final viewport = _viewport;
@@ -228,12 +345,16 @@ class _MainAppState extends State<MainApp> {
? const Center(child: Text('Waiting for state...')) ? const Center(child: Text('Waiting for state...'))
: ZTimelineScope( : ZTimelineScope(
viewport: viewport, viewport: viewport,
child: EntryPopoverOverlay(
popoverContentBuilder: _buildPopoverContent,
compactDateBuilder: _buildCompactDate,
child: Column( child: Column(
children: [ children: [
const ZTimelineBreadcrumb(), const ZTimelineBreadcrumb(),
const ZTimelineTieredHeader(), const ZTimelineTieredHeader(),
Expanded( Expanded(
child: ZTimelineInteractor( child: ZTimelineInteractor(
onBackgroundTap: _onBackgroundTap,
child: ZTimelineView( child: ZTimelineView(
groups: _groups, groups: _groups,
entries: _entries, entries: _entries,
@@ -242,6 +363,9 @@ class _MainAppState extends State<MainApp> {
colorBuilder: _colorForEntry, colorBuilder: _colorForEntry,
enableDrag: true, enableDrag: true,
onEntryMoved: _onEntryMoved, onEntryMoved: _onEntryMoved,
onEntryResized: _onEntryResized,
onEntrySelected: _onEntrySelected,
selectedEntryId: _state?.selectedItemId,
), ),
), ),
), ),
@@ -249,6 +373,7 @@ class _MainAppState extends State<MainApp> {
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -3,6 +3,6 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "flutter build web --release --wasm --base-href /flutter/ && node scripts/copy-build.mjs" "build": "flutter build web --release --wasm --base-href /flutter/ --source-maps && node scripts/copy-build.mjs"
} }
} }

View File

@@ -4,8 +4,20 @@ import 'package:flutter/material.dart';
class ZTimelineConstants { class ZTimelineConstants {
const ZTimelineConstants._(); const ZTimelineConstants._();
// Pill body
static const double pillInnerBorderWidth = 1.0;
static const double pillContentHeight = 16.0; // labelMedium line height
// Computed pill height (single source of truth for lane height)
// pillPadding.vertical (6.0 * 2 = 12.0) inlined because EdgeInsets
// getters are not compile-time constants.
static const double pillHeight =
pillInnerBorderWidth * 2 +
12.0 + // pillPadding.vertical
pillContentHeight; // = 30.0
// Heights // Heights
static const double laneHeight = 28.0; static const double laneHeight = pillHeight;
static const double groupHeaderHeight = 34.0; static const double groupHeaderHeight = 34.0;
// Spacing // Spacing
@@ -19,6 +31,15 @@ class ZTimelineConstants {
vertical: 6.0, vertical: 6.0,
); );
// Point events (hasEnd == false)
static const double pointEventCircleDiameter = 12.0;
static const double pointEventCircleTextGap = 6.0;
static const double pointEventTextGap = 8.0;
// Resize handles
static const double resizeHandleWidth = 6.0;
static const Duration minResizeDuration = Duration(hours: 1);
// Content width // Content width
static const double minContentWidth = 1200.0; static const double minContentWidth = 1200.0;
} }

View File

@@ -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)';
}

View File

@@ -9,22 +9,27 @@ class ZTimelineInteractionState {
const ZTimelineInteractionState({ const ZTimelineInteractionState({
this.isGrabbing = false, this.isGrabbing = false,
this.isDraggingEntry = false, this.isDraggingEntry = false,
this.isResizingEntry = false,
}); });
/// Whether the user is actively panning (for cursor feedback). /// Whether the user is actively panning (for cursor feedback).
final bool isGrabbing; final bool isGrabbing;
/// Whether an entry is being dragged (disables pan gesture). /// Whether an entry is being dragged (disables pan gesture).
/// This will be used by future drag-and-drop functionality.
final bool isDraggingEntry; final bool isDraggingEntry;
/// Whether an entry is being resized (disables pan gesture).
final bool isResizingEntry;
ZTimelineInteractionState copyWith({ ZTimelineInteractionState copyWith({
bool? isGrabbing, bool? isGrabbing,
bool? isDraggingEntry, bool? isDraggingEntry,
bool? isResizingEntry,
}) { }) {
return ZTimelineInteractionState( return ZTimelineInteractionState(
isGrabbing: isGrabbing ?? this.isGrabbing, isGrabbing: isGrabbing ?? this.isGrabbing,
isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry, isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry,
isResizingEntry: isResizingEntry ?? this.isResizingEntry,
); );
} }
@@ -33,9 +38,10 @@ class ZTimelineInteractionState {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is ZTimelineInteractionState && return other is ZTimelineInteractionState &&
other.isGrabbing == isGrabbing && other.isGrabbing == isGrabbing &&
other.isDraggingEntry == isDraggingEntry; other.isDraggingEntry == isDraggingEntry &&
other.isResizingEntry == isResizingEntry;
} }
@override @override
int get hashCode => Object.hash(isGrabbing, isDraggingEntry); int get hashCode => Object.hash(isGrabbing, isDraggingEntry, isResizingEntry);
} }

View File

@@ -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)';
}

View File

@@ -10,7 +10,7 @@ import '../constants.dart';
/// The timeline uses two coordinate spaces: /// The timeline uses two coordinate spaces:
/// ///
/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain. /// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain.
/// Used by [TimeScaleService] and stored in [ProjectedEntry]. /// Computed via [TimeScaleService].
/// ///
/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline. /// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline.
/// What gets passed to [Positioned] widgets. /// What gets passed to [Positioned] widgets.
@@ -35,8 +35,7 @@ class LayoutCoordinateService {
required double normalizedWidth, required double normalizedWidth,
required double contentWidth, required double contentWidth,
}) { }) {
return (normalizedWidth * contentWidth) return (normalizedWidth * contentWidth).clamp(0.0, double.infinity);
.clamp(0.0, double.infinity);
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -62,8 +61,7 @@ class LayoutCoordinateService {
required double widgetX, required double widgetX,
required double contentWidth, required double contentWidth,
}) { }) {
final adjustedX = final adjustedX = (widgetX).clamp(0.0, contentWidth);
(widgetX).clamp(0.0, contentWidth);
return contentWidth == 0 ? 0.0 : adjustedX / contentWidth; return contentWidth == 0 ? 0.0 : adjustedX / contentWidth;
} }
@@ -75,10 +73,7 @@ class LayoutCoordinateService {
/// ///
/// Used by all positioned elements (pills, ghost overlay) within the /// Used by all positioned elements (pills, ghost overlay) within the
/// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0. /// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0.
static double laneToY({ static double laneToY({required int lane, required double laneHeight}) {
required int lane,
required double laneHeight,
}) {
return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing); return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing);
} }
@@ -86,10 +81,7 @@ class LayoutCoordinateService {
/// ///
/// Used by drop targets to determine which lane the cursor is over. /// Used by drop targets to determine which lane the cursor is over.
/// The Y coordinate should be relative to the timeline Stack. /// The Y coordinate should be relative to the timeline Stack.
static int yToLane({ static int yToLane({required double y, required double laneHeight}) {
required double y,
required double laneHeight,
}) {
final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing; final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing;
return (y / laneStep).floor() + 1; return (y / laneStep).floor() + 1;
} }

View File

@@ -286,8 +286,10 @@ class TieredTickService {
// Start one period before to ensure we cover partial sections at the edge // Start one period before to ensure we cover partial sections at the edge
var current = alignToUnit(startUtc, unit); var current = alignToUnit(startUtc, unit);
final oneBeforeStart = final oneBeforeStart = alignToUnit(
alignToUnit(current.subtract(const Duration(milliseconds: 1)), unit); current.subtract(const Duration(milliseconds: 1)),
unit,
);
current = oneBeforeStart; current = oneBeforeStart;
while (current.isBefore(endUtc)) { while (current.isBefore(endUtc)) {

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -1,6 +1,9 @@
import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/entry_drag_state.dart'; import '../models/entry_drag_state.dart';
import '../models/entry_resize_state.dart';
import '../models/interaction_state.dart'; import '../models/interaction_state.dart';
import '../models/timeline_entry.dart'; import '../models/timeline_entry.dart';
@@ -13,6 +16,14 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
ZTimelineInteractionState _state = const ZTimelineInteractionState(); ZTimelineInteractionState _state = const ZTimelineInteractionState();
EntryDragState? _dragState; EntryDragState? _dragState;
EntryResizeState? _resizeState;
// -- Hover state --
String? _hoveredEntryId;
Rect? _hoveredPillGlobalRect;
// -- Interaction cursor position (during drag/resize) --
Offset? _interactionGlobalPosition;
/// The current interaction state. /// The current interaction state.
ZTimelineInteractionState get state => _state; ZTimelineInteractionState get state => _state;
@@ -20,12 +31,52 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
/// The current drag state, or null if no drag is active. /// The current drag state, or null if no drag is active.
EntryDragState? get dragState => _dragState; EntryDragState? get dragState => _dragState;
/// The current resize state, or null if no resize is active.
EntryResizeState? get resizeState => _resizeState;
/// Whether the user is actively panning (for cursor feedback). /// Whether the user is actively panning (for cursor feedback).
bool get isGrabbing => _state.isGrabbing; bool get isGrabbing => _state.isGrabbing;
/// Whether an entry is being dragged (disables pan gesture). /// Whether an entry is being dragged (disables pan gesture).
bool get isDraggingEntry => _state.isDraggingEntry; bool get isDraggingEntry => _state.isDraggingEntry;
/// Whether an entry is being resized.
bool get isResizingEntry => _state.isResizingEntry;
/// Whether any entry interaction (drag or resize) is active.
bool get isInteracting => isDraggingEntry || isResizingEntry;
/// The currently hovered entry ID, or null.
String? get hoveredEntryId => _hoveredEntryId;
/// The global rect of the hovered pill, or null.
Rect? get hoveredPillGlobalRect => _hoveredPillGlobalRect;
/// The global cursor position during drag/resize, or null.
Offset? get interactionGlobalPosition => _interactionGlobalPosition;
/// Set the hovered entry and its global rect.
void setHoveredEntry(String id, Rect globalRect) {
if (_hoveredEntryId == id && _hoveredPillGlobalRect == globalRect) return;
_hoveredEntryId = id;
_hoveredPillGlobalRect = globalRect;
notifyListeners();
}
/// Clear the hovered entry.
void clearHoveredEntry() {
if (_hoveredEntryId == null) return;
_hoveredEntryId = null;
_hoveredPillGlobalRect = null;
notifyListeners();
}
/// Update the cursor position during drag/resize.
void updateInteractionPosition(Offset globalPosition) {
_interactionGlobalPosition = globalPosition;
notifyListeners();
}
/// Update the grabbing state. /// Update the grabbing state.
void setGrabbing(bool value) { void setGrabbing(bool value) {
if (_state.isGrabbing == value) return; if (_state.isGrabbing == value) return;
@@ -43,7 +94,10 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
/// Begin dragging an entry. /// Begin dragging an entry.
/// ///
/// Sets drag state and marks [isDraggingEntry] as true. /// Sets drag state and marks [isDraggingEntry] as true.
/// Clears hover state since the user is now dragging.
void beginDrag(TimelineEntry entry) { void beginDrag(TimelineEntry entry) {
_hoveredEntryId = null;
_hoveredPillGlobalRect = null;
_dragState = EntryDragState( _dragState = EntryDragState(
entryId: entry.id, entryId: entry.id,
originalEntry: entry, originalEntry: entry,
@@ -76,17 +130,56 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
/// End the drag and clear state. /// End the drag and clear state.
void endDrag() { void endDrag() {
_dragState = null; _dragState = null;
_interactionGlobalPosition = null;
setDraggingEntry(false); setDraggingEntry(false);
} }
/// Cancel the drag (alias for [endDrag]). /// Cancel the drag (alias for [endDrag]).
void cancelDrag() => endDrag(); void cancelDrag() => endDrag();
/// Called by drag-drop system when an entry drag starts. /// Begin resizing an entry edge.
@Deprecated('Use beginDrag instead') ///
void beginEntryDrag() => setDraggingEntry(true); /// Clears hover state since the user is now resizing.
void beginResize(TimelineEntry entry, ResizeEdge edge) {
/// Called by drag-drop system when an entry drag ends. _hoveredEntryId = null;
@Deprecated('Use endDrag instead') _hoveredPillGlobalRect = null;
void endEntryDrag() => setDraggingEntry(false); _resizeState = EntryResizeState(
entryId: entry.id,
originalEntry: entry,
edge: edge,
targetStart: entry.start,
targetEnd: entry.end,
targetLane: entry.lane,
);
_state = _state.copyWith(isResizingEntry: true);
notifyListeners();
}
/// 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();
} }

View File

@@ -29,7 +29,9 @@ class BreadcrumbSegmentChip extends StatelessWidget {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: AnimatedSize( child: AnimatedSize(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: segment.isVisible ? _buildChip(context) : const SizedBox.shrink(), child: segment.isVisible
? _buildChip(context)
: const SizedBox.shrink(),
), ),
); );
} }
@@ -45,14 +47,16 @@ class BreadcrumbSegmentChip extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.5),
),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
segment.label, segment.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(
color: colorScheme.onSurface, context,
), ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
), ),
), ),
), ),

View File

@@ -1,77 +1,82 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../constants.dart';
import '../models/entry_drag_state.dart';
import '../services/layout_coordinate_service.dart'; import '../services/layout_coordinate_service.dart';
import '../services/time_scale_service.dart'; import '../services/time_scale_service.dart';
import '../state/timeline_viewport_notifier.dart'; import '../state/timeline_viewport_notifier.dart';
import 'event_pill.dart';
import 'event_point.dart';
/// A semi-transparent ghost overlay showing where an entry will land. /// A preview overlay showing where an entry will land during drag/resize.
/// ///
/// Displayed during drag operations to give visual feedback about the /// Renders the actual [EventPill] or [EventPoint] at full opacity so the
/// target position. /// preview looks identical to the real item.
class GhostOverlay extends StatelessWidget { class DragPreview extends StatelessWidget {
const GhostOverlay({ const DragPreview({
required this.dragState, required this.targetStart,
required this.targetEnd,
required this.targetLane,
required this.viewport, required this.viewport,
required this.contentWidth, required this.contentWidth,
required this.laneHeight, required this.laneHeight,
required this.color,
required this.label,
this.hasEnd = true,
super.key, super.key,
}); });
final EntryDragState dragState; final DateTime targetStart;
final DateTime targetEnd;
final int targetLane;
final TimelineViewportNotifier viewport; final TimelineViewportNotifier viewport;
final double contentWidth; final double contentWidth;
final double laneHeight; final double laneHeight;
final Color color;
final String label;
/// Whether this is a range event. When false, renders [EventPoint].
final bool hasEnd;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final startX = TimeScaleService.mapTimeToPosition( final startX = TimeScaleService.mapTimeToPosition(
dragState.targetStart, targetStart,
viewport.start, viewport.start,
viewport.end, viewport.end,
); );
final endX = TimeScaleService.mapTimeToPosition( final top = LayoutCoordinateService.laneToY(
dragState.targetEnd, lane: targetLane,
viewport.start, laneHeight: laneHeight,
viewport.end,
); );
// Use centralized coordinate service to ensure ghost matches pill layout
final left = LayoutCoordinateService.normalizedToWidgetX( final left = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: startX, normalizedX: startX,
contentWidth: contentWidth, contentWidth: contentWidth,
); );
if (!hasEnd) {
return Positioned(
left: left.clamp(0.0, double.infinity),
top: top,
height: laneHeight,
child: IgnorePointer(child: EventPoint(color: color, label: label)),
);
}
final endX = TimeScaleService.mapTimeToPosition(
targetEnd,
viewport.start,
viewport.end,
);
final width = LayoutCoordinateService.calculateItemWidth( final width = LayoutCoordinateService.calculateItemWidth(
normalizedWidth: endX - startX, normalizedWidth: endX - startX,
contentWidth: contentWidth, contentWidth: contentWidth,
); );
final top = LayoutCoordinateService.laneToY(
lane: dragState.targetLane,
laneHeight: laneHeight,
);
final scheme = Theme.of(context).colorScheme;
return Positioned( return Positioned(
left: left.clamp(0.0, double.infinity), left: left.clamp(0.0, double.infinity),
width: width.clamp(0.0, double.infinity), width: width.clamp(0.0, double.infinity),
top: top, top: top,
height: laneHeight, height: laneHeight,
child: IgnorePointer( child: IgnorePointer(child: EventPill(color: color, label: label)),
child: Container(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(
ZTimelineConstants.pillBorderRadius,
),
border: Border.all(
color: scheme.primary.withValues(alpha: 0.6),
width: 2,
),
),
),
),
); );
} }
} }

View File

@@ -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,
),
),
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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);

View File

@@ -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,
],
],
);
}
}

View File

@@ -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
},
);
}
}

View File

@@ -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,
),
),
],
),
),
);
}
}

View File

@@ -98,10 +98,7 @@ class ZTimelineBreadcrumb extends StatelessWidget {
} }
class _BreadcrumbSegmentRow extends StatelessWidget { class _BreadcrumbSegmentRow extends StatelessWidget {
const _BreadcrumbSegmentRow({ const _BreadcrumbSegmentRow({required this.segments, required this.viewport});
required this.segments,
required this.viewport,
});
final List<BreadcrumbSegment> segments; final List<BreadcrumbSegment> segments;
final TimelineViewportNotifier viewport; final TimelineViewportNotifier viewport;
@@ -173,9 +170,9 @@ class _ZoomLevelIndicator extends StatelessWidget {
), ),
child: Text( child: Text(
level.label, level.label,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(
color: colorScheme.onSurfaceVariant, context,
), ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
), ),
); );
} }

View File

@@ -27,6 +27,7 @@ class ZTimelineInteractor extends StatefulWidget {
const ZTimelineInteractor({ const ZTimelineInteractor({
required this.child, required this.child,
this.autofocus = true, this.autofocus = true,
this.onBackgroundTap,
super.key, super.key,
}); });
@@ -36,6 +37,9 @@ class ZTimelineInteractor extends StatefulWidget {
/// Whether to automatically focus this widget for keyboard input. /// Whether to automatically focus this widget for keyboard input.
final bool autofocus; final bool autofocus;
/// Called when the user taps empty space (not on a pill/entry).
final VoidCallback? onBackgroundTap;
@override @override
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState(); State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
} }
@@ -88,7 +92,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
// Single-finger pan // Single-finger pan
else if (details.pointerCount == 1 && else if (details.pointerCount == 1 &&
config.enablePan && config.enablePan &&
!scope.interaction.isDraggingEntry) { !scope.interaction.isInteracting) {
if (_lastFocalPoint != null) { if (_lastFocalPoint != null) {
final diff = details.focalPoint - _lastFocalPoint!; final diff = details.focalPoint - _lastFocalPoint!;
final ratio = -diff.dx / width; final ratio = -diff.dx / width;
@@ -221,8 +225,9 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
listenable: scope.interaction, listenable: scope.interaction,
builder: (context, child) { builder: (context, child) {
return MouseRegion( return MouseRegion(
cursor: cursor: scope.interaction.isResizingEntry
scope.interaction.isGrabbing || ? SystemMouseCursors.resizeColumn
: scope.interaction.isGrabbing ||
scope.interaction.isDraggingEntry scope.interaction.isDraggingEntry
? SystemMouseCursors.grabbing ? SystemMouseCursors.grabbing
: SystemMouseCursors.basic, : SystemMouseCursors.basic,
@@ -230,6 +235,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
); );
}, },
child: GestureDetector( child: GestureDetector(
onTap: widget.onBackgroundTap,
onScaleStart: _handleScaleStart, onScaleStart: _handleScaleStart,
onScaleUpdate: _handleScaleUpdate, onScaleUpdate: _handleScaleUpdate,
onScaleEnd: _handleScaleEnd, onScaleEnd: _handleScaleEnd,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/interaction_config.dart'; import '../models/interaction_config.dart';
import '../services/timeline_group_registry.dart';
import '../state/timeline_interaction_notifier.dart'; import '../state/timeline_interaction_notifier.dart';
import '../state/timeline_viewport_notifier.dart'; import '../state/timeline_viewport_notifier.dart';
@@ -61,11 +62,13 @@ class ZTimelineScope extends StatefulWidget {
class _ZTimelineScopeState extends State<ZTimelineScope> { class _ZTimelineScopeState extends State<ZTimelineScope> {
late final ZTimelineInteractionNotifier _interactionNotifier; late final ZTimelineInteractionNotifier _interactionNotifier;
late final TimelineGroupRegistry _groupRegistry;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_interactionNotifier = ZTimelineInteractionNotifier(); _interactionNotifier = ZTimelineInteractionNotifier();
_groupRegistry = TimelineGroupRegistry();
} }
@override @override
@@ -81,6 +84,7 @@ class _ZTimelineScopeState extends State<ZTimelineScope> {
viewport: widget.viewport, viewport: widget.viewport,
interaction: _interactionNotifier, interaction: _interactionNotifier,
config: widget.config, config: widget.config,
groupRegistry: _groupRegistry,
), ),
child: widget.child, child: widget.child,
); );
@@ -105,6 +109,7 @@ class ZTimelineScopeData {
required this.viewport, required this.viewport,
required this.interaction, required this.interaction,
required this.config, required this.config,
required this.groupRegistry,
}); });
/// The viewport notifier for domain state (start/end times). /// The viewport notifier for domain state (start/end times).
@@ -116,15 +121,19 @@ class ZTimelineScopeData {
/// Configuration for interaction behavior. /// Configuration for interaction behavior.
final ZTimelineInteractionConfig config; final ZTimelineInteractionConfig config;
/// Registry for group lane areas, used for cross-group hit detection.
final TimelineGroupRegistry groupRegistry;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is ZTimelineScopeData && return other is ZTimelineScopeData &&
other.viewport == viewport && other.viewport == viewport &&
other.interaction == interaction && other.interaction == interaction &&
other.config == config; other.config == config &&
other.groupRegistry == groupRegistry;
} }
@override @override
int get hashCode => Object.hash(viewport, interaction, config); int get hashCode => Object.hash(viewport, interaction, config, groupRegistry);
} }

View File

@@ -97,8 +97,9 @@ class ZTimelineTieredHeader extends StatelessWidget {
domainEnd: effectiveViewport.end, domainEnd: effectiveViewport.end,
borderColor: Theme.of(context).colorScheme.outlineVariant, borderColor: Theme.of(context).colorScheme.outlineVariant,
labelColor: Theme.of(context).colorScheme.onSurface, labelColor: Theme.of(context).colorScheme.onSurface,
secondaryLabelColor: secondaryLabelColor: Theme.of(
Theme.of(context).colorScheme.onSurfaceVariant, context,
).colorScheme.onSurfaceVariant,
), ),
), ),
), ),
@@ -126,9 +127,9 @@ class _ConfigIndicator extends StatelessWidget {
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Text( child: Text(
'Zoom: $configName', 'Zoom: $configName',
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(
color: colorScheme.onSurfaceVariant, context,
), ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
), ),
); );
} }
@@ -155,8 +156,7 @@ class _TieredHeaderPainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final borderPaint = final borderPaint = Paint()
Paint()
..color = borderColor ..color = borderColor
..strokeWidth = 1.0; ..strokeWidth = 1.0;

View File

@@ -2,14 +2,12 @@ import 'package:flutter/material.dart';
import '../constants.dart'; import '../constants.dart';
import '../models/entry_drag_state.dart'; import '../models/entry_drag_state.dart';
import '../models/projected_entry.dart'; import '../models/entry_resize_state.dart';
import '../models/timeline_entry.dart'; import '../models/timeline_entry.dart';
import '../models/timeline_group.dart'; import '../models/timeline_group.dart';
import '../services/timeline_projection_service.dart';
import '../state/timeline_viewport_notifier.dart'; import '../state/timeline_viewport_notifier.dart';
import 'draggable_event_pill.dart'; import 'drag_preview.dart';
import 'ghost_overlay.dart'; import 'interactive_event_pill.dart';
import 'group_drop_target.dart';
import 'timeline_scope.dart'; import 'timeline_scope.dart';
typedef EntryLabelBuilder = String Function(TimelineEntry entry); typedef EntryLabelBuilder = String Function(TimelineEntry entry);
@@ -24,8 +22,24 @@ typedef OnEntryMoved =
int newLane, int newLane,
); );
/// Callback signature for when an entry is resized via edge drag.
typedef OnEntryResized =
void Function(
TimelineEntry entry,
DateTime newStart,
DateTime newEnd,
int newLane,
);
/// Callback signature for when an entry is tapped / selected.
typedef OnEntrySelected = void Function(TimelineEntry entry);
/// Base timeline view: renders groups with between-group headers and /// Base timeline view: renders groups with between-group headers and
/// lane rows containing event pills. /// lane rows containing event pills.
///
/// Viewport changes (pan/zoom) are handled by each pill's internal
/// [AnimatedBuilder] — the parent tree only rebuilds when [entries]
/// or [groups] change.
class ZTimelineView extends StatelessWidget { class ZTimelineView extends StatelessWidget {
const ZTimelineView({ const ZTimelineView({
super.key, super.key,
@@ -37,7 +51,10 @@ class ZTimelineView extends StatelessWidget {
this.laneHeight = ZTimelineConstants.laneHeight, this.laneHeight = ZTimelineConstants.laneHeight,
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight, this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
this.onEntryMoved, this.onEntryMoved,
this.onEntryResized,
this.onEntrySelected,
this.enableDrag = true, this.enableDrag = true,
this.selectedEntryId,
}); });
final List<TimelineGroup> groups; final List<TimelineGroup> groups;
@@ -54,19 +71,45 @@ class ZTimelineView extends StatelessWidget {
/// position. The [newLane] is calculated to avoid collisions. /// position. The [newLane] is calculated to avoid collisions.
final OnEntryMoved? onEntryMoved; final OnEntryMoved? onEntryMoved;
/// Callback invoked when an entry is resized via edge drag.
final OnEntryResized? onEntryResized;
/// Callback invoked when an entry is tapped / selected.
final OnEntrySelected? onEntrySelected;
/// Whether drag-and-drop is enabled. /// Whether drag-and-drop is enabled.
final bool enableDrag; final bool enableDrag;
/// ID of the currently selected entry, if any.
final String? selectedEntryId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( // Group entries by groupId and sort by lane+start.
animation: viewport, // Runs once per entries/groups change, NOT on every pan frame.
builder: (context, _) { final grouped = <String, List<TimelineEntry>>{};
final projected = const TimelineProjectionService().project( for (final e in entries) {
entries: entries, (grouped[e.groupId] ??= []).add(e);
domainStart: viewport.start, }
domainEnd: viewport.end, for (final list in grouped.values) {
); list.sort((a, b) {
final cmp = a.lane.compareTo(b.lane);
return cmp != 0 ? cmp : a.start.compareTo(b.start);
});
}
// 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@@ -79,16 +122,18 @@ class ZTimelineView extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final group = groups[index]; final group = groups[index];
final groupEntries = final groupEntries =
projected[group.id] ?? const <ProjectedEntry>[]; grouped[group.id] ?? const <TimelineEntry>[];
final lanesCount = _countLanes(groupEntries); final lanesCount = _countLanes(groupEntries);
Widget groupColumn = Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_GroupHeader(title: group.title, height: groupHeaderHeight), _GroupHeader(title: group.title, height: groupHeaderHeight),
_GroupLanes( RepaintBoundary(
child: _GroupLanes(
group: group, group: group,
entries: groupEntries, entries: groupEntries,
allEntries: entries,
viewport: viewport, viewport: viewport,
lanesCount: lanesCount, lanesCount: lanesCount,
laneHeight: laneHeight, laneHeight: laneHeight,
@@ -96,42 +141,26 @@ class ZTimelineView extends StatelessWidget {
labelBuilder: labelBuilder, labelBuilder: labelBuilder,
contentWidth: contentWidth, contentWidth: contentWidth,
enableDrag: enableDrag, 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; var maxLane = 0;
for (final e in entries) { for (final e in entries) {
if (e.entry.lane > maxLane) maxLane = e.entry.lane; if (e.lane > maxLane) maxLane = e.lane;
} }
return maxLane.clamp(0, 1000); // basic guard return maxLane.clamp(0, 1000); // basic guard
} }
@@ -162,10 +191,11 @@ class _GroupHeader extends StatelessWidget {
} }
} }
class _GroupLanes extends StatelessWidget { class _GroupLanes extends StatefulWidget {
const _GroupLanes({ const _GroupLanes({
required this.group, required this.group,
required this.entries, required this.entries,
required this.allEntries,
required this.viewport, required this.viewport,
required this.lanesCount, required this.lanesCount,
required this.laneHeight, required this.laneHeight,
@@ -173,10 +203,17 @@ class _GroupLanes extends StatelessWidget {
required this.colorBuilder, required this.colorBuilder,
required this.contentWidth, required this.contentWidth,
required this.enableDrag, required this.enableDrag,
required this.nextEntryInLane,
required this.groupHeaderHeight,
this.onEntryResized,
this.onEntryMoved,
this.onEntrySelected,
this.selectedEntryId,
}); });
final TimelineGroup group; final TimelineGroup group;
final List<ProjectedEntry> entries; final List<TimelineEntry> entries;
final List<TimelineEntry> allEntries;
final TimelineViewportNotifier viewport; final TimelineViewportNotifier viewport;
final int lanesCount; final int lanesCount;
final double laneHeight; final double laneHeight;
@@ -184,27 +221,108 @@ class _GroupLanes extends StatelessWidget {
final EntryColorBuilder colorBuilder; final EntryColorBuilder colorBuilder;
final double contentWidth; final double contentWidth;
final bool enableDrag; final bool enableDrag;
final OnEntryResized? onEntryResized;
final OnEntryMoved? onEntryMoved;
final OnEntrySelected? onEntrySelected;
final String? selectedEntryId;
final Map<String, TimelineEntry> nextEntryInLane;
final double groupHeaderHeight;
@override
State<_GroupLanes> createState() => _GroupLanesState();
}
class _GroupLanesState extends State<_GroupLanes> {
final GlobalKey _lanesKey = GlobalKey();
@override
void didChangeDependencies() {
super.didChangeDependencies();
_registerGroup();
}
@override
void didUpdateWidget(_GroupLanes oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.group.id != widget.group.id) {
final scope = ZTimelineScope.maybeOf(context);
scope?.groupRegistry.unregister(oldWidget.group.id);
}
if (oldWidget.lanesCount != widget.lanesCount ||
oldWidget.laneHeight != widget.laneHeight ||
oldWidget.contentWidth != widget.contentWidth ||
oldWidget.group.id != widget.group.id) {
_registerGroup();
}
}
@override
void dispose() {
final scope = ZTimelineScope.maybeOf(context);
scope?.groupRegistry.unregister(widget.group.id);
super.dispose();
}
void _registerGroup() {
final scope = ZTimelineScope.maybeOf(context);
if (scope == null || !widget.enableDrag) return;
scope.groupRegistry.register(
widget.group.id,
_lanesKey,
verticalOffset: ZTimelineConstants.verticalOuterPadding,
lanesCount: widget.lanesCount,
laneHeight: widget.laneHeight,
contentWidth: widget.contentWidth,
headerHeight: widget.groupHeaderHeight,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scope = ZTimelineScope.maybeOf(context); final scope = ZTimelineScope.maybeOf(context);
// Build pill widgets once per build() call (stable between interaction
// changes). When the ListenableBuilder fires, the same widget objects
// are reused — Flutter matches by identity and skips rebuilding.
final pillWidgets = <Widget>[
for (final entry in widget.entries)
InteractiveEventPill(
key: ValueKey(entry.id),
entry: entry,
laneHeight: widget.laneHeight,
labelBuilder: widget.labelBuilder,
colorBuilder: widget.colorBuilder,
contentWidth: widget.contentWidth,
enableDrag: widget.enableDrag,
viewport: widget.viewport,
allEntries: widget.allEntries,
onEntryResized: widget.onEntryResized,
onEntryMoved: widget.onEntryMoved,
onEntrySelected: widget.onEntrySelected,
selectedEntryId: widget.selectedEntryId,
groupRegistry: scope?.groupRegistry,
nextEntryInLane: widget.nextEntryInLane[entry.id],
),
];
// If no scope (drag not enabled), use static height // If no scope (drag not enabled), use static height
if (scope == null || !enableDrag) { if (scope == null || !widget.enableDrag) {
return _buildContent(context, lanesCount); return _wrapContent(pillWidgets, widget.lanesCount, null);
} }
// Listen to interaction notifier for drag state changes // Listen to interaction only for height changes + drag preview.
return ListenableBuilder( return ListenableBuilder(
listenable: scope.interaction, listenable: scope.interaction,
builder: (context, _) { builder: (context, _) {
final effectiveLanesCount = _calculateEffectiveLanesCount( final effectiveLanesCount = _calculateEffectiveLanesCount(
actualLanesCount: lanesCount, actualLanesCount: widget.lanesCount,
dragState: scope.interaction.dragState, dragState: scope.interaction.dragState,
groupId: group.id, resizeState: scope.interaction.resizeState,
groupId: widget.group.id,
); );
return _buildContent(context, effectiveLanesCount); return _wrapContent(pillWidgets, effectiveLanesCount, scope);
}, },
); );
} }
@@ -212,66 +330,47 @@ class _GroupLanes extends StatelessWidget {
int _calculateEffectiveLanesCount({ int _calculateEffectiveLanesCount({
required int actualLanesCount, required int actualLanesCount,
required EntryDragState? dragState, required EntryDragState? dragState,
required EntryResizeState? resizeState,
required String groupId, required String groupId,
}) { }) {
// No drag active - use actual lane count var effective = actualLanesCount;
if (dragState == null) {
return actualLanesCount; // Expand for drag target lane
if (dragState != null && dragState.targetGroupId == groupId) {
if (dragState.targetLane > effective) {
effective = dragState.targetLane;
}
} }
// Drag active but over different group - use actual lane count // Expand for resize target lane
if (dragState.targetGroupId != groupId) { if (resizeState != null && resizeState.originalEntry.groupId == groupId) {
return actualLanesCount; if (resizeState.targetLane > effective) {
effective = resizeState.targetLane;
}
} }
// Drag active over this group - expand to accommodate target lane return effective;
return actualLanesCount > dragState.targetLane
? actualLanesCount
: dragState.targetLane;
} }
Widget _buildContent(BuildContext context, int effectiveLanesCount) { Widget _wrapContent(
List<Widget> pillWidgets,
int effectiveLanesCount,
ZTimelineScopeData? scope,
) {
final totalHeight = final totalHeight =
effectiveLanesCount * laneHeight + effectiveLanesCount * widget.laneHeight +
(effectiveLanesCount > 0 (effectiveLanesCount > 0
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing ? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
: 0); : 0);
final scope = ZTimelineScope.maybeOf(context);
// The inner Stack with pills and ghost overlay final innerStack = SizedBox(
Widget innerStack = SizedBox(
height: totalHeight, height: totalHeight,
width: double.infinity, width: double.infinity,
child: Stack( child: Stack(
children: [ children: [
// Event pills ...pillWidgets,
for (final e in entries) // Ghost overlay for drag operations
DraggableEventPill( if (widget.enableDrag && scope != null) _buildDragPreview(scope),
entry: e,
laneHeight: laneHeight,
labelBuilder: labelBuilder,
colorBuilder: colorBuilder,
contentWidth: contentWidth,
enableDrag: enableDrag,
),
// Ghost overlay (rendered in same coordinate space as pills)
if (enableDrag && scope != null)
ListenableBuilder(
listenable: scope.interaction,
builder: (context, _) {
final dragState = scope.interaction.dragState;
if (dragState == null || dragState.targetGroupId != group.id) {
return const SizedBox.shrink();
}
return GhostOverlay(
dragState: dragState,
viewport: viewport,
contentWidth: contentWidth,
laneHeight: laneHeight,
);
},
),
], ],
), ),
); );
@@ -281,6 +380,7 @@ class _GroupLanes extends StatelessWidget {
curve: Curves.easeInOut, curve: Curves.easeInOut,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Padding( child: Padding(
key: _lanesKey,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: ZTimelineConstants.verticalOuterPadding, vertical: ZTimelineConstants.verticalOuterPadding,
), ),
@@ -288,4 +388,42 @@ class _GroupLanes extends StatelessWidget {
), ),
); );
} }
Widget _buildDragPreview(ZTimelineScopeData scope) {
final dragState = scope.interaction.dragState;
final resizeState = scope.interaction.resizeState;
// Show ghost for resize (entry stays in its own group)
if (resizeState != null &&
resizeState.originalEntry.groupId == widget.group.id) {
return DragPreview(
targetStart: resizeState.targetStart,
targetEnd: resizeState.targetEnd,
targetLane: resizeState.targetLane,
viewport: widget.viewport,
contentWidth: widget.contentWidth,
laneHeight: widget.laneHeight,
color: widget.colorBuilder(resizeState.originalEntry),
label: widget.labelBuilder(resizeState.originalEntry),
hasEnd: resizeState.originalEntry.hasEnd,
);
}
// Show ghost for drag-move
if (dragState != null && dragState.targetGroupId == widget.group.id) {
return DragPreview(
targetStart: dragState.targetStart,
targetEnd: dragState.targetEnd,
targetLane: dragState.targetLane,
viewport: widget.viewport,
contentWidth: widget.contentWidth,
laneHeight: widget.laneHeight,
color: widget.colorBuilder(dragState.originalEntry),
label: widget.labelBuilder(dragState.originalEntry),
hasEnd: dragState.originalEntry.hasEnd,
);
}
return const SizedBox.shrink();
}
} }

View File

@@ -7,9 +7,9 @@ export 'src/constants.dart';
// Models // Models
export 'src/models/breadcrumb_segment.dart'; export 'src/models/breadcrumb_segment.dart';
export 'src/models/entry_drag_state.dart'; export 'src/models/entry_drag_state.dart';
export 'src/models/entry_resize_state.dart';
export 'src/models/interaction_config.dart'; export 'src/models/interaction_config.dart';
export 'src/models/interaction_state.dart'; export 'src/models/interaction_state.dart';
export 'src/models/projected_entry.dart';
export 'src/models/tier_config.dart'; export 'src/models/tier_config.dart';
export 'src/models/tier_section.dart'; export 'src/models/tier_section.dart';
export 'src/models/tiered_tick_data.dart'; export 'src/models/tiered_tick_data.dart';
@@ -23,17 +23,18 @@ export 'src/services/entry_placement_service.dart';
export 'src/services/layout_coordinate_service.dart'; export 'src/services/layout_coordinate_service.dart';
export 'src/services/tiered_tick_service.dart'; export 'src/services/tiered_tick_service.dart';
export 'src/services/time_scale_service.dart'; export 'src/services/time_scale_service.dart';
export 'src/services/timeline_projection_service.dart'; export 'src/services/timeline_group_registry.dart';
// State // State
export 'src/state/timeline_interaction_notifier.dart'; export 'src/state/timeline_interaction_notifier.dart';
export 'src/state/timeline_viewport_notifier.dart'; export 'src/state/timeline_viewport_notifier.dart';
// Widgets // Widgets
export 'src/widgets/breadcrumb_segment_chip.dart'; export 'src/widgets/breadcrumb_segment_chip.dart';
export 'src/widgets/draggable_event_pill.dart'; export 'src/widgets/entry_popover_overlay.dart';
export 'src/widgets/ghost_overlay.dart'; export 'src/widgets/event_pill.dart';
export 'src/widgets/group_drop_target.dart'; export 'src/widgets/event_point.dart';
export 'src/widgets/drag_preview.dart';
export 'src/widgets/interactive_event_pill.dart';
export 'src/widgets/timeline_breadcrumb.dart'; export 'src/widgets/timeline_breadcrumb.dart';
export 'src/widgets/timeline_interactor.dart'; export 'src/widgets/timeline_interactor.dart';
export 'src/widgets/timeline_scope.dart'; export 'src/widgets/timeline_scope.dart';

View 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"

View File

@@ -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);

View File

@@ -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)),
);
});
}

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});
}

View File

@@ -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);
});
});
});
}

View File

@@ -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),
);
});
});
});
}

View File

@@ -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);
});
});
});
}

View File

@@ -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);
});
});
});
}

View File

@@ -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);
});
});
});
}

View File

@@ -1,6 +1,14 @@
import { cpSync, readFileSync, writeFileSync } from "node:fs"; import {
copyFileSync,
cpSync,
existsSync,
readFileSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { resolve, dirname } from "node:path"; import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const src = resolve(__dirname, "../build/web"); const src = resolve(__dirname, "../build/web");
@@ -9,6 +17,22 @@ const dest = resolve(__dirname, "../../../apps/web/public/flutter");
cpSync(src, dest, { recursive: true }); cpSync(src, dest, { recursive: true });
console.log(`Copied Flutter build: ${src}${dest}`); console.log(`Copied Flutter build: ${src}${dest}`);
// Copy flutter.js.map from the Flutter SDK (not included in build output)
const flutterBin = execFileSync("which", ["flutter"], {
encoding: "utf-8",
}).trim();
const sdkBinDir = dirname(realpathSync(flutterBin));
const flutterJsMap = resolve(
sdkBinDir,
"cache/flutter_web_sdk/flutter_js/flutter.js.map",
);
if (existsSync(flutterJsMap)) {
copyFileSync(flutterJsMap, resolve(dest, "flutter.js.map"));
console.log("Copied flutter.js.map from SDK");
} else {
console.warn(`flutter.js.map not found at ${flutterJsMap}`);
}
// Extract buildConfig from flutter_bootstrap.js so the React app can fetch it // Extract buildConfig from flutter_bootstrap.js so the React app can fetch it
const bootstrap = readFileSync(resolve(dest, "flutter_bootstrap.js"), "utf-8"); const bootstrap = readFileSync(resolve(dest, "flutter_bootstrap.js"), "utf-8");
const match = bootstrap.match(/_flutter\.buildConfig\s*=\s*({.*?});/); const match = bootstrap.match(/_flutter\.buildConfig\s*=\s*({.*?});/);

View File

@@ -1,46 +1,410 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF"> <base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <meta name="description" content="Zendegi Timeline - Dev Mode">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="z_flutter"> <meta name="apple-mobile-web-app-title" content="z_flutter">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>Zendegi Timeline (Dev)</title>
<title>z_flutter</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
flex-direction: column;
height: 100vh;
}
#toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #16213e;
border-bottom: 1px solid #0f3460;
font-size: 13px;
flex-shrink: 0;
flex-wrap: wrap;
}
#toolbar .group { display: flex; align-items: center; gap: 4px; }
#toolbar label { color: #8899aa; }
#toolbar button {
padding: 4px 10px;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
cursor: pointer;
font-size: 12px;
}
#toolbar button:hover { background: #0f3460; }
#toolbar .sep { width: 1px; height: 20px; background: #0f3460; }
#event-log {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 180px;
overflow-y: auto;
background: #0d1117;
border-top: 1px solid #0f3460;
font-family: monospace;
font-size: 12px;
padding: 6px 10px;
z-index: 100;
}
#event-log .entry { padding: 2px 0; border-bottom: 1px solid #161b22; }
#event-log .type { color: #58a6ff; }
#event-log .time { color: #6e7681; margin-right: 8px; }
#event-log .payload { color: #8b949e; }
#flutter-container { flex: 1; min-height: 0; position: relative; }
</style>
</head> </head>
<body> <body>
<!-- <div id="toolbar">
You can customize the "flutter_bootstrap.js" script. <div class="group">
This is useful to provide a custom configuration to the Flutter loader <label>Theme:</label>
or to give the user feedback during the initialization process. <button id="btn-light">Light</button>
<button id="btn-dark">Dark</button>
</div>
<div class="sep"></div>
<div class="group">
<label>Data:</label>
<button id="btn-few">Few items</button>
<button id="btn-many">Many items</button>
<button id="btn-empty">Empty</button>
</div>
<div class="sep"></div>
<div class="group">
<button id="btn-push">Push state</button>
<button id="btn-clear-log">Clear log</button>
</div>
</div>
For more details: <div id="flutter-container"></div>
* https://docs.flutter.dev/platform-integration/web/initialization <div id="event-log"></div>
-->
<script>
// -----------------------------------------------------------------------
// Test data generators
// -----------------------------------------------------------------------
function makeId() {
return crypto.randomUUID();
}
function iso(year, month, day) {
return new Date(year, month - 1, day).toISOString();
}
function buildFewItems() {
const g1 = makeId();
const g2 = makeId();
const g3 = makeId();
const items = {};
const addItem = (groupId, title, start, end, lane, desc) => {
const id = makeId();
items[id] = {
id, groupId, title, description: desc ?? null,
start, end: end ?? null, lane,
};
};
// Work group
addItem(g1, "Project Alpha", iso(2026, 1, 5), iso(2026, 3, 15), 1, "Main project");
addItem(g1, "Project Beta", iso(2026, 2, 10), iso(2026, 5, 20), 2, "Secondary project");
addItem(g1, "Code Review", iso(2026, 3, 1), iso(2026, 3, 5), 1);
addItem(g1, "Sprint Planning", iso(2026, 1, 15), null, 3); // point event
// Personal group
addItem(g2, "Vacation", iso(2026, 4, 1), iso(2026, 4, 14), 1, "Spring break");
addItem(g2, "Birthday", iso(2026, 6, 12), null, 1); // point event
addItem(g2, "Move apartments", iso(2026, 3, 20), iso(2026, 3, 25), 2);
// Learning group
addItem(g3, "Flutter course", iso(2026, 1, 1), iso(2026, 2, 28), 1);
addItem(g3, "Rust book", iso(2026, 2, 15), iso(2026, 4, 30), 2);
addItem(g3, "Conference talk", iso(2026, 5, 10), null, 1); // point event
return {
timeline: { id: makeId(), title: "My Timeline" },
groups: {
[g1]: { id: g1, title: "Work", sortOrder: 0 },
[g2]: { id: g2, title: "Personal", sortOrder: 1 },
[g3]: { id: g3, title: "Learning", sortOrder: 2 },
},
items,
groupOrder: [g1, g2, g3],
selectedItemId: null,
darkMode: true,
};
}
function buildManyItems() {
const groupCount = 5;
const groupIds = Array.from({ length: groupCount }, makeId);
const groupNames = ["Engineering", "Design", "Marketing", "Operations", "Research"];
const groups = {};
for (let i = 0; i < groupCount; i++) {
groups[groupIds[i]] = { id: groupIds[i], title: groupNames[i], sortOrder: i };
}
const items = {};
const baseDate = new Date(2026, 0, 1);
let itemIndex = 0;
for (const gId of groupIds) {
for (let lane = 1; lane <= 3; lane++) {
for (let j = 0; j < 4; j++) {
const id = makeId();
const startOffset = j * 45 + lane * 5 + Math.floor(Math.random() * 10);
const duration = 14 + Math.floor(Math.random() * 30);
const start = new Date(baseDate);
start.setDate(start.getDate() + startOffset);
const end = new Date(start);
end.setDate(end.getDate() + duration);
const isPoint = Math.random() < 0.15;
items[id] = {
id, groupId: gId,
title: `Task ${++itemIndex}`,
description: null,
start: start.toISOString(),
end: isPoint ? null : end.toISOString(),
lane,
};
}
}
}
return {
timeline: { id: makeId(), title: "Large Timeline" },
groups, items,
groupOrder: groupIds,
selectedItemId: null,
darkMode: true,
};
}
function buildEmpty() {
const g1 = makeId();
return {
timeline: { id: makeId(), title: "Empty Timeline" },
groups: { [g1]: { id: g1, title: "Untitled Group", sortOrder: 0 } },
items: {},
groupOrder: [g1],
selectedItemId: null,
darkMode: true,
};
}
// -----------------------------------------------------------------------
// Bridge state
// -----------------------------------------------------------------------
let currentState = buildFewItems();
let updateStateCallback = null;
window.__zendegi__ = {
getState: () => JSON.stringify(currentState),
onEvent: (json) => {
const event = JSON.parse(json);
logEvent(event);
handleEvent(event);
},
set updateState(cb) { updateStateCallback = cb; },
get updateState() { return updateStateCallback; },
};
function pushState(state) {
currentState = state;
if (updateStateCallback) {
updateStateCallback(JSON.stringify(state));
}
}
// -----------------------------------------------------------------------
// Event handling
// -----------------------------------------------------------------------
function handleEvent(event) {
switch (event.type) {
case "content_height":
// No-op in dev page — flex layout handles sizing.
// The event is still logged via logEvent().
break;
case "entry_moved": {
const { entryId, newStart, newEnd, newGroupId, newLane } = event.payload;
const item = currentState.items[entryId];
if (item) {
currentState.items[entryId] = {
...item,
start: newStart,
end: newEnd,
groupId: newGroupId,
lane: newLane,
};
}
break;
}
case "entry_resized": {
const { entryId, newStart, newEnd, groupId, lane } = event.payload;
const item = currentState.items[entryId];
if (item) {
currentState.items[entryId] = {
...item,
start: newStart,
end: newEnd,
groupId: groupId,
lane: lane,
};
}
break;
}
case "item_selected":
currentState.selectedItemId = event.payload.itemId;
pushState(currentState);
break;
case "item_deselected":
currentState.selectedItemId = null;
pushState(currentState);
break;
}
}
// -----------------------------------------------------------------------
// Event log
// -----------------------------------------------------------------------
function logEvent(event) {
const log = document.getElementById("event-log");
const entry = document.createElement("div");
entry.className = "entry";
const now = new Date().toLocaleTimeString("en-GB", { hour12: false });
const payloadStr = event.payload ? " " + JSON.stringify(event.payload) : "";
entry.innerHTML =
`<span class="time">${now}</span>` +
`<span class="type">${event.type}</span>` +
`<span class="payload">${payloadStr}</span>`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
// -----------------------------------------------------------------------
// Toolbar actions
// -----------------------------------------------------------------------
function setPageTheme(isDark) {
const body = document.body;
const toolbar = document.getElementById("toolbar");
const eventLog = document.getElementById("event-log");
if (isDark) {
body.style.background = "#1a1a2e";
body.style.color = "#e0e0e0";
toolbar.style.background = "#16213e";
toolbar.style.borderBottomColor = "#0f3460";
eventLog.style.background = "#0d1117";
eventLog.style.borderTopColor = "#0f3460";
for (const btn of toolbar.querySelectorAll("button")) {
btn.style.background = "#1a1a2e";
btn.style.color = "#e0e0e0";
btn.style.borderColor = "#0f3460";
}
for (const sep of toolbar.querySelectorAll(".sep")) {
sep.style.background = "#0f3460";
}
for (const lbl of toolbar.querySelectorAll("label")) {
lbl.style.color = "#8899aa";
}
} else {
body.style.background = "#f0f0f0";
body.style.color = "#333";
toolbar.style.background = "#e0e0e0";
toolbar.style.borderBottomColor = "#ccc";
eventLog.style.background = "#f5f5f5";
eventLog.style.borderTopColor = "#ccc";
for (const btn of toolbar.querySelectorAll("button")) {
btn.style.background = "#fff";
btn.style.color = "#333";
btn.style.borderColor = "#ccc";
}
for (const sep of toolbar.querySelectorAll(".sep")) {
sep.style.background = "#ccc";
}
for (const lbl of toolbar.querySelectorAll("label")) {
lbl.style.color = "#666";
}
}
}
document.getElementById("btn-dark").addEventListener("click", () => {
currentState.darkMode = true;
setPageTheme(true);
pushState(currentState);
});
document.getElementById("btn-light").addEventListener("click", () => {
currentState.darkMode = false;
setPageTheme(false);
pushState(currentState);
});
document.getElementById("btn-few").addEventListener("click", () => {
const state = buildFewItems();
state.darkMode = currentState.darkMode;
pushState(state);
});
document.getElementById("btn-many").addEventListener("click", () => {
const state = buildManyItems();
state.darkMode = currentState.darkMode;
pushState(state);
});
document.getElementById("btn-empty").addEventListener("click", () => {
const state = buildEmpty();
state.darkMode = currentState.darkMode;
pushState(state);
});
document.getElementById("btn-push").addEventListener("click", () => {
pushState(currentState);
});
document.getElementById("btn-clear-log").addEventListener("click", () => {
document.getElementById("event-log").innerHTML = "";
});
</script>
<script>
// Intercept Flutter bootstrap to render into #flutter-container
window._flutter = {};
let _loaderInstance = null;
Object.defineProperty(window._flutter, 'loader', {
set(loader) {
const origLoad = loader.load.bind(loader);
loader.load = (opts = {}) => origLoad({
...opts,
onEntrypointLoaded: async (engineInitializer) => {
const appRunner = await engineInitializer.initializeEngine({
hostElement: document.getElementById('flutter-container'),
});
appRunner.runApp();
},
});
_loaderInstance = loader;
},
get() { return _loaderInstance; },
configurable: true,
});
</script>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>
</body> </body>
</html> </html>