mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
20
apps/mobile/.gitignore
vendored
Normal file
20
apps/mobile/.gitignore
vendored
Normal 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
|
||||
@@ -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
40
apps/mobile/app.config.ts
Normal 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 },
|
||||
};
|
||||
};
|
||||
58
apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx
Normal file
58
apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx
Normal file
122
apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx
Normal file
85
apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
apps/mobile/app/(app)/[workspace]/(tabs)/settings.tsx
Normal file
107
apps/mobile/app/(app)/[workspace]/(tabs)/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/mobile/app/(app)/[workspace]/_layout.tsx
Normal file
38
apps/mobile/app/(app)/[workspace]/_layout.tsx
Normal 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 }} />;
|
||||
}
|
||||
15
apps/mobile/app/(app)/_layout.tsx
Normal file
15
apps/mobile/app/(app)/_layout.tsx
Normal 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 }} />;
|
||||
}
|
||||
89
apps/mobile/app/(app)/select-workspace.tsx
Normal file
89
apps/mobile/app/(app)/select-workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/mobile/app/(auth)/_layout.tsx
Normal file
5
apps/mobile/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
74
apps/mobile/app/(auth)/login.tsx
Normal file
74
apps/mobile/app/(auth)/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/mobile/app/(auth)/verify.tsx
Normal file
85
apps/mobile/app/(auth)/verify.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/mobile/app/_layout.tsx
Normal file
37
apps/mobile/app/_layout.tsx
Normal 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
30
apps/mobile/app/index.tsx
Normal 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`} />;
|
||||
}
|
||||
9
apps/mobile/babel.config.js
Normal file
9
apps/mobile/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
58
apps/mobile/components/ui/actor-avatar.tsx
Normal file
58
apps/mobile/components/ui/actor-avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/mobile/components/ui/button.tsx
Normal file
63
apps/mobile/components/ui/button.tsx
Normal 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 };
|
||||
36
apps/mobile/components/ui/card.tsx
Normal file
36
apps/mobile/components/ui/card.tsx
Normal 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 };
|
||||
24
apps/mobile/components/ui/input.tsx
Normal file
24
apps/mobile/components/ui/input.tsx
Normal 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 };
|
||||
54
apps/mobile/components/ui/priority-icon.tsx
Normal file
54
apps/mobile/components/ui/priority-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/mobile/components/ui/screen-header.tsx
Normal file
27
apps/mobile/components/ui/screen-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/mobile/components/ui/status-icon.tsx
Normal file
25
apps/mobile/components/ui/status-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/mobile/components/ui/text.tsx
Normal file
18
apps/mobile/components/ui/text.tsx
Normal 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
3
apps/mobile/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
49
apps/mobile/lib/inbox-display.ts
Normal file
49
apps/mobile/lib/inbox-display.ts
Normal 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;
|
||||
}
|
||||
24
apps/mobile/lib/time-ago.ts
Normal file
24
apps/mobile/lib/time-ago.ts
Normal 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
6
apps/mobile/lib/utils.ts
Normal 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));
|
||||
}
|
||||
23
apps/mobile/metro.config.js
Normal file
23
apps/mobile/metro.config.js
Normal 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
1
apps/mobile/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
46
apps/mobile/package.json
Normal file
46
apps/mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
apps/mobile/tailwind.config.js
Normal file
56
apps/mobile/tailwind.config.js
Normal 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
16
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"nativewind-env.d.ts"
|
||||
]
|
||||
}
|
||||
10
package.json
10
package.json
@@ -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
5675
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user