feat(mobile): project status + priority pickers via formSheet routes

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.
This commit is contained in:
Naiyuan Qing
2026-05-20 11:15:25 +08:00
parent 414c3b74a9
commit c644e2a338
10 changed files with 256 additions and 207 deletions

View File

@@ -191,6 +191,14 @@ export default function WorkspaceLayout() {
options={SHEET_OPTIONS}
/>
{/* Project-detail formSheet pickers. */}
<Stack.Screen
name="project/[id]/picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/lead"
options={SHEET_OPTIONS}

View File

@@ -13,7 +13,7 @@
* confirmation via `Alert.alert` per iOS HIG (destructive actions need
* a second tap).
*/
import { useCallback, useState } from "react";
import { useCallback } from "react";
import {
ActionSheetIOS,
ActivityIndicator,
@@ -29,27 +29,18 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Ionicons } from "@expo/vector-icons";
import type {
ProjectPriority,
ProjectStatus,
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { ProjectHeaderCard } from "@/components/project/project-header-card";
import { ProjectPropertiesSection } from "@/components/project/project-properties-section";
import { ProjectRelatedIssues } from "@/components/project/project-related-issues";
import { ProjectResourcesSection } from "@/components/project/project-resources-section";
import { ProjectStatusPickerSheet } from "@/components/project/pickers/project-status-picker-sheet";
import { ProjectPriorityPickerSheet } from "@/components/project/pickers/project-priority-picker-sheet";
import {
projectDetailOptions,
projectResourcesOptions,
} from "@/data/queries/projects";
import { issueKeys } from "@/data/queries/issue-keys";
import {
useDeleteProject,
useUpdateProject,
} from "@/data/mutations/projects";
import { useDeleteProject } from "@/data/mutations/projects";
import { useProjectRealtime } from "@/data/realtime/use-project-realtime";
import { useWorkspaceStore } from "@/data/workspace-store";
@@ -60,16 +51,8 @@ export default function ProjectDetail() {
const qc = useQueryClient();
const detail = useQuery(projectDetailOptions(wsId, id));
const updateProject = useUpdateProject(id);
const deleteProject = useDeleteProject(id);
// Status + Priority pickers still use the older transparent-Modal
// pattern (project-status-picker-sheet / project-priority-picker-sheet) —
// not part of the SheetShell formSheet migration. Lead + Add Resource
// moved to formSheet routes.
const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
// Per-record realtime — when another client deletes the project we're
// viewing, pop back so the user isn't stranded on a 404.
useProjectRealtime(id, () => router.back());
@@ -195,8 +178,20 @@ export default function ProjectDetail() {
/>
<ProjectPropertiesSection
project={project}
onPressStatus={() => 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() {
<ProjectRelatedIssues projectId={id} />
</ScrollView>
)}
{project ? (
<>
<ProjectStatusPickerSheet
visible={statusOpen}
value={project.status}
onChange={(next: ProjectStatus) =>
updateProject.mutate({ status: next })
}
onClose={() => setStatusOpen(false)}
/>
<ProjectPriorityPickerSheet
visible={priorityOpen}
value={project.priority}
onChange={(next: ProjectPriority) =>
updateProject.mutate({ priority: next })
}
onClose={() => setPriorityOpen(false)}
/>
</>
) : null}
</SafeAreaView>
);
}

View File

@@ -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 (
<ProjectPriorityPickerBody
value={project?.priority ?? "none"}
onChange={(next) => {
updateProject.mutate({ priority: next });
router.back();
}}
/>
);
}

View File

@@ -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 (
<ProjectStatusPickerBody
value={project?.status ?? "planned"}
onChange={(next) => {
updateProject.mutate({ status: next });
router.back();
}}
/>
);
}

View File

@@ -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() {
</ScrollView>
</KeyboardAvoidingView>
<ProjectStatusPickerSheet
visible={statusOpen}
value={status}
onChange={setStatus}
onClose={() => setStatusOpen(false)}
/>
<ProjectPriorityPickerSheet
<DraftPickerModal visible={statusOpen} onClose={() => setStatusOpen(false)}>
<ProjectStatusPickerBody
value={status}
onChange={(next) => {
setStatus(next);
setStatusOpen(false);
}}
/>
</DraftPickerModal>
<DraftPickerModal
visible={priorityOpen}
value={priority}
onChange={setPriority}
onClose={() => setPriorityOpen(false)}
/>
>
<ProjectPriorityPickerBody
value={priority}
onChange={(next) => {
setPriority(next);
setPriorityOpen(false);
}}
/>
</DraftPickerModal>
</>
);
}
/**
* 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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-8">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl overflow-hidden">
{children}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}
function Field({
label,
children,

View File

@@ -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 (
<ScrollView showsVerticalScrollIndicator={false}>
<View className="px-4 pt-3 pb-2">
<Text className="text-lg font-semibold text-foreground">Priority</Text>
</View>
<View className="px-2">
{PROJECT_PRIORITIES.map((priority) => {
const selected = priority === value;
return (
<Pressable
key={priority}
onPress={() => onChange(priority)}
className={cn(
"flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-secondary",
selected && "bg-secondary",
)}
>
<ProjectPriorityIcon priority={priority} size={18} />
<Text className="flex-1 text-base text-foreground">
{PROJECT_PRIORITY_LABEL[priority]}
</Text>
{selected ? (
<Text className="text-sm text-muted-foreground"></Text>
) : null}
</Pressable>
);
})}
</View>
</ScrollView>
);
}

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-8">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl p-2">
{PROJECT_PRIORITIES.map((priority) => {
const selected = priority === value;
return (
<Pressable
key={priority}
onPress={() => {
onChange(priority);
onClose();
}}
className={cn(
"flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary",
selected && "bg-secondary",
)}
>
<ProjectPriorityIcon priority={priority} size={18} />
<Text className="flex-1 text-sm text-foreground">
{PROJECT_PRIORITY_LABEL[priority]}
</Text>
{selected ? (
<Text className="text-xs text-muted-foreground"></Text>
) : null}
</Pressable>
);
})}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -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 (
<ScrollView showsVerticalScrollIndicator={false}>
<View className="px-4 pt-3 pb-2">
<Text className="text-lg font-semibold text-foreground">Status</Text>
</View>
<View className="px-2">
{PROJECT_STATUSES.map((status) => {
const selected = status === value;
return (
<Pressable
key={status}
onPress={() => onChange(status)}
className={cn(
"flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-secondary",
selected && "bg-secondary",
)}
>
<ProjectStatusIcon status={status} size={18} />
<Text className="flex-1 text-base text-foreground">
{PROJECT_STATUS_LABEL[status]}
</Text>
{selected ? (
<Text className="text-sm text-muted-foreground"></Text>
) : null}
</Pressable>
);
})}
</View>
</ScrollView>
);
}

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-8">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl p-2">
{PROJECT_STATUSES.map((status) => {
const selected = status === value;
return (
<Pressable
key={status}
onPress={() => {
onChange(status);
onClose();
}}
className={cn(
"flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary",
selected && "bg-secondary",
)}
>
<ProjectStatusIcon status={status} size={18} />
<Text className="flex-1 text-sm text-foreground">
{PROJECT_STATUS_LABEL[status]}
</Text>
{selected ? (
<Text className="text-xs text-muted-foreground"></Text>
) : null}
</Pressable>
);
})}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -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 `<XxxPickerBody>` component
under `components/<domain>/pickers/`, embedded inside an Expo Router
formSheet route at `app/(app)/[workspace]/<context>/picker/<field>.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)**