From c644e2a33813cdc2fa80e76059c7f4536d575b04 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 20 May 2026 11:15:25 +0800 Subject: [PATCH] feat(mobile): project status + priority pickers via formSheet routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project detail's Status and Priority chips were the last two picker chips still using the legacy centered-Modal pattern. The mixed gesture (Status/Priority popped a centered card; Lead / Add Resource slid up a formSheet) violated the picker-row consistency rule in CLAUDE.md Lesson 6 — the four chips on the same row now all open the same way. - New picker bodies under components/project/pickers/. - New formSheet routes under app/(app)/[workspace]/project/[id]/picker/. - Register both screens in workspace _layout.tsx using SHEET_OPTIONS. - project/[id].tsx: drop the local state, swap chip onPress to router.push, and remove the trailing 'still uses transparent-Modal' apology comment. - project/new.tsx is a draft modal so it can't push to a route (no project exists yet to read from cache). Inline a tiny DraftPickerModal shell that hosts the same picker bodies — documented in the file. - Delete the obsolete ProjectStatusPickerSheet / ProjectPriorityPickerSheet files and update rnr-migration.md to reflect that B.2 is closed. --- apps/mobile/app/(app)/[workspace]/_layout.tsx | 8 ++ .../app/(app)/[workspace]/project/[id].tsx | 58 ++++----------- .../project/[id]/picker/priority.tsx | 28 +++++++ .../project/[id]/picker/status.tsx | 28 +++++++ .../app/(app)/[workspace]/project/new.tsx | 72 +++++++++++++++--- .../pickers/project-priority-picker-body.tsx | 52 +++++++++++++ .../pickers/project-priority-picker-sheet.tsx | 69 ------------------ .../pickers/project-status-picker-body.tsx | 52 +++++++++++++ .../pickers/project-status-picker-sheet.tsx | 73 ------------------- apps/mobile/docs/rnr-migration.md | 23 +++--- 10 files changed, 256 insertions(+), 207 deletions(-) create mode 100644 apps/mobile/app/(app)/[workspace]/project/[id]/picker/priority.tsx create mode 100644 apps/mobile/app/(app)/[workspace]/project/[id]/picker/status.tsx create mode 100644 apps/mobile/components/project/pickers/project-priority-picker-body.tsx delete mode 100644 apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx create mode 100644 apps/mobile/components/project/pickers/project-status-picker-body.tsx delete mode 100644 apps/mobile/components/project/pickers/project-status-picker-sheet.tsx diff --git a/apps/mobile/app/(app)/[workspace]/_layout.tsx b/apps/mobile/app/(app)/[workspace]/_layout.tsx index 38db88375..9362d51d3 100644 --- a/apps/mobile/app/(app)/[workspace]/_layout.tsx +++ b/apps/mobile/app/(app)/[workspace]/_layout.tsx @@ -191,6 +191,14 @@ export default function WorkspaceLayout() { options={SHEET_OPTIONS} /> {/* Project-detail formSheet pickers. */} + + router.back()); @@ -195,8 +178,20 @@ export default function ProjectDetail() { /> setStatusOpen(true)} - onPressPriority={() => setPriorityOpen(true)} + onPressStatus={() => { + if (wsSlug) + router.push({ + pathname: "/[workspace]/project/[id]/picker/status", + params: { workspace: wsSlug, id }, + }); + }} + onPressPriority={() => { + if (wsSlug) + router.push({ + pathname: "/[workspace]/project/[id]/picker/priority", + params: { workspace: wsSlug, id }, + }); + }} onPressLead={() => { if (wsSlug) router.push({ @@ -219,27 +214,6 @@ export default function ProjectDetail() { )} - - {project ? ( - <> - - updateProject.mutate({ status: next }) - } - onClose={() => setStatusOpen(false)} - /> - - updateProject.mutate({ priority: next }) - } - onClose={() => setPriorityOpen(false)} - /> - - ) : null} ); } diff --git a/apps/mobile/app/(app)/[workspace]/project/[id]/picker/priority.tsx b/apps/mobile/app/(app)/[workspace]/project/[id]/picker/priority.tsx new file mode 100644 index 000000000..d39323d73 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/project/[id]/picker/priority.tsx @@ -0,0 +1,28 @@ +/** + * Project priority picker route — presented as a formSheet by the parent + * Stack. Self-contained: reads project from cache, fires useUpdateProject + * on selection, then router.back()s. + */ +import { useLocalSearchParams, router } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body"; +import { projectDetailOptions } from "@/data/queries/projects"; +import { useUpdateProject } from "@/data/mutations/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +export default function ProjectPriorityPickerRoute() { + const { id } = useLocalSearchParams<{ id: string }>(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { data: project } = useQuery(projectDetailOptions(wsId, id)); + const updateProject = useUpdateProject(id); + + return ( + { + updateProject.mutate({ priority: next }); + router.back(); + }} + /> + ); +} diff --git a/apps/mobile/app/(app)/[workspace]/project/[id]/picker/status.tsx b/apps/mobile/app/(app)/[workspace]/project/[id]/picker/status.tsx new file mode 100644 index 000000000..ec6eaeed8 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/project/[id]/picker/status.tsx @@ -0,0 +1,28 @@ +/** + * Project status picker route — presented as a formSheet by the parent + * Stack. Self-contained: reads project from cache, fires useUpdateProject + * on selection, then router.back()s. + */ +import { useLocalSearchParams, router } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body"; +import { projectDetailOptions } from "@/data/queries/projects"; +import { useUpdateProject } from "@/data/mutations/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +export default function ProjectStatusPickerRoute() { + const { id } = useLocalSearchParams<{ id: string }>(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { data: project } = useQuery(projectDetailOptions(wsId, id)); + const updateProject = useUpdateProject(id); + + return ( + { + updateProject.mutate({ status: next }); + router.back(); + }} + /> + ); +} diff --git a/apps/mobile/app/(app)/[workspace]/project/new.tsx b/apps/mobile/app/(app)/[workspace]/project/new.tsx index 70978460b..01abb24e6 100644 --- a/apps/mobile/app/(app)/[workspace]/project/new.tsx +++ b/apps/mobile/app/(app)/[workspace]/project/new.tsx @@ -16,6 +16,7 @@ import { Alert, InteractionManager, KeyboardAvoidingView, + Modal, Platform, Pressable, ScrollView, @@ -32,8 +33,8 @@ import { } from "@/components/ui/input-tokens"; import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; -import { ProjectStatusPickerSheet } from "@/components/project/pickers/project-status-picker-sheet"; -import { ProjectPriorityPickerSheet } from "@/components/project/pickers/project-priority-picker-sheet"; +import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body"; +import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body"; import { projectPriorityLabel, projectStatusLabel, @@ -212,22 +213,69 @@ export default function NewProject() { - setStatusOpen(false)} - /> - 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/components/project/pickers/project-priority-picker-body.tsx b/apps/mobile/components/project/pickers/project-priority-picker-body.tsx new file mode 100644 index 000000000..d816fbcbb --- /dev/null +++ b/apps/mobile/components/project/pickers/project-priority-picker-body.tsx @@ -0,0 +1,52 @@ +/** + * Pure picker body for project priority — single-select over the 5 + * ProjectPriority enum values. See issue/pickers/status-picker-body.tsx for + * the "extract body, route owns shell" rationale. + */ +import { Pressable, ScrollView, View } from "react-native"; +import type { ProjectPriority } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; +import { + PROJECT_PRIORITIES, + PROJECT_PRIORITY_LABEL, +} from "@/lib/project-status"; +import { cn } from "@/lib/utils"; + +interface Props { + value: ProjectPriority | string; + onChange: (next: ProjectPriority) => void; +} + +export function ProjectPriorityPickerBody({ value, onChange }: Props) { + return ( + + + Priority + + + {PROJECT_PRIORITIES.map((priority) => { + const selected = priority === value; + return ( + onChange(priority)} + className={cn( + "flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-secondary", + selected && "bg-secondary", + )} + > + + + {PROJECT_PRIORITY_LABEL[priority]} + + {selected ? ( + + ) : null} + + ); + })} + + + ); +} diff --git a/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx deleted file mode 100644 index 1254a8c3b..000000000 --- a/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Project priority picker. Single-select over 5 ProjectPriority enum values. - * Shell mirrors project-status-picker-sheet.tsx. - */ -import { Modal, Pressable, View } from "react-native"; -import type { ProjectPriority } from "@multica/core/types"; -import { Text } from "@/components/ui/text"; -import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; -import { - PROJECT_PRIORITIES, - PROJECT_PRIORITY_LABEL, -} from "@/lib/project-status"; -import { cn } from "@/lib/utils"; - -interface Props { - visible: boolean; - value: ProjectPriority | string; - onChange: (next: ProjectPriority) => void; - onClose: () => void; -} - -export function ProjectPriorityPickerSheet({ - visible, - value, - onChange, - onClose, -}: Props) { - return ( - - - - {}} className="w-full max-w-sm"> - - {PROJECT_PRIORITIES.map((priority) => { - const selected = priority === value; - return ( - { - onChange(priority); - onClose(); - }} - className={cn( - "flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary", - selected && "bg-secondary", - )} - > - - - {PROJECT_PRIORITY_LABEL[priority]} - - {selected ? ( - - ) : null} - - ); - })} - - - - - - ); -} diff --git a/apps/mobile/components/project/pickers/project-status-picker-body.tsx b/apps/mobile/components/project/pickers/project-status-picker-body.tsx new file mode 100644 index 000000000..1ffa65c46 --- /dev/null +++ b/apps/mobile/components/project/pickers/project-status-picker-body.tsx @@ -0,0 +1,52 @@ +/** + * Pure picker body for project status — single-select over the 5 + * ProjectStatus enum values. See issue/pickers/status-picker-body.tsx for + * the "extract body, route owns shell" rationale. + */ +import { Pressable, ScrollView, View } from "react-native"; +import type { ProjectStatus } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; +import { + PROJECT_STATUSES, + PROJECT_STATUS_LABEL, +} from "@/lib/project-status"; +import { cn } from "@/lib/utils"; + +interface Props { + value: ProjectStatus | string; + onChange: (next: ProjectStatus) => void; +} + +export function ProjectStatusPickerBody({ value, onChange }: Props) { + return ( + + + Status + + + {PROJECT_STATUSES.map((status) => { + const selected = status === value; + return ( + onChange(status)} + className={cn( + "flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-secondary", + selected && "bg-secondary", + )} + > + + + {PROJECT_STATUS_LABEL[status]} + + {selected ? ( + + ) : null} + + ); + })} + + + ); +} diff --git a/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx deleted file mode 100644 index 795794881..000000000 --- a/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Project status picker. Single-select over the 5 ProjectStatus enum values. - * Tap-to-apply (no confirm step); sheet auto-closes on selection. - * - * Modal shell mirrors issue/pickers/status-picker-sheet.tsx — same fade-in - * centered popover, same tap-outside-to-dismiss behavior, same selected-row - * styling. - */ -import { Modal, Pressable, View } from "react-native"; -import type { ProjectStatus } from "@multica/core/types"; -import { Text } from "@/components/ui/text"; -import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; -import { - PROJECT_STATUSES, - PROJECT_STATUS_LABEL, -} from "@/lib/project-status"; -import { cn } from "@/lib/utils"; - -interface Props { - visible: boolean; - value: ProjectStatus | string; - onChange: (next: ProjectStatus) => void; - onClose: () => void; -} - -export function ProjectStatusPickerSheet({ - visible, - value, - onChange, - onClose, -}: Props) { - return ( - - - - {}} className="w-full max-w-sm"> - - {PROJECT_STATUSES.map((status) => { - const selected = status === value; - return ( - { - onChange(status); - onClose(); - }} - className={cn( - "flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary", - selected && "bg-secondary", - )} - > - - - {PROJECT_STATUS_LABEL[status]} - - {selected ? ( - - ) : null} - - ); - })} - - - - - - ); -} diff --git a/apps/mobile/docs/rnr-migration.md b/apps/mobile/docs/rnr-migration.md index 721aa4e7b..04ba00b05 100644 --- a/apps/mobile/docs/rnr-migration.md +++ b/apps/mobile/docs/rnr-migration.md @@ -105,18 +105,19 @@ This tier is where the biggest payoff lives (Lesson 6 in CLAUDE.md catalogues th | `components/issue/comment-action-sheet.tsx` | `ActionSheetIOS.showActionSheetWithOptions` | One-of-N action menu — exactly what ActionSheetIOS is for. Recommended Phase 3 starter (visible deletion, no styling questions). | | `components/issue/pickers/due-date-picker-sheet.tsx` | `@react-native-community/datetimepicker` inline picker | Date selection — native API already installed | -**B.2 — replaced by RNR `Select` (small file, mostly callsite changes)** +**B.2 — replaced by formSheet routes (done)** -| Current sheet | Replacement | -|---|---| -| `components/issue/pickers/status-picker-sheet.tsx` | RNR `Select` | -| `components/issue/pickers/priority-picker-sheet.tsx` | `ActionSheetIOS` (options fixed and few) or RNR `Select` | -| `components/issue/pickers/assignee-picker-sheet.tsx` | RNR `Select` (searchable) | -| `components/issue/pickers/label-picker-sheet.tsx` | RNR `Select` (multi) | -| `components/issue/pickers/project-picker-sheet.tsx` | RNR `Select` (searchable) | -| `components/project/pickers/project-status-picker-sheet.tsx` | RNR `Select` | -| `components/project/pickers/project-priority-picker-sheet.tsx` | `ActionSheetIOS` or RNR `Select` | -| `components/project/pickers/project-lead-picker-sheet.tsx` | RNR `Select` (searchable) | +The original plan was to swap each picker-sheet for an RNR `Select`. The +mobile-sheet-rollout PR series instead converged on a different shape: +every former picker-sheet now ships as a pure `` component +under `components//pickers/`, embedded inside an Expo Router +formSheet route at `app/(app)/[workspace]//picker/.tsx`. +This gives the iOS UISheetPresentationController-native chrome +(grabber + detents + spring drag-dismiss) without the per-callsite +state and visibility prop dance an RNR `Select` would still require. + +Files in this row are all deleted; their bodies + routes live under the +paths above. No follow-up needed. **B.3 — genuinely needs a custom-content sheet (RNR `Dialog` pageSheet)**