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)**