diff --git a/apps/mobile/app/(app)/[workspace]/_layout.tsx b/apps/mobile/app/(app)/[workspace]/_layout.tsx index d98c2dd72..0f4214620 100644 --- a/apps/mobile/app/(app)/[workspace]/_layout.tsx +++ b/apps/mobile/app/(app)/[workspace]/_layout.tsx @@ -14,6 +14,7 @@ import { usePresenceRealtime } from "@/data/realtime/use-presence-realtime"; import { useWorkspacePresencePrefetch } from "@/lib/use-workspace-presence-prefetch"; import { ModalCloseButton } from "@/components/ui/modal-close-button"; import { useNewIssueDraftResetOnWorkspaceChange } from "@/data/stores/new-issue-draft-store"; +import { useNewProjectDraftResetOnWorkspaceChange } from "@/data/stores/new-project-draft-store"; import { useChatSessionPickerResetOnWorkspaceChange } from "@/data/stores/chat-session-picker-store"; /** @@ -107,6 +108,7 @@ export default function WorkspaceLayout() { // changes — a draft picked under workspace A (assignee id, draft // session id, etc.) is invalid in workspace B and must not leak. useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null); + useNewProjectDraftResetOnWorkspaceChange(matched?.id ?? null); useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null); // Wait for the workspaces list before deciding membership — otherwise a @@ -235,6 +237,16 @@ export default function WorkspaceLayout() { name="new-issue-picker/due-date" options={SHEET_OPTIONS} /> + {/* New-project draft formSheet pickers — same pattern as + new-issue-picker/*. Stacked on top of `project/new` (a modal). */} + + {/* Shared filter sheet for My Issues and the workspace Issues page — chooses the right view-store via `?scope=my|all` URL param. */} diff --git a/apps/mobile/app/(app)/[workspace]/new-project-picker/priority.tsx b/apps/mobile/app/(app)/[workspace]/new-project-picker/priority.tsx new file mode 100644 index 000000000..972eb73c0 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/new-project-picker/priority.tsx @@ -0,0 +1,21 @@ +/** + * Priority picker route for the in-progress new-project draft. See ./status.tsx. + */ +import { router } from "expo-router"; +import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body"; +import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store"; + +export default function NewProjectPriorityPickerRoute() { + const priority = useNewProjectDraftStore((s) => s.priority); + const setPriority = useNewProjectDraftStore((s) => s.setPriority); + + return ( + { + setPriority(next); + router.back(); + }} + /> + ); +} diff --git a/apps/mobile/app/(app)/[workspace]/new-project-picker/status.tsx b/apps/mobile/app/(app)/[workspace]/new-project-picker/status.tsx new file mode 100644 index 000000000..4d1e9d32a --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/new-project-picker/status.tsx @@ -0,0 +1,24 @@ +/** + * Status picker route for the in-progress new-project draft. Reads/writes + * `useNewProjectDraftStore` — the project/new.tsx modal owns the draft and + * reads from the same store. See ../project/new.tsx for the lifecycle, and + * ../new-issue-picker/status.tsx for the mirror pattern. + */ +import { router } from "expo-router"; +import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body"; +import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store"; + +export default function NewProjectStatusPickerRoute() { + const status = useNewProjectDraftStore((s) => s.status); + const setStatus = useNewProjectDraftStore((s) => s.setStatus); + + return ( + { + setStatus(next); + router.back(); + }} + /> + ); +} diff --git a/apps/mobile/app/(app)/[workspace]/project/new.tsx b/apps/mobile/app/(app)/[workspace]/project/new.tsx index 01abb24e6..a5d745cb3 100644 --- a/apps/mobile/app/(app)/[workspace]/project/new.tsx +++ b/apps/mobile/app/(app)/[workspace]/project/new.tsx @@ -8,6 +8,10 @@ * project from a "I need to track this stream of work" intent and figure * out who's leading it later. The picker lives on the detail screen. * + * Status / priority cross-route through `useNewProjectDraftStore` so the + * formSheet picker routes can read/write them — same pattern as + * new-issue.tsx + new-issue-picker/* (see new-project-draft-store.ts). + * * On success: dismiss modal → navigate to the new project's detail page so * the user can immediately add a lead / attach issues / configure properties. */ @@ -16,7 +20,6 @@ import { Alert, InteractionManager, KeyboardAvoidingView, - Modal, Platform, Pressable, ScrollView, @@ -24,7 +27,6 @@ import { View, } from "react-native"; import { Stack, router } from "expo-router"; -import type { ProjectPriority, ProjectStatus } from "@multica/core/types"; import { Text } from "@/components/ui/text"; import { AutosizeTextArea } from "@/components/ui/autosize-textarea"; import { @@ -33,15 +35,25 @@ import { } from "@/components/ui/input-tokens"; import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; -import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body"; -import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body"; import { projectPriorityLabel, projectStatusLabel, } from "@/lib/project-status"; import { useCreateProject } from "@/data/mutations/projects"; +import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store"; import { useWorkspaceStore } from "@/data/workspace-store"; +/** + * Typed map of new-project picker route pathnames. Keeps `router.push` calls + * compile-checked rather than depending on free-form template strings — + * same approach as `create-form-attribute-row.tsx`. + */ +type NewProjectPickerField = "status" | "priority"; +const NEW_PROJECT_PICKER_PATHNAMES = { + status: "/[workspace]/new-project-picker/status", + priority: "/[workspace]/new-project-picker/priority", +} as const satisfies Record; + export default function NewProject() { const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); const create = useCreateProject(); @@ -49,11 +61,9 @@ export default function NewProject() { const [title, setTitle] = useState(""); const [icon, setIcon] = useState(""); const [description, setDescription] = useState(""); - const [status, setStatus] = useState("planned"); - const [priority, setPriority] = useState("none"); - - const [statusOpen, setStatusOpen] = useState(false); - const [priorityOpen, setPriorityOpen] = useState(false); + const status = useNewProjectDraftStore((s) => s.status); + const priority = useNewProjectDraftStore((s) => s.priority); + const resetDraft = useNewProjectDraftStore((s) => s.reset); const dirty = title.length > 0 || @@ -64,8 +74,20 @@ export default function NewProject() { const canCreate = title.trim().length > 0 && !create.isPending; + const openPicker = useCallback( + (field: NewProjectPickerField) => { + if (!wsSlug) return; + router.push({ + pathname: NEW_PROJECT_PICKER_PATHNAMES[field], + params: { workspace: wsSlug }, + }); + }, + [wsSlug], + ); + const onCancel = useCallback(() => { if (!dirty) { + resetDraft(); router.back(); return; } @@ -77,11 +99,14 @@ export default function NewProject() { { text: "Discard", style: "destructive", - onPress: () => router.back(), + onPress: () => { + resetDraft(); + router.back(); + }, }, ], ); - }, [dirty]); + }, [dirty, resetDraft]); const onCreate = useCallback(() => { if (!canCreate) return; @@ -95,6 +120,7 @@ export default function NewProject() { }, { onSuccess: (project) => { + resetDraft(); router.back(); // Wait for the modal dismiss animation to finish before pushing // the detail screen. `InteractionManager` resolves once iOS @@ -113,7 +139,17 @@ export default function NewProject() { }, }, ); - }, [canCreate, create, title, description, icon, status, priority, wsSlug]); + }, [ + canCreate, + create, + title, + description, + icon, + status, + priority, + wsSlug, + resetDraft, + ]); const headerLeft = useCallback(() => { return ( @@ -186,7 +222,7 @@ export default function NewProject() { setStatusOpen(true)} + onPress={() => openPicker("status")} className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5" > @@ -199,7 +235,7 @@ export default function NewProject() { setPriorityOpen(true)} + onPress={() => openPicker("priority")} className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5" > @@ -212,70 +248,10 @@ export default function NewProject() { - - setStatusOpen(false)}> - { - setStatus(next); - setStatusOpen(false); - }} - /> - - setPriorityOpen(false)} - > - { - setPriority(next); - setPriorityOpen(false); - }} - /> - ); } -/** - * Inline modal shell for the new-project draft pickers. The host screen is - * already a presentation="modal", and routes can't read draft local state - * (the project doesn't exist yet, nothing in the cache to push the picker - * to). So we keep a lightweight centered card here rather than wire a - * separate draft store for a single transient form — matches the carve-out - * in apps/mobile/CLAUDE.md "modal container selection" for short fixed - * picker lists with no neighbour pickers to be consistent with. - */ -function DraftPickerModal({ - visible, - onClose, - children, -}: { - visible: boolean; - onClose: () => void; - children: React.ReactNode; -}) { - return ( - - - - {}} className="w-full max-w-sm"> - - {children} - - - - - - ); -} - function Field({ label, children, diff --git a/apps/mobile/data/stores/new-project-draft-store.ts b/apps/mobile/data/stores/new-project-draft-store.ts new file mode 100644 index 000000000..13020333f --- /dev/null +++ b/apps/mobile/data/stores/new-project-draft-store.ts @@ -0,0 +1,62 @@ +/** + * Draft state for the New Project modal (`app/(app)/[workspace]/project/new.tsx`). + * + * Mirrors `new-issue-draft-store.ts` — same rationale: the formSheet picker + * routes (`new-project-picker/status.tsx`, `new-project-picker/priority.tsx`) + * live in a separate Stack screen and have no React parent-child relationship + * with the new-project modal. They need a way to read the current draft value + * and write the new selection back without prop-drilling through the router. + * A small Zustand store is the minimum-viable cross-screen channel — and the + * same pattern as new-issue. + * + * Lifecycle: `reset()` runs from `project/new.tsx` when the user dismisses + * the modal (either submit succeeds or they cancel) so the next open starts + * clean. Title / description / icon stay local useState because they're + * controlled inputs that never leave the new-project screen; only the + * attribute-chip values cross routes. + * + * Workspace lifecycle: workspace-scoped — reset is wired in + * `app/(app)/[workspace]/_layout.tsx` via + * `useNewProjectDraftResetOnWorkspaceChange()`, the only caller. + */ +import { useEffect, useRef } from "react"; +import { create } from "zustand"; +import type { ProjectPriority, ProjectStatus } from "@multica/core/types"; + +interface NewProjectDraftState { + status: ProjectStatus; + priority: ProjectPriority; + setStatus: (next: ProjectStatus) => void; + setPriority: (next: ProjectPriority) => void; + reset: () => void; +} + +const INITIAL: Pick = { + status: "planned", + priority: "none", +}; + +export const useNewProjectDraftStore = create((set) => ({ + ...INITIAL, + setStatus: (next) => set({ status: next }), + setPriority: (next) => set({ priority: next }), + reset: () => set({ ...INITIAL }), +})); + +/** + * Clears the new-project draft store whenever the active workspace id + * changes. The previous draft is invalid in a different workspace (we may + * later add fields like `lead` whose id only resolves in the seeded + * workspace). The `useRef` gate ensures the first mount is a no-op — we + * only fire `reset()` when the id actually changes, not on the initial + * render where `prev === current`. + */ +export function useNewProjectDraftResetOnWorkspaceChange(wsId: string | null) { + const prevRef = useRef(wsId); + useEffect(() => { + if (prevRef.current !== wsId) { + useNewProjectDraftStore.getState().reset(); + prevRef.current = wsId; + } + }, [wsId]); +}