add and edit
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
**/.claude/**
|
**/.claude/**
|
||||||
**/public/flutter/**
|
**/public/flutter/**
|
||||||
**/z-timeline/**
|
**/z-flutter/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export const createTimelineItem = createServerFn({ method: "POST" })
|
|||||||
start: z.string().transform((s) => new Date(s)),
|
start: z.string().transform((s) => new Date(s)),
|
||||||
end: z
|
end: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.nullable()
|
||||||
.transform((s) => (s ? new Date(s) : undefined)),
|
.transform((s) => (s ? new Date(s) : null)),
|
||||||
timelineGroupId: z.string().uuid(),
|
timelineGroupId: z.string().uuid(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
|
|||||||
.transform((s) => (s ? new Date(s) : null)),
|
.transform((s) => (s ? new Date(s) : null)),
|
||||||
timelineGroupId: z.string().uuid(),
|
timelineGroupId: z.string().uuid(),
|
||||||
lane: z.number().int().min(1),
|
lane: z.number().int().min(1),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
@@ -27,6 +29,10 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
|
|||||||
end: data.end,
|
end: data.end,
|
||||||
timelineGroupId: data.timelineGroupId,
|
timelineGroupId: data.timelineGroupId,
|
||||||
lane: data.lane,
|
lane: data.lane,
|
||||||
|
...(data.title !== undefined && { title: data.title }),
|
||||||
|
...(data.description !== undefined && {
|
||||||
|
description: data.description,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.where(eq(timelineItem.id, data.id))
|
.where(eq(timelineItem.id, data.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { Button } from "@/components/ui/button";
|
||||||
import UserMenu from "@/components/user-menu";
|
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 { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation";
|
||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
@@ -24,11 +25,16 @@ 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 entryResized = useEntryResizedMutation(timelineId);
|
const entryResized = useEntryResizedMutation(timelineId);
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const editItem = selectedItemId
|
||||||
|
? (timeline.items[selectedItemId] ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const flutterState: FlutterTimelineState = useMemo(
|
const flutterState: FlutterTimelineState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
timeline: { id: timeline.id, title: timeline.title },
|
timeline: { id: timeline.id, title: timeline.title },
|
||||||
@@ -62,9 +68,11 @@ 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);
|
||||||
@@ -77,6 +85,18 @@ function RouteComponent() {
|
|||||||
[entryMoved, entryResized]
|
[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">
|
||||||
<div className="flex flex-row items-center justify-between px-2 py-1">
|
<div className="flex flex-row items-center justify-between px-2 py-1">
|
||||||
@@ -91,7 +111,7 @@ function RouteComponent() {
|
|||||||
<h1 className="text-lg font-serif font-bold">{timeline.title}</h1>
|
<h1 className="text-lg font-serif font-bold">{timeline.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add Item
|
Add Item
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface ItemForLane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assignLane(
|
function assignLane(
|
||||||
existing: ItemForLane[],
|
existing: Array<ItemForLane>,
|
||||||
newStart: Date,
|
newStart: Date,
|
||||||
newEnd: Date | null
|
newEnd: Date | null
|
||||||
): number {
|
): number {
|
||||||
@@ -57,7 +57,7 @@ async function main() {
|
|||||||
|
|
||||||
if (items.length === 0) continue;
|
if (items.length === 0) continue;
|
||||||
|
|
||||||
const assigned: ItemForLane[] = [];
|
const assigned: Array<ItemForLane> = [];
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ class _MainAppState extends State<MainApp> {
|
|||||||
return (start: earliest.subtract(padding), end: latest.add(padding));
|
return (start: earliest.subtract(padding), end: latest.add(padding));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onEntrySelected(TimelineEntry entry) {
|
||||||
|
emitEvent('item_selected', {'itemId': entry.id});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onBackgroundTap() {
|
||||||
|
emitEvent('item_deselected');
|
||||||
|
}
|
||||||
|
|
||||||
void _onEntryMoved(
|
void _onEntryMoved(
|
||||||
TimelineEntry entry,
|
TimelineEntry entry,
|
||||||
DateTime newStart,
|
DateTime newStart,
|
||||||
@@ -346,6 +354,7 @@ class _MainAppState extends State<MainApp> {
|
|||||||
const ZTimelineTieredHeader(),
|
const ZTimelineTieredHeader(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ZTimelineInteractor(
|
child: ZTimelineInteractor(
|
||||||
|
onBackgroundTap: _onBackgroundTap,
|
||||||
child: ZTimelineView(
|
child: ZTimelineView(
|
||||||
groups: _groups,
|
groups: _groups,
|
||||||
entries: _entries,
|
entries: _entries,
|
||||||
@@ -355,6 +364,8 @@ class _MainAppState extends State<MainApp> {
|
|||||||
enableDrag: true,
|
enableDrag: true,
|
||||||
onEntryMoved: _onEntryMoved,
|
onEntryMoved: _onEntryMoved,
|
||||||
onEntryResized: _onEntryResized,
|
onEntryResized: _onEntryResized,
|
||||||
|
onEntrySelected: _onEntrySelected,
|
||||||
|
selectedEntryId: _state?.selectedItemId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import '../constants.dart';
|
|||||||
/// A standalone event pill widget with entry color, rounded corners,
|
/// A standalone event pill widget with entry color, rounded corners,
|
||||||
/// and auto text color based on brightness.
|
/// and auto text color based on brightness.
|
||||||
class EventPill extends StatelessWidget {
|
class EventPill extends StatelessWidget {
|
||||||
const EventPill({required this.color, required this.label, super.key});
|
const EventPill({
|
||||||
|
required this.color,
|
||||||
|
required this.label,
|
||||||
|
this.isSelected = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final Color color;
|
final Color color;
|
||||||
final String label;
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -17,6 +23,8 @@ class EventPill extends StatelessWidget {
|
|||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black87;
|
: Colors.black87;
|
||||||
|
|
||||||
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: ZTimelineConstants.pillPadding,
|
padding: ZTimelineConstants.pillPadding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -24,6 +32,9 @@ class EventPill extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
ZTimelineConstants.pillBorderRadius,
|
ZTimelineConstants.pillBorderRadius,
|
||||||
),
|
),
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: primaryColor, width: 2)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ class EventPoint extends StatelessWidget {
|
|||||||
required this.color,
|
required this.color,
|
||||||
required this.label,
|
required this.label,
|
||||||
this.maxTextWidth,
|
this.maxTextWidth,
|
||||||
|
this.isSelected = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Color color;
|
final Color color;
|
||||||
final String label;
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
/// Maximum width for the label text. When provided, text truncates with
|
/// Maximum width for the label text. When provided, text truncates with
|
||||||
/// ellipsis. When null the text sizes naturally.
|
/// ellipsis. When null the text sizes naturally.
|
||||||
@@ -49,6 +51,12 @@ class EventPoint extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showText) ...[
|
if (showText) ...[
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class InteractiveEventPill extends StatefulWidget {
|
|||||||
this.allEntries = const [],
|
this.allEntries = const [],
|
||||||
this.onEntryResized,
|
this.onEntryResized,
|
||||||
this.onEntryMoved,
|
this.onEntryMoved,
|
||||||
|
this.onEntrySelected,
|
||||||
|
this.selectedEntryId,
|
||||||
this.groupRegistry,
|
this.groupRegistry,
|
||||||
this.nextEntryStartX,
|
this.nextEntryStartX,
|
||||||
super.key,
|
super.key,
|
||||||
@@ -46,6 +48,8 @@ class InteractiveEventPill extends StatefulWidget {
|
|||||||
final List<TimelineEntry> allEntries;
|
final List<TimelineEntry> allEntries;
|
||||||
final OnEntryResized? onEntryResized;
|
final OnEntryResized? onEntryResized;
|
||||||
final OnEntryMoved? onEntryMoved;
|
final OnEntryMoved? onEntryMoved;
|
||||||
|
final OnEntrySelected? onEntrySelected;
|
||||||
|
final String? selectedEntryId;
|
||||||
final TimelineGroupRegistry? groupRegistry;
|
final TimelineGroupRegistry? groupRegistry;
|
||||||
|
|
||||||
/// Normalized startX of the next entry in the same lane, used to compute
|
/// Normalized startX of the next entry in the same lane, used to compute
|
||||||
@@ -285,6 +289,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
final isPoint = !widget.entry.entry.hasEnd;
|
final isPoint = !widget.entry.entry.hasEnd;
|
||||||
final color = widget.colorBuilder(widget.entry.entry);
|
final color = widget.colorBuilder(widget.entry.entry);
|
||||||
final label = widget.labelBuilder(widget.entry.entry);
|
final label = widget.labelBuilder(widget.entry.entry);
|
||||||
|
final isSelected = widget.entry.entry.id == widget.selectedEntryId;
|
||||||
|
|
||||||
final top = LayoutCoordinateService.laneToY(
|
final top = LayoutCoordinateService.laneToY(
|
||||||
lane: widget.entry.entry.lane,
|
lane: widget.entry.entry.lane,
|
||||||
@@ -312,6 +317,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
color: color,
|
color: color,
|
||||||
label: label,
|
label: label,
|
||||||
maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0,
|
maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0,
|
||||||
|
isSelected: isSelected,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!widget.enableDrag) {
|
if (!widget.enableDrag) {
|
||||||
@@ -355,7 +361,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Range event (hasEnd == true)
|
// Range event (hasEnd == true)
|
||||||
final pill = EventPill(color: color, label: label);
|
final pill = EventPill(color: color, label: label, isSelected: isSelected);
|
||||||
final width = _pillWidth;
|
final width = _pillWidth;
|
||||||
|
|
||||||
if (!widget.enableDrag) {
|
if (!widget.enableDrag) {
|
||||||
@@ -434,6 +440,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
onExit: (_) => _onHoverExit(),
|
onExit: (_) => _onHoverExit(),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
|
||||||
onPanDown: _onPanDown,
|
onPanDown: _onPanDown,
|
||||||
onPanStart: _onPanStart,
|
onPanStart: _onPanStart,
|
||||||
onPanUpdate: _onPanUpdate,
|
onPanUpdate: _onPanUpdate,
|
||||||
@@ -453,6 +460,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
|||||||
onExit: (_) => _onHoverExit(),
|
onExit: (_) => _onHoverExit(),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
|
||||||
onPanDown: _onPanDown,
|
onPanDown: _onPanDown,
|
||||||
onPanStart: _onPanStart,
|
onPanStart: _onPanStart,
|
||||||
onPanUpdate: _onPanUpdate,
|
onPanUpdate: _onPanUpdate,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -231,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,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ typedef OnEntryResized =
|
|||||||
int newLane,
|
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.
|
||||||
class ZTimelineView extends StatelessWidget {
|
class ZTimelineView extends StatelessWidget {
|
||||||
@@ -47,7 +50,9 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
|
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
|
||||||
this.onEntryMoved,
|
this.onEntryMoved,
|
||||||
this.onEntryResized,
|
this.onEntryResized,
|
||||||
|
this.onEntrySelected,
|
||||||
this.enableDrag = true,
|
this.enableDrag = true,
|
||||||
|
this.selectedEntryId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TimelineGroup> groups;
|
final List<TimelineGroup> groups;
|
||||||
@@ -67,9 +72,15 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
/// Callback invoked when an entry is resized via edge drag.
|
/// Callback invoked when an entry is resized via edge drag.
|
||||||
final OnEntryResized? onEntryResized;
|
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(
|
return AnimatedBuilder(
|
||||||
@@ -112,6 +123,8 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
enableDrag: enableDrag,
|
enableDrag: enableDrag,
|
||||||
onEntryResized: onEntryResized,
|
onEntryResized: onEntryResized,
|
||||||
onEntryMoved: onEntryMoved,
|
onEntryMoved: onEntryMoved,
|
||||||
|
onEntrySelected: onEntrySelected,
|
||||||
|
selectedEntryId: selectedEntryId,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -171,6 +184,8 @@ class _GroupLanes extends StatefulWidget {
|
|||||||
required this.enableDrag,
|
required this.enableDrag,
|
||||||
this.onEntryResized,
|
this.onEntryResized,
|
||||||
this.onEntryMoved,
|
this.onEntryMoved,
|
||||||
|
this.onEntrySelected,
|
||||||
|
this.selectedEntryId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TimelineGroup group;
|
final TimelineGroup group;
|
||||||
@@ -185,6 +200,8 @@ class _GroupLanes extends StatefulWidget {
|
|||||||
final bool enableDrag;
|
final bool enableDrag;
|
||||||
final OnEntryResized? onEntryResized;
|
final OnEntryResized? onEntryResized;
|
||||||
final OnEntryMoved? onEntryMoved;
|
final OnEntryMoved? onEntryMoved;
|
||||||
|
final OnEntrySelected? onEntrySelected;
|
||||||
|
final String? selectedEntryId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_GroupLanes> createState() => _GroupLanesState();
|
State<_GroupLanes> createState() => _GroupLanesState();
|
||||||
@@ -330,6 +347,8 @@ class _GroupLanesState extends State<_GroupLanes> {
|
|||||||
allEntries: widget.allEntries,
|
allEntries: widget.allEntries,
|
||||||
onEntryResized: widget.onEntryResized,
|
onEntryResized: widget.onEntryResized,
|
||||||
onEntryMoved: widget.onEntryMoved,
|
onEntryMoved: widget.onEntryMoved,
|
||||||
|
onEntrySelected: widget.onEntrySelected,
|
||||||
|
selectedEntryId: widget.selectedEntryId,
|
||||||
groupRegistry: scope?.groupRegistry,
|
groupRegistry: scope?.groupRegistry,
|
||||||
nextEntryStartX: _findNextEntryStartX(
|
nextEntryStartX: _findNextEntryStartX(
|
||||||
widget.entries,
|
widget.entries,
|
||||||
|
|||||||
@@ -269,9 +269,11 @@
|
|||||||
}
|
}
|
||||||
case "item_selected":
|
case "item_selected":
|
||||||
currentState.selectedItemId = event.payload.itemId;
|
currentState.selectedItemId = event.payload.itemId;
|
||||||
|
pushState(currentState);
|
||||||
break;
|
break;
|
||||||
case "item_deselected":
|
case "item_deselected":
|
||||||
currentState.selectedItemId = null;
|
currentState.selectedItemId = null;
|
||||||
|
pushState(currentState);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user