feat(mobile): v1 shell — auth, workspace switching, inbox + my-issues

- Auth: email OTP login mirroring packages/core/auth/store.ts behavior
  (401 clears token, non-401 preserves; token written only on verify
  success); expo-secure-store with key "multica_token" matching desktop
- Workspace context: /[workspace]/ URL slug as source of truth (deep-
  link friendly), ApiClient auto-injects X-Workspace-Slug, SecureStore
  persists last-selected slug for cold-start restore
- Bottom tabs (Ionicons): Inbox / My Issues / Settings
- Inbox: actor avatar, unread brand-dot, status icon, time-ago + body
  subtitle. getInboxDisplayTitle mirrored from packages/views/inbox/
  components/inbox-display.ts
- My Issues: priority bars (matching IssuePriority bar counts from
  packages/core/issues/config/priority.ts), status dot, identifier,
  title, assignee avatar
- Settings: account info + workspace switcher; switching replaces nav
  to /[newSlug]/inbox so back stack doesn't trail to old workspace
- Multi-env: .env.staging / .env.production / .env.development.local
  with EXPO_PUBLIC_API_URL; APP_ENV in app.config.ts swaps
  bundleIdentifier so dev/staging/prod coexist on a device
- Build: dev:mobile + dev:mobile:staging scripts; main turbo
  build/typecheck/lint/test filter excludes @multica/mobile

Tech-stack (locked in apps/mobile/CLAUDE.md):
- Expo SDK 55, RN 0.83.6, React 19.2.0 (pinned, NOT catalog)
- NativeWind 4 + Tailwind 3.4 (intentional mismatch w/ web's Tailwind 4;
  visual tokens transcribed by hand from packages/ui/styles/tokens.css)
- TanStack Query 5 with AppState focus listener; Zustand 5

Not in this commit (intentional): issue detail page, mark-read mutation,
pull-to-refresh polish — next iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-09 13:14:38 +08:00
parent 518d342021
commit def9c08d35
35 changed files with 7000 additions and 59 deletions

20
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
node_modules/
.expo/
dist/
web-build/
# macOS
.DS_Store
# Local env (committed: .env.staging, .env.production)
.env*.local
# Native (Expo prebuild output)
ios/
android/
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

View File

@@ -9,15 +9,36 @@ For cross-app sharing rules, see the root `CLAUDE.md` *Sharing Principles* secti
Everything else, mobile writes its own.
## Tech-stack baseline (locked)
## Behavioral parity with web/desktop
Mobile is allowed to differ in **UI and interaction** — it's a phone, not a port. It is NOT allowed to differ in **product semantics**. Users should not get a different mental model of "what's there" depending on which client they open.
Concrete rules:
- **Counts and visibility must agree.** If web shows the user N comments on an issue under a given filter, mobile must show the same N (subject to identical pagination/coalescing rules). If mobile silently re-implements timeline grouping with different coalescing windows, mobile is wrong.
- **Permissions and access checks must agree.** "Can comment", "can change status", "can archive inbox item" — mobile decides via the same logic web does (mirrored from packages/core, not re-derived from feel).
- **State enums and transitions must agree.** Issue status set, priority set, inbox item types, comment types — mobile renders all of them (with a sensible fallback for unknown values, per "API Response Compatibility" in the root CLAUDE.md). Mobile does NOT silently drop categories.
- **Data identity must agree.** Same `id`, same `slug`, same canonical fields. Mobile does not invent its own ids or normalize differently.
**Concrete UX divergence is fine** when it preserves semantics:
- ✅ Web shows comment thread as a recursive tree; mobile shows a flat list (because phone screens). Same comments, different layout.
- ✅ Web has a sidebar workspace switcher; mobile puts it in Settings. Same switching semantics.
- ✅ Web shows inbox item read-state with a filled background; mobile uses a leading dot. Same boolean.
- ❌ Web counts both replies and parent comments in the comment count; mobile counts only top-level. **Not allowed** — same N rule.
- ❌ Web treats `status="cancelled"` as visible; mobile silently hides it. **Not allowed** — same enums rule.
When UI requires a divergence, write down at the divergence point what the rule is mirroring (point at the source function in packages/core or packages/views) and why mobile renders it differently. Future readers should be able to tell, in 30 seconds, that the mobile divergence is intentional and which web-side function is the source of truth.
## Tech-stack baseline
Start minimal. Add to this list when actually adopted — do NOT pre-list libraries.
- **Expo SDK 54**
- **React Native 0.81**
- **React 19.x** — whatever Expo SDK 54 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`.
- **Expo SDK 55**
- **React Native 0.82**
- **React 19.1** — whatever Expo SDK 55 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`.
- **TypeScript** strict
- **Expo Router 6** file-based routing
- **Expo Router 55** (file-based routing — version aligns with Expo SDK)
- **NativeWind 4** + **Tailwind 3.4** — NativeWind 5 is unstable and doesn't support Expo Go; stay on v4. (Note: web/desktop use Tailwind v4 — versions intentionally differ.)
- **react-native-reusables (RNR)** — the shadcn equivalent for React Native. Uses NativeWind + RN-Primitives + CVA. Component API mirrors shadcn.
- **TanStack Query 5** — mobile owns its `QueryClient` with `AppState` focus listener + `NetInfo` online listener.

40
apps/mobile/app.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { ExpoConfig, ConfigContext } from "expo/config";
/**
* Dynamic Expo config — replaces app.json so we can read APP_ENV at runtime
* and switch bundleIdentifier / display name for dev / staging / production.
*
* APP_ENV is set by package.json scripts:
* - dev → APP_ENV unset (treated as "development")
* - dev:staging → APP_ENV=staging
* - dev:prod → APP_ENV=production (rare; usually only for EAS build)
*/
export default ({ config }: ConfigContext): ExpoConfig => {
const env = process.env.APP_ENV ?? "development";
const isProd = env === "production";
const isStaging = env === "staging";
return {
...config,
name: isProd
? "Multica"
: isStaging
? "Multica (Staging)"
: "Multica (Dev)",
slug: "multica-mobile",
version: "0.1.0",
orientation: "portrait",
userInterfaceStyle: "automatic",
scheme: "multica",
ios: {
supportsTablet: false,
bundleIdentifier: isProd
? "ai.multica.mobile"
: isStaging
? "ai.multica.mobile.staging"
: "ai.multica.mobile.dev",
},
plugins: ["expo-router", "expo-secure-store"],
extra: { APP_ENV: env },
};
};

View File

@@ -0,0 +1,58 @@
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
const ACTIVE = "#2e2e33"; // matches tailwind.config.js primary
const INACTIVE = "#71717a"; // matches muted-foreground
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: ACTIVE,
tabBarInactiveTintColor: INACTIVE,
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="inbox"
options={{
title: "Inbox",
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? "mail" : "mail-outline"}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="my-issues"
options={{
title: "My Issues",
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? "list" : "list-outline"}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? "settings" : "settings-outline"}
size={size}
color={color}
/>
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,122 @@
import { ActivityIndicator, FlatList, Pressable, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { ScreenHeader } from "@/components/ui/screen-header";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { StatusIcon } from "@/components/ui/status-icon";
import { inboxListOptions } from "@/data/queries/inbox";
import { useWorkspaceStore } from "@/data/workspace-store";
import { getInboxDisplayTitle } from "@/lib/inbox-display";
import { timeAgo } from "@/lib/time-ago";
import { cn } from "@/lib/utils";
export default function Inbox() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { data, isLoading, error, refetch, isRefetching } = useQuery(
inboxListOptions(wsId),
);
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<ScreenHeader title="Inbox" subtitle={wsSlug ?? undefined} />
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="px-4 gap-3">
<Text className="text-sm text-destructive">
Failed to load inbox:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
Retry
</Button>
</View>
) : !data || data.length === 0 ? (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground">
No inbox items.
</Text>
</View>
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-[60px]" />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => <InboxRow item={item} />}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</SafeAreaView>
);
}
function InboxRow({ item }: { item: InboxItem }) {
const isUnread = !item.read;
const displayTitle = getInboxDisplayTitle(item);
const actorType = item.actor_type ?? item.recipient_type;
const actorId = item.actor_id ?? item.recipient_id;
return (
<Pressable className="active:bg-secondary px-4 py-3">
<View className="flex-row gap-3">
<ActorAvatar type={actorType} id={actorId} size={36} />
<View className="flex-1 min-w-0">
{/* Top row: unread dot + title + status icon + time */}
<View className="flex-row items-center gap-1.5">
{isUnread ? (
<View className="size-1.5 rounded-full bg-brand shrink-0" />
) : null}
<Text
className={cn(
"flex-1 text-sm",
isUnread
? "font-medium text-foreground"
: "text-muted-foreground",
)}
numberOfLines={1}
>
{displayTitle}
</Text>
{item.issue_status ? (
<StatusIcon status={item.issue_status} />
) : null}
<Text
className={cn(
"text-xs shrink-0",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/60",
)}
>
{timeAgo(item.created_at)}
</Text>
</View>
{/* Bottom row: body */}
{item.body ? (
<Text
className={cn(
"text-xs mt-0.5",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/60",
)}
numberOfLines={1}
>
{item.body}
</Text>
) : null}
</View>
</View>
</Pressable>
);
}

View File

@@ -0,0 +1,85 @@
import { ActivityIndicator, FlatList, Pressable, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import type { Issue } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { ScreenHeader } from "@/components/ui/screen-header";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { StatusIcon } from "@/components/ui/status-icon";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { myIssuesAssignedOptions } from "@/data/queries/my-issues";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function MyIssues() {
const userId = useAuthStore((s) => s.user?.id ?? null);
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { data, isLoading, error, refetch, isRefetching } = useQuery(
myIssuesAssignedOptions(wsId, userId),
);
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<ScreenHeader title="My Issues" subtitle={wsSlug ?? undefined} />
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="px-4 gap-3">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
Retry
</Button>
</View>
) : !data || data.length === 0 ? (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground">
No issues assigned to you.
</Text>
</View>
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => <IssueRow issue={item} />}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</SafeAreaView>
);
}
function IssueRow({ issue }: { issue: Issue }) {
return (
<Pressable className="active:bg-secondary px-4 py-3">
<View className="flex-row items-center gap-3">
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<Text className="text-xs text-muted-foreground shrink-0 w-16">
{issue.identifier}
</Text>
<Text className="flex-1 text-sm text-foreground" numberOfLines={1}>
{issue.title}
</Text>
{issue.assignee_type && issue.assignee_id ? (
<ActorAvatar
type={issue.assignee_type}
id={issue.assignee_id}
size={20}
/>
) : null}
</View>
</Pressable>
);
}

View File

@@ -0,0 +1,107 @@
import { ActivityIndicator, ScrollView, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import type { Workspace } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { CardPressable } from "@/components/ui/card";
import { ScreenHeader } from "@/components/ui/screen-header";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function Settings() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const currentSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const clearWorkspace = useWorkspaceStore((s) => s.clear);
const { data, isLoading, error } = useQuery(workspaceListOptions());
const onSwitch = async (ws: Workspace) => {
if (ws.slug === currentSlug) return;
await setCurrentWorkspace(ws.id, ws.slug);
// Replace (not push) so the back stack doesn't trail to the old workspace.
router.replace(`/${ws.slug}/inbox`);
};
const onSignOut = async () => {
await clearWorkspace();
await logout();
};
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<ScreenHeader title="Settings" />
<ScrollView contentContainerClassName="px-4 pb-6 gap-6">
{/* Account */}
<View className="gap-2">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
Account
</Text>
<View className="rounded-md border border-border bg-card p-4">
<Text className="text-base font-medium text-foreground">
{user?.name ?? "—"}
</Text>
<Text className="text-sm text-muted-foreground mt-1">
{user?.email}
</Text>
</View>
</View>
{/* Workspaces */}
<View className="gap-2">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
Workspaces
</Text>
{isLoading ? (
<View className="py-4 items-center">
<ActivityIndicator />
</View>
) : error ? (
<Text className="text-sm text-destructive">
Failed to load workspaces
</Text>
) : (
<View className="gap-2">
{data?.map((ws) => {
const isActive = ws.slug === currentSlug;
return (
<CardPressable
key={ws.id}
onPress={() => onSwitch(ws)}
disabled={isActive}
>
<View className="flex-row items-center justify-between">
<View className="flex-1 pr-2">
<Text className="text-base font-medium text-foreground">
{ws.name}
</Text>
<Text className="text-xs text-muted-foreground mt-0.5">
/{ws.slug}
</Text>
</View>
{isActive ? (
<Text className="text-xs text-muted-foreground">
Active
</Text>
) : null}
</View>
</CardPressable>
);
})}
</View>
)}
</View>
{/* Sign out */}
<View className="pt-4 border-t border-border">
<Button variant="outline" onPress={onSignOut}>
Sign out
</Button>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,38 @@
import { useEffect } from "react";
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useWorkspaceStore } from "@/data/workspace-store";
/**
* Workspace context layout. Reads the slug from the URL (the route is the
* source of truth — see apps/mobile/CLAUDE.md "Behavioral parity"), validates
* membership against the workspaces list, then syncs id+slug into the
* Zustand store so ApiClient.fetch can read the slug synchronously when
* injecting the X-Workspace-Slug header.
*
* If the slug doesn't match any workspace the user belongs to, redirect to
* /select-workspace (covers stale persisted slugs after the user lost
* membership, deep links to wrong slugs, etc.).
*/
export default function WorkspaceLayout() {
const { workspace: slug } = useLocalSearchParams<{ workspace: string }>();
const { data: workspaces, isLoading } = useQuery(workspaceListOptions());
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const matched = workspaces?.find((w) => w.slug === slug);
useEffect(() => {
if (matched) {
setCurrentWorkspace(matched.id, matched.slug);
}
}, [matched, setCurrentWorkspace]);
// Wait for the workspaces list before deciding membership — otherwise a
// valid deep link would briefly redirect away on cold start.
if (isLoading) return null;
if (!matched) return <Redirect href="/select-workspace" />;
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,15 @@
import { Stack, Redirect } from "expo-router";
import { useAuthStore } from "@/data/auth-store";
/**
* Auth-required layout. Redirects to /login when no user is loaded.
*
* Workspace membership is enforced one level deeper at [workspace]/_layout —
* not here — because select-workspace.tsx itself is auth-required but
* workspace-less.
*/
export default function AppLayout() {
const user = useAuthStore((s) => s.user);
if (!user) return <Redirect href="/login" />;
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,89 @@
import { ActivityIndicator, ScrollView, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { CardPressable } from "@/components/ui/card";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function SelectWorkspace() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const { data, isLoading, error, refetch } = useQuery(workspaceListOptions());
const onSelect = async (id: string, slug: string) => {
await setCurrentWorkspace(id, slug);
router.replace(`/${slug}/inbox`);
};
return (
<SafeAreaView className="flex-1 bg-background">
<ScrollView contentContainerClassName="px-6 py-6 gap-6">
<View className="gap-1">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
Signed in as
</Text>
<Text className="text-base text-foreground">{user?.email}</Text>
</View>
<View className="gap-3">
<Text className="text-2xl font-semibold text-foreground">
Select a workspace
</Text>
{isLoading ? (
<View className="py-8 items-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="gap-3">
<Text className="text-sm text-destructive">
Failed to load workspaces:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
Retry
</Button>
</View>
) : !data || data.length === 0 ? (
<Text className="text-sm text-muted-foreground">
You don't belong to any workspaces yet. Contact your workspace
admin to be invited.
</Text>
) : (
<View className="gap-3">
{data.map((ws) => (
<CardPressable
key={ws.id}
onPress={() => onSelect(ws.id, ws.slug)}
>
<Text className="text-base font-semibold text-foreground">
{ws.name}
</Text>
<Text className="text-xs text-muted-foreground mt-1">
/{ws.slug}
</Text>
{ws.description ? (
<Text className="text-sm text-muted-foreground mt-2">
{ws.description}
</Text>
) : null}
</CardPressable>
))}
</View>
)}
</View>
<View className="pt-4 border-t border-border">
<Button variant="outline" onPress={() => logout()}>
Sign out
</Button>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,74 @@
import { useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useAuthStore } from "@/data/auth-store";
export default function Login() {
const sendCode = useAuthStore((s) => s.sendCode);
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async () => {
const trimmed = email.trim();
if (!trimmed) return;
setSubmitting(true);
setError(null);
try {
await sendCode(trimmed);
router.push({ pathname: "/verify", params: { email: trimmed } });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send code");
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View className="flex-1 justify-center px-6 gap-6">
<View className="gap-2">
<Text className="text-3xl font-bold text-foreground">
Sign in to Multica
</Text>
<Text className="text-base text-muted-foreground">
Enter your email and we'll send you a verification code.
</Text>
</View>
<View className="gap-3">
<Input
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
onSubmitEditing={onSubmit}
returnKeyType="send"
editable={!submitting}
/>
{error ? (
<Text className="text-sm text-destructive">{error}</Text>
) : null}
</View>
<Button
disabled={submitting || !email.trim()}
onPress={onSubmit}
>
{submitting ? "Sending..." : "Send code"}
</Button>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useAuthStore } from "@/data/auth-store";
export default function Verify() {
const verifyCode = useAuthStore((s) => s.verifyCode);
const { email = "" } = useLocalSearchParams<{ email?: string }>();
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async () => {
const trimmed = code.trim();
if (!trimmed || !email) return;
setSubmitting(true);
setError(null);
try {
await verifyCode(email, trimmed);
// Successful verify: route to the entry redirect, which decides where
// to go based on auth + persisted workspace slug.
router.replace("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid code");
setSubmitting(false);
}
};
return (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View className="flex-1 justify-center px-6 gap-6">
<View className="gap-2">
<Text className="text-3xl font-bold text-foreground">
Enter verification code
</Text>
<Text className="text-base text-muted-foreground">
We sent a code to {email}
</Text>
</View>
<View className="gap-3">
<Input
autoCapitalize="none"
keyboardType="number-pad"
placeholder="6-digit code"
value={code}
onChangeText={setCode}
onSubmitEditing={onSubmit}
returnKeyType="go"
editable={!submitting}
maxLength={8}
/>
{error ? (
<Text className="text-sm text-destructive">{error}</Text>
) : null}
</View>
<View className="gap-3">
<Button
disabled={submitting || !code.trim()}
onPress={onSubmit}
>
{submitting ? "Verifying..." : "Verify"}
</Button>
<Button
variant="outline"
disabled={submitting}
onPress={() => router.back()}
>
Back
</Button>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,37 @@
import "../global.css";
import { useEffect } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/data/query-client";
import { useAuthStore } from "@/data/auth-store";
function AuthInitializer({ children }: { children: React.ReactNode }) {
const initialize = useAuthStore((s) => s.initialize);
useEffect(() => {
initialize();
}, [initialize]);
return <>{children}</>;
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<AuthInitializer>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
</Stack>
</AuthInitializer>
</QueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

30
apps/mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { ActivityIndicator, View } from "react-native";
import { Redirect } from "expo-router";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
/**
* Entry redirect. AuthInitializer (in _layout.tsx) finishes auth + slug
* hydration before this renders meaningfully — until then, isLoading is true.
*
* no user → /login
* user, no slug → /select-workspace
* user, slug → /[slug]/inbox
*/
export default function Index() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-background">
<ActivityIndicator />
</View>
);
}
if (!user) return <Redirect href="/login" />;
if (!slug) return <Redirect href="/select-workspace" />;
return <Redirect href={`/${slug}/inbox`} />;
}

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,58 @@
/**
* Mobile ActorAvatar. Mirrors the role of packages/views/common/actor-avatar.tsx
* (member/agent → avatar URL or initials chip), but stripped down for phone
* use: no hover card, no presence dot, no nested focus management.
*
* Behavioral parity rules (apps/mobile/CLAUDE.md):
* - Same actor type → same name → same initials. Lookup is shared via
* useActorLookup which reads the same MemberWithUser / Agent lists.
* - Agents get distinct visual treatment (brand-tinted background) to
* match web's "agents render with distinct styling" rule from the
* repo-root CLAUDE.md "Agent Assignees" section.
*/
import { Image, View } from "react-native";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
import { useActorLookup, getInitials } from "@/data/use-actor-name";
interface Props {
type: "member" | "agent" | null | undefined;
id: string | null | undefined;
size?: number;
}
export function ActorAvatar({ type, id, size = 32 }: Props) {
const { getName, getAvatarUrl } = useActorLookup();
const name = getName(type, id);
const url = getAvatarUrl(type, id);
if (url) {
return (
<Image
source={{ uri: url }}
style={{ width: size, height: size, borderRadius: size / 2 }}
className="bg-muted"
/>
);
}
const isAgent = type === "agent";
return (
<View
style={{ width: size, height: size, borderRadius: size / 2 }}
className={cn(
"items-center justify-center",
isAgent ? "bg-brand/15" : "bg-muted",
)}
>
<Text
className={cn(
"text-xs font-medium",
isAgent ? "text-brand" : "text-muted-foreground",
)}
>
{getInitials(name)}
</Text>
</View>
);
}

View File

@@ -0,0 +1,63 @@
import * as React from "react";
import { Pressable, View, type PressableProps } from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
const buttonVariants = cva("items-center justify-center rounded-md", {
variants: {
variant: {
default: "bg-primary active:opacity-80",
secondary: "bg-secondary active:opacity-80",
outline: "border border-border bg-background active:bg-secondary",
brand: "bg-brand active:opacity-80",
},
size: {
default: "h-12 px-6",
sm: "h-10 px-4",
lg: "h-14 px-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
const buttonTextVariants = cva("text-sm font-medium", {
variants: {
variant: {
default: "text-primary-foreground",
secondary: "text-secondary-foreground",
outline: "text-foreground",
brand: "text-brand-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
interface ButtonProps
extends PressableProps,
VariantProps<typeof buttonVariants> {
children: React.ReactNode;
className?: string;
}
const Button = React.forwardRef<View, ButtonProps>(
({ className, variant, size, children, ...props }, ref) => {
return (
<Pressable
ref={ref as React.Ref<View>}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
>
<Text className={buttonTextVariants({ variant })}>{children}</Text>
</Pressable>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { Pressable, View, type PressableProps, type ViewProps } from "react-native";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<View, ViewProps & { className?: string }>(
({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"rounded-md border border-border bg-card p-4",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardPressable = React.forwardRef<
View,
PressableProps & { className?: string; children?: React.ReactNode }
>(({ className, children, ...props }, ref) => (
<Pressable
ref={ref as React.Ref<View>}
className={cn(
"rounded-md border border-border bg-card p-4 active:bg-secondary",
className,
)}
{...props}
>
{children as React.ReactNode}
</Pressable>
));
CardPressable.displayName = "CardPressable";
export { Card, CardPressable };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { TextInput, type TextInputProps } from "react-native";
import { cn } from "@/lib/utils";
type Props = TextInputProps & { className?: string };
const Input = React.forwardRef<TextInput, Props>(
({ className, ...props }, ref) => {
return (
<TextInput
ref={ref}
className={cn(
"h-12 rounded-md border border-border bg-background px-4 text-base text-foreground",
className,
)}
placeholderTextColor="#a1a1aa"
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,54 @@
/**
* Mobile PriorityIcon. Renders 4 stacked bars matching the priority order,
* mirroring the structure of packages/core/issues/config/priority.ts which
* defines `bars: 0..4` per priority. Visual is mobile-tuned (small chevron
* stack), but the bar count is the same as web/desktop — Behavioral parity
* rule (counts and visibility must agree).
*/
import { View } from "react-native";
import { cn } from "@/lib/utils";
import type { IssuePriority } from "@multica/core/types";
const BARS: Record<IssuePriority, number> = {
urgent: 4,
high: 3,
medium: 2,
low: 1,
none: 0,
};
const COLOR: Record<IssuePriority, string> = {
urgent: "bg-destructive",
high: "bg-warning",
medium: "bg-warning",
low: "bg-info",
none: "bg-muted-foreground/40",
};
export function PriorityIcon({ priority }: { priority: IssuePriority }) {
const filled = BARS[priority];
const color = COLOR[priority];
if (priority === "none") {
return (
<View className="size-4 items-center justify-center">
<View className="size-1 rounded-full bg-muted-foreground/40" />
</View>
);
}
return (
<View className="size-4 flex-row items-end justify-center gap-[1px]">
{[1, 2, 3, 4].map((b) => (
<View
key={b}
className={cn(
"w-[2px] rounded-sm",
b <= filled ? color : "bg-muted-foreground/30",
)}
style={{ height: 4 + b * 2 }}
/>
))}
</View>
);
}

View File

@@ -0,0 +1,27 @@
/**
* iOS-style large title header. Sits at the top of each tab, above the list.
* Not a real UINavigationBar — it's a static large title (no scroll-to-shrink
* collapse), but visually communicates "this is an iOS app" instead of the
* naked SafeAreaView default.
*/
import { View } from "react-native";
import { Text } from "@/components/ui/text";
export function ScreenHeader({
title,
subtitle,
}: {
title: string;
subtitle?: string;
}) {
return (
<View className="px-4 pt-2 pb-3">
<Text className="text-3xl font-bold text-foreground">{title}</Text>
{subtitle ? (
<Text className="text-sm text-muted-foreground mt-0.5">
{subtitle}
</Text>
) : null}
</View>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Mobile StatusIcon. Renders a small colored circle/glyph per IssueStatus.
* Mirrors the status enum coverage of packages/core/issues/config/status.ts
* — every status MUST be represented (Behavioral parity: same enums rule).
*/
import { View } from "react-native";
import type { IssueStatus } from "@multica/core/types";
const COLOR: Record<IssueStatus, string> = {
backlog: "bg-muted-foreground/30",
todo: "bg-muted-foreground",
in_progress: "bg-warning",
in_review: "bg-success",
done: "bg-info",
blocked: "bg-destructive",
cancelled: "bg-muted-foreground/30",
};
export function StatusIcon({ status }: { status: IssueStatus }) {
return (
<View className="size-4 items-center justify-center">
<View className={`size-2.5 rounded-full ${COLOR[status]}`} />
</View>
);
}

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { Text as RNText, type TextProps } from "react-native";
import { cn } from "@/lib/utils";
type Props = TextProps & { className?: string };
const Text = React.forwardRef<RNText, Props>(({ className, ...props }, ref) => {
return (
<RNText
ref={ref}
className={cn("text-base text-foreground", className)}
{...props}
/>
);
});
Text.displayName = "Text";
export { Text };

3
apps/mobile/global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,49 @@
/**
* Inbox title display helpers.
*
* Mirrors packages/views/inbox/components/inbox-display.ts. Keeping behavior
* identical is required by apps/mobile/CLAUDE.md "Behavioral parity":
* the title a user sees in the mobile inbox MUST match what they see on
* web for the same item. When the web version changes, sync this file.
*/
import type { InboxItem } from "@multica/core/types";
function singleLine(value: string | null | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function stripQuickCreatePrefix(
title: string,
identifier?: string,
): string {
const normalized = singleLine(title);
if (!normalized) return "";
if (identifier) {
const exactPrefix = new RegExp(
`^Created\\s+${escapeRegExp(identifier)}:\\s*`,
"i",
);
const withoutExactPrefix = normalized.replace(exactPrefix, "");
if (withoutExactPrefix !== normalized) return withoutExactPrefix.trim();
}
return normalized.replace(/^Created\s+[A-Z][A-Z0-9]*-\d+:\s*/i, "").trim();
}
export function getInboxDisplayTitle(item: InboxItem): string {
const details = item.details ?? {};
if (item.type === "quick_create_done") {
const cleanedTitle = stripQuickCreatePrefix(item.title, details.identifier);
if (cleanedTitle) return cleanedTitle;
const prompt = singleLine(details.original_prompt);
if (prompt) return prompt;
}
if (item.type === "quick_create_failed") {
const prompt = singleLine(details.original_prompt);
if (prompt) return prompt;
}
return item.title;
}

View File

@@ -0,0 +1,24 @@
/**
* Mobile time-ago formatter. Mirrors the algorithm in
* packages/views/inbox/components/inbox-list-item.tsx `useTimeAgo` so
* "X minutes ago" reads identically across web/desktop and mobile (Behavioral
* parity rule in apps/mobile/CLAUDE.md). The web version is i18n-driven via
* useT; mobile v1 is English-only — when mobile ships i18n, mirror that
* structure.
*/
export function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w ago`;
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}

6
apps/mobile/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,23 @@
// Metro bundler configuration for the mobile app inside the multica monorepo.
// Watches the entire monorepo so type-only imports from packages/core/types/*
// resolve, looks up node_modules from both project and monorepo root, and
// enables symlinks so Metro can follow pnpm's symlinked layout to transitive
// deps. Hierarchical lookup is left enabled (default) — pnpm needs it.
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const path = require("path");
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [monorepoRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(monorepoRoot, "node_modules"),
];
config.resolver.unstable_enableSymlinks = true;
module.exports = withNativeWind(config, { input: "./global.css" });

1
apps/mobile/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

46
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "@multica/mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"dev:staging": "dotenv -e .env.staging -- cross-env APP_ENV=staging expo start",
"ios": "expo start --ios",
"ios:staging": "dotenv -e .env.staging -- cross-env APP_ENV=staging expo start --ios",
"lint": "expo lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rn-primitives/slot": "^1.1.0",
"@tanstack/react-query": "^5.96.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~55.0.23",
"expo-constants": "~55.0.16",
"expo-linking": "~55.0.0",
"expo-router": "~55.0.14",
"expo-secure-store": "~55.0.13",
"expo-status-bar": "~55.0.0",
"expo-system-ui": "~55.0.0",
"nativewind": "^4.1.23",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.6",
"react-native-gesture-handler": "~2.30.1",
"react-native-reanimated": "~4.2.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.17",
"zustand": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~19.0.0",
"cross-env": "^7.0.3",
"dotenv-cli": "^7.4.4",
"typescript": "~5.9.0"
}
}

View File

@@ -0,0 +1,56 @@
/**
* Mobile design tokens — transcribed by hand from packages/ui/styles/tokens.css
* (web/desktop). Web tokens use oklch + Tailwind v4 @theme inline syntax which
* NativeWind 4 + Tailwind 3.4 can't consume, so we re-author them here as hex
* approximations. When web tokens drift, sync this file by hand — divergence
* is intentional.
*
* Mobile-specific tweaks (touch-friendly spacing, no hover variants) live here
* too. Do NOT import packages/ui/styles/tokens.css.
*/
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
background: "#ffffff",
foreground: "#1f1f23",
card: "#ffffff",
"card-foreground": "#1f1f23",
popover: "#ffffff",
"popover-foreground": "#1f1f23",
primary: "#2e2e33",
"primary-foreground": "#fafafa",
secondary: "#f4f4f5",
"secondary-foreground": "#2e2e33",
muted: "#f4f4f5",
"muted-foreground": "#71717a",
accent: "#f4f4f5",
"accent-foreground": "#2e2e33",
destructive: "#dc2626",
border: "#e4e4e7",
input: "#e4e4e7",
ring: "#a1a1aa",
brand: "#4571e0",
"brand-foreground": "#fafafa",
success: "#22c55e",
warning: "#eab308",
info: "#3b82f6",
priority: "#f97316",
},
borderRadius: {
sm: "calc(0.625rem * 0.6)",
md: "calc(0.625rem * 0.8)",
lg: "0.625rem",
xl: "calc(0.625rem * 1.4)",
},
},
},
plugins: [],
};

16
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
"nativewind-env.d.ts"
]
}

View File

@@ -8,10 +8,12 @@
"dev:docs": "turbo dev --filter=@multica/docs",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"dev:desktop:staging": "turbo dev:staging --filter=@multica/desktop",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",
"lint": "turbo lint",
"dev:mobile": "pnpm -C apps/mobile dev",
"dev:mobile:staging": "pnpm -C apps/mobile dev:staging",
"build": "turbo build --filter=!@multica/mobile",
"typecheck": "turbo typecheck --filter=!@multica/mobile",
"test": "turbo test --filter=!@multica/mobile",
"lint": "turbo lint --filter=!@multica/mobile",
"clean": "turbo clean && rm -rf node_modules",
"ui:add": "cd packages/ui && npx shadcn@latest add",
"generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs"

5675
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff