diff --git a/app.json b/app.json index a707555..61e950e 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "product-manager-mobile", "owner": "interactor", "version": "1.0.0", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icon.png", "scheme": "buildapp", "userInterfaceStyle": "automatic", @@ -43,7 +43,8 @@ { "backgroundColor": "#FFFFFF" } - ] + ], + "expo-web-browser" ], "experiments": { "typedRoutes": true diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index f8cf7bd..838fcae 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { View, Text, @@ -10,22 +10,45 @@ import { StyleSheet, KeyboardAvoidingView, Platform, + type TextInput as TextInputType, } from "react-native"; +import * as WebBrowser from "expo-web-browser"; import { useAuthStore } from "@/src/store/auth"; +import { API_BASE_URL } from "@/src/lib/config"; + +WebBrowser.maybeCompleteAuthSession(); export default function LoginScreen() { - const { signIn } = useAuthStore(); + const { signIn, ssoSignIn } = useAuthStore(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); + const [ssoLoading, setSsoLoading] = useState(false); const [error, setError] = useState(null); + const passwordRef = useRef(null); + + // Dev-only QA auto-login: lets simulators sign in without typing. + // Set EXPO_PUBLIC_QA_EMAIL / EXPO_PUBLIC_QA_PASSWORD in .env.local + // (gitignored); compiled out of production builds via __DEV__. + const autoTried = useRef(false); + useEffect(() => { + const qaEmail = process.env.EXPO_PUBLIC_QA_EMAIL; + const qaPassword = process.env.EXPO_PUBLIC_QA_PASSWORD; + if (!__DEV__ || !qaEmail || !qaPassword || autoTried.current) return; + autoTried.current = true; + setLoading(true); + signIn(qaEmail, qaPassword) + .catch((err) => setError(err instanceof Error ? err.message : "QA auto-login failed")) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); async function handleSignIn() { - if (!email || !password) return; + if (!email.trim() || !password) return; setLoading(true); setError(null); try { - await signIn(email, password); + await signIn(email.trim(), password); } catch (err) { setError(err instanceof Error ? err.message : "Sign in failed"); } finally { @@ -33,40 +56,125 @@ export default function LoginScreen() { } } + async function handleSSO() { + setSsoLoading(true); + setError(null); + try { + // callbackUrl points to the backend route that mints pm_mobile_* and + // redirects to buildapp://auth/callback?token=...&email=...&userId=... + const platform = Platform.OS === "ios" ? "ios" : "android"; + const callbackUrl = encodeURIComponent( + `/api/auth/mobile-callback?platform=${platform}` + ); + const signInUrl = `${API_BASE_URL}/api/auth/signin/interactor?callbackUrl=${callbackUrl}`; + + const result = await WebBrowser.openAuthSessionAsync(signInUrl, "buildapp"); + + if (result.type !== "success" || !result.url) { + // User cancelled or error — no action needed + return; + } + + // Parse buildapp://auth/callback?token=pm_mobile_*&email=...&userId=... + const url = new URL(result.url); + const token = url.searchParams.get("token"); + const ssoEmail = url.searchParams.get("email"); + const userId = url.searchParams.get("userId"); + + if (!token?.startsWith("pm_mobile_") || !ssoEmail || !userId) { + setError("Sign in failed. Please try again."); + return; + } + + await ssoSignIn(token, ssoEmail, userId); + } catch { + setError("Sign in failed. Please try again."); + } finally { + setSsoLoading(false); + } + } + + const busy = loading || ssoLoading; + return ( + + + B + + + Build Project management for software teams - {error && {error}} + {/* Interactor SSO (Google + others) */} + + {ssoLoading ? ( + + ) : ( + <> + + I + + Sign in with Interactor SSO + + )} + + + + + or continue with email + + + + {error && ( + + {error} + + )} { setEmail(v); setError(null); }} + onSubmitEditing={() => passwordRef.current?.focus()} + editable={!busy} /> { setPassword(v); setError(null); }} onSubmitEditing={handleSignIn} + editable={!busy} /> {loading ? ( @@ -81,20 +189,68 @@ export default function LoginScreen() { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#FFFFFF" }, - inner: { flex: 1, justifyContent: "center", padding: 24 }, + inner: { flex: 1, justifyContent: "center", padding: 28 }, + logoWrap: { alignItems: "center", marginBottom: 24 }, + logoCircle: { + width: 64, + height: 64, + borderRadius: 16, + backgroundColor: "#4CD964", + alignItems: "center", + justifyContent: "center", + }, + logoText: { fontSize: 32, fontWeight: "800", color: "#FFFFFF" }, title: { - fontSize: 40, + fontSize: 34, fontWeight: "800", color: "#1C1C1E", - marginBottom: 8, + textAlign: "center", + marginBottom: 6, }, - subtitle: { fontSize: 16, color: "#8E8E93", marginBottom: 40 }, - error: { - color: "#FF3B30", - fontSize: 14, - marginBottom: 16, + subtitle: { + fontSize: 15, + color: "#8E8E93", textAlign: "center", + marginBottom: 28, + }, + ssoButton: { + height: 52, + borderRadius: 9999, + borderWidth: 1, + borderColor: "#E5E5EA", + backgroundColor: "#FFFFFF", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 10, + marginBottom: 20, + }, + ssoIcon: { + width: 22, + height: 22, + borderRadius: 4, + backgroundColor: "#4CD964", + alignItems: "center", + justifyContent: "center", + }, + ssoIconText: { color: "#FFFFFF", fontWeight: "800", fontSize: 12 }, + ssoButtonText: { color: "#1C1C1E", fontWeight: "500", fontSize: 15 }, + divider: { + flexDirection: "row", + alignItems: "center", + gap: 10, + marginBottom: 20, + }, + dividerLine: { flex: 1, height: 1, backgroundColor: "#E5E5EA" }, + dividerText: { color: "#8E8E93", fontSize: 12 }, + errorBox: { + backgroundColor: "#FFF0EF", + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + marginBottom: 16, }, + errorText: { color: "#FF3B30", fontSize: 14, textAlign: "center" }, input: { height: 52, borderRadius: 12, @@ -106,6 +262,7 @@ const styles = StyleSheet.create({ backgroundColor: "#F5F5F5", marginBottom: 12, }, + inputError: { borderColor: "#FF3B30" }, button: { height: 52, borderRadius: 9999, @@ -114,6 +271,6 @@ const styles = StyleSheet.create({ justifyContent: "center", marginTop: 8, }, - buttonDisabled: { opacity: 0.6 }, + buttonDisabled: { opacity: 0.5 }, buttonText: { color: "#FFFFFF", fontWeight: "700", fontSize: 16 }, }); diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index ab0cc8b..7a838d9 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,45 +1,301 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later -import { Tabs } from "expo-router"; -import { Platform } from "react-native"; +/** + * Tab shell mirroring the web app's mobile bottom nav (final state after + * product-manager #1966-#1973): + * + * Attention · Goals · [AI hero] · Feedback · Menu + * + * Red count badges on Attention (approvals + admissions) and Goals (running + * fleet goals). The Menu tab opens the Menu sheet (profile + project nav); + * Overview lives there now, not in the tab bar. The fleet drawer opens via a + * left-swipe from the right edge (32px zone, 50px threshold — same as web), + * the header fleet button, or the edge handle. + */ +import { useRef, useState } from "react"; +import { Tabs, useRouter } from "expo-router"; +import { View, Text, TouchableOpacity, PanResponder, Platform, StyleSheet, Image } from "react-native"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { AppHeader } from "@/src/components/app-header"; +import { AiPromptSheet } from "@/src/components/ai-prompt-sheet"; +import { FleetSidebar } from "@/src/components/fleet-sidebar"; +import { MenuSheet } from "@/src/components/menu-sheet"; +import { + useTodos, + useAdmissionApprovals, + useFleetQueue, + useFleetMachines, + useCounts, +} from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; +import { colors } from "@/src/lib/theme"; -const PRIMARY = "#4CD964"; -const INACTIVE = "#8E8E93"; +type IoniconName = React.ComponentProps["name"]; -export default function TabLayout() { +function tabIcon(focused: boolean, active: IoniconName, inactive: IoniconName) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ({ color }: { color: any }) => ( + + ); +} + +/** Center AI hero — the Interactor icon in a protruding ringed circle, + * identical to the web bottom nav (public/images/icon/icon_green.png). */ +function AiTabButton({ onPress }: { onPress: () => void }) { + return ( + + + + + AI + + ); +} + +/** Menu tab — opens the Menu sheet instead of navigating. */ +function MenuTabButton({ onPress }: { onPress: () => void }) { return ( - - - - - - + Menu + + ); +} + +export default function TabLayout() { + const [aiOpen, setAiOpen] = useState(false); + const [fleetOpen, setFleetOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const router = useRouter(); + + const selectedProject = useProjectStore((s) => s.selected); + + // Red tab badges (web useNavCounts parity): Attention = approvals routed to + // me (todos + goal admissions); Goals = fleet goals actively running; + // Feedback = untriaged feedback (project-scoped, mirrors untriagedFeedbackCount). + const todos = useTodos(); + const admissions = useAdmissionApprovals(); + const fleetQueue = useFleetQueue(true); + const machines = useFleetMachines(true); + const projectCounts = useCounts( + selectedProject + ? { orgSlug: selectedProject.orgSlug, projectSlug: selectedProject.slug } + : undefined + ); + + const attentionCount = + (todos.data?.data.items.filter((t) => t.kind === "approval").length ?? 0) + + (admissions.data?.data.length ?? 0); + const runningCount = + fleetQueue.data?.data.goals.filter( + (g) => g.status === "running" || g.status === "recovering" + ).length ?? 0; + const feedbackCount = projectCounts.data?.untriagedFeedbackCount ?? 0; + + // Edge handle status dot (matches the header fleet button logic). + const machineList = (machines.data?.data ?? []).filter((m) => m.status); + const alert = machineList.some( + (m) => m.status === "stuck" || m.status === "error" || m.status === "offline" + ); + const working = machineList.filter((m) => m.status === "working").length; + const dotColor = alert ? colors.danger : working > 0 ? colors.primary : "#D1D1D6"; + + // Left-swipe from the right screen edge opens the fleet drawer. + // Web thresholds: 32px edge zone, |dx| >= 50, vertical swipes rejected. + const edgeSwipe = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_e, g) => + g.dx < 0 && Math.abs(g.dx) > Math.abs(g.dy), + onPanResponderRelease: (_e, g) => { + if (g.dx <= -50) setFleetOpen(true); + }, + }) + ).current; + + const badgeStyle = { + backgroundColor: colors.danger, + color: "#FFFFFF", + fontSize: 11, + fontWeight: "700" as const, + }; + + return ( + + setFleetOpen(true)} /> + + 0 ? (attentionCount > 99 ? "99+" : attentionCount) : undefined, + tabBarBadgeStyle: badgeStyle, + tabBarIcon: ({ focused, color }) => + tabIcon(focused, "notifications", "notifications-outline")({ color }), + }} + /> + 0 ? (runningCount > 99 ? "99+" : runningCount) : undefined, + tabBarBadgeStyle: badgeStyle, + tabBarIcon: ({ focused, color }) => + tabIcon(focused, "disc", "disc-outline")({ color }), + }} + /> + setAiOpen(true)} />, + }} + /> + 0 ? (feedbackCount > 99 ? "99+" : feedbackCount) : undefined, + tabBarBadgeStyle: badgeStyle, + tabBarIcon: ({ focused, color }) => + tabIcon(focused, "chatbubble-ellipses", "chatbubble-ellipses-outline")({ color }), + }} + /> + setMenuOpen(true)} />, + }} + /> + {/* Hidden routes: Overview lives in the Menu sheet now; notifications + + me open from the top bar / Menu sheet. */} + + + + + + {/* Right-edge swipe zone + visible fleet handle (only while closed) */} + {!fleetOpen && !menuOpen && ( + <> + + setFleetOpen(true)} + activeOpacity={0.8} + accessibilityLabel="Open fleet panel" + > + + + {machineList.length > 0 && ( + + {working}/{machineList.length} + + )} + + + )} + + setFleetOpen(false)} /> + setMenuOpen(false)} /> + + setAiOpen(false)} + onSubmit={(prompt) => { + setAiOpen(false); + router.push({ pathname: "/(tabs)/ai", params: { prompt } } as never); + }} /> - + ); } + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.background }, + aiWrap: { flex: 1, alignItems: "center" }, + aiCircle: { + width: 60, + height: 60, + borderRadius: 30, + marginTop: -26, + borderWidth: 4, + borderColor: "#FFFFFF", + overflow: "hidden", + backgroundColor: colors.primary, + shadowColor: "#000", + shadowOpacity: 0.18, + shadowRadius: 8, + shadowOffset: { width: 0, height: 3 }, + elevation: 6, + }, + aiImage: { width: 52, height: 52 }, + aiLabel: { fontSize: 11, fontWeight: "600", color: colors.textSecondary, marginTop: 1 }, + menuWrap: { flex: 1, alignItems: "center", justifyContent: "center", gap: 2 }, + menuLabel: { fontSize: 11, fontWeight: "600", color: colors.textSecondary }, + edgeZone: { + position: "absolute", + top: 100, + right: 0, + bottom: 80, + width: 32, + }, + edgeHandle: { + position: "absolute", + right: 0, + top: "45%", + alignItems: "center", + gap: 2, + backgroundColor: colors.background, + borderTopLeftRadius: 10, + borderBottomLeftRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + borderRightWidth: 0, + borderColor: colors.border, + paddingVertical: 8, + paddingHorizontal: 6, + shadowColor: "#000", + shadowOpacity: 0.08, + shadowRadius: 4, + shadowOffset: { width: -1, height: 1 }, + elevation: 3, + }, + edgeDot: { + position: "absolute", + top: 5, + right: 4, + width: 7, + height: 7, + borderRadius: 4, + borderWidth: 1, + borderColor: colors.background, + }, + edgeCount: { fontSize: 8, fontWeight: "600", color: colors.textSecondary }, +}); diff --git a/app/(tabs)/ai.tsx b/app/(tabs)/ai.tsx new file mode 100644 index 0000000..7e98ba1 --- /dev/null +++ b/app/(tabs)/ai.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * AI chat screen — native counterpart of the web's /ai page. + * + * The full Senior PM chat (agent sessions + streaming) is not yet ported to + * native; this screen receives the prompt from the AI bottom sheet and hands + * off to the web chat until the native session API client lands. + */ +import { View, Text, TouchableOpacity, StyleSheet, Linking } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams } from "expo-router"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { API_BASE_URL } from "@/src/lib/config"; +import { colors, radii } from "@/src/lib/theme"; + +export default function AiScreen() { + const { prompt } = useLocalSearchParams<{ prompt?: string }>(); + + return ( + + + + + + Ask the Senior PM + + {prompt ? ( + + {prompt} + + ) : null} + + + The native AI chat is coming soon. Continue this conversation in the + Build web app for now. + + + { + const url = prompt + ? `${API_BASE_URL}/ai?prompt=${encodeURIComponent(prompt)}` + : `${API_BASE_URL}/ai`; + void Linking.openURL(url); + }} + > + + Open AI chat on web + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + inner: { flex: 1, alignItems: "center", justifyContent: "center", padding: 28 }, + iconCircle: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: colors.primary, + alignItems: "center", + justifyContent: "center", + marginBottom: 16, + }, + title: { fontSize: 20, fontWeight: "700", color: colors.text, marginBottom: 12 }, + promptBubble: { + backgroundColor: colors.primaryLight, + borderRadius: radii.lg, + paddingHorizontal: 16, + paddingVertical: 10, + marginBottom: 12, + maxWidth: "90%", + }, + promptText: { fontSize: 14, color: colors.primaryText }, + subtitle: { + fontSize: 14, + color: colors.textSecondary, + textAlign: "center", + marginBottom: 24, + lineHeight: 20, + }, + button: { + flexDirection: "row", + alignItems: "center", + gap: 8, + backgroundColor: colors.primary, + borderRadius: radii.pill, + paddingHorizontal: 20, + height: 44, + }, + buttonText: { color: "#FFFFFF", fontWeight: "600", fontSize: 14 }, +}); diff --git a/app/(tabs)/approvals.tsx b/app/(tabs)/approvals.tsx index a8f77a8..bbaf98e 100644 --- a/app/(tabs)/approvals.tsx +++ b/app/(tabs)/approvals.tsx @@ -3,115 +3,303 @@ import { useState } from "react"; import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, - RefreshControl, StyleSheet, SafeAreaView, Modal, TextInput, Alert, + RefreshControl, StyleSheet, Modal, TextInput, Alert, } from "react-native"; -import { useTodos, useDecideApproval, type TodoItem } from "@/src/lib/queries"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { PageHeader } from "@/src/components/page-header"; +import { + useTodos, + useDecideApproval, + useAdmissionApprovals, + type TodoItem, + type AdmissionApproval, +} from "@/src/lib/queries"; type ApprovalItem = Extract; -function ApprovalRow({ +const VERDICT_CONFIG: Record = { + aligned: { label: "Aligned", color: "#2F855A", bg: "#E8F8EB" }, + conflict: { label: "Conflict", color: "#C53030", bg: "#FFE5E3" }, + unclear: { label: "Unclear", color: "#B7791F", bg: "#FFF1E0" }, +}; + +/** + * Goal-admission approval card — mirrors the web todos/attention panel item: + * goal title + steward domain + the AI admission brief (verdict + summary). + * A policy-auto-granted row shows provenance instead of decide buttons. + */ +function AdmissionCard({ + item, + onApprove, + onReject, + isPending, +}: { + item: AdmissionApproval; + onApprove: () => void; + onReject: () => void; + isPending: boolean; +}) { + const verdict = item.brief?.verdict ? VERDICT_CONFIG[item.brief.verdict] : null; + + return ( + + + + + + + + {item.projectSlug} · {item.stewardDomain} steward + + + {item.requestedBy?.name ?? item.requestedBy?.email ?? "Agent"} requested admission + + + {item.goalDisplayNumber != null && ( + G#{item.goalDisplayNumber} + )} + + + {item.goalTitle} + + {/* AI admission brief */} + {item.brief && !item.brief.unavailable && ( + + {verdict && ( + + {verdict.label} + + )} + {item.brief.summary} + + )} + {item.brief?.unavailable && ( + AI triage was unavailable for this goal. + )} + + {item.autoApproved ? ( + + Auto-admitted by policy: {item.autoApproved.ruleSummary} + + ) : ( + + + + Reject + + + {isPending ? ( + + ) : ( + <> + + Approve + + )} + + + )} + + ); +} + +function ApprovalCard({ item, - onDecide, + onApprove, + onReject, + isPending, }: { item: ApprovalItem; - onDecide: (id: string, action: "grant" | "reject") => void; + onApprove: () => void; + onReject: () => void; + isPending: boolean; }) { + const daysAgo = Math.floor( + (Date.now() - new Date(item.createdAt).getTime()) / 86_400_000 + ); + const timeLabel = daysAgo === 0 ? "Today" : daysAgo === 1 ? "Yesterday" : `${daysAgo}d ago`; + return ( - - {item.title} - {item.project.name} · {item.organization.name} - - Requested {new Date(item.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })} - + + {/* Header */} + + + + + + + {item.project.name} · {item.organization.name} + + {timeLabel} + + + + {/* Title */} + {item.title} + + {/* Actions */} onDecide(item.id, "reject")} + style={styles.rejectBtn} + onPress={onReject} + disabled={isPending} + activeOpacity={0.75} > - Reject + + Reject onDecide(item.id, "grant")} + style={styles.approveBtn} + onPress={onApprove} + disabled={isPending} + activeOpacity={0.75} > - Approve + {isPending ? ( + + ) : ( + <> + + Approve + + )} ); } +const TABS = ["Requests", "Admissions"] as const; +type Tab = (typeof TABS)[number]; + export default function ApprovalsScreen() { - const { data, isLoading, isRefetching, refetch } = useTodos(); + const [tab, setTab] = useState("Requests"); + const todos = useTodos(); + const admissions = useAdmissionApprovals(); const decide = useDecideApproval(); - const [pendingAction, setPendingAction] = useState<{ id: string; action: "grant" | "reject" } | null>(null); + const [pendingReject, setPendingReject] = useState(null); const [comment, setComment] = useState(""); - const approvals = (data?.data ?? []).filter( + const approvals = (todos.data?.data.items ?? []).filter( (t): t is ApprovalItem => t.kind === "approval" ); + const admissionItems = admissions.data?.data ?? []; - function handleDecide(id: string, action: "grant" | "reject") { - if (action === "reject") { - setPendingAction({ id, action }); - } else { - Alert.alert("Approve", "Approve this request?", [ - { text: "Cancel", style: "cancel" }, - { - text: "Approve", - onPress: () => - decide.mutate({ id, action: "grant" }, { - onError: (e) => Alert.alert("Error", e.message), - }), - }, - ]); - } + const isLoading = tab === "Requests" ? todos.isLoading : admissions.isLoading; + const isRefetching = tab === "Requests" ? todos.isRefetching : admissions.isRefetching; + const refetch = tab === "Requests" ? todos.refetch : admissions.refetch; + const badgeCount = tab === "Requests" ? approvals.length : admissionItems.length; + + function handleApprove(id: string) { + Alert.alert("Approve", "Approve this request?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Approve", + onPress: () => + decide.mutate({ id, action: "grant" }, { + onError: (e) => Alert.alert("Error", e.message), + }), + }, + ]); } function submitReject() { - if (!pendingAction) return; + if (!pendingReject) return; decide.mutate( - { id: pendingAction.id, action: "reject", comment: comment.trim() || undefined }, + { id: pendingReject, action: "reject", comment: comment.trim() || undefined }, { - onSuccess: () => { setPendingAction(null); setComment(""); }, + onSuccess: () => { setPendingReject(null); setComment(""); }, onError: (e) => Alert.alert("Error", e.message), } ); } return ( - - - Approvals - {approvals.length > 0 && ( - {approvals.length} - )} + + {/* Header */} + + + {/* Tabs: general approval requests vs goal-admission approvals */} + + {TABS.map((t) => ( + setTab(t)} + > + {t} + + ))} {isLoading ? ( - - ) : ( + + + + ) : tab === "Requests" ? ( item.id} - refreshControl={} - renderItem={({ item }) => } + contentContainerStyle={approvals.length === 0 ? styles.emptyFill : styles.listContent} + refreshControl={ + + } + renderItem={({ item }) => ( + handleApprove(item.id)} + onReject={() => setPendingReject(item.id)} + isPending={decide.isPending && decide.variables?.id === item.id} + /> + )} + ListEmptyComponent={ + + + All clear! + No pending approvals right now. + + } + /> + ) : ( + item.id} + contentContainerStyle={admissionItems.length === 0 ? styles.emptyFill : styles.listContent} + refreshControl={ + + } + renderItem={({ item }) => ( + handleApprove(item.id)} + onReject={() => setPendingReject(item.id)} + isPending={decide.isPending && decide.variables?.id === item.id} + /> + )} ListEmptyComponent={ - No pending approvals + + All clear! + No goal admissions waiting on you. } - contentContainerStyle={approvals.length === 0 ? styles.emptyFill : undefined} /> )} - {/* Reject with comment modal */} - + {/* Reject bottom sheet */} + + Reason for rejection - Optional — helps the requester understand the decision. + + Optional — helps the requester understand the decision. + { setPendingAction(null); setComment(""); }} + onPress={() => { setPendingReject(null); setComment(""); }} > Cancel {decide.isPending - ? - : Reject + ? + : Reject } @@ -148,30 +336,146 @@ export default function ApprovalsScreen() { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#F5F5F5" }, - header: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 12, + }, heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, - count: { fontSize: 14, fontWeight: "600", color: "#8E8E93", backgroundColor: "#E5E5EA", paddingHorizontal: 8, paddingVertical: 2, borderRadius: 9999 }, - row: { backgroundColor: "#FFFFFF", marginHorizontal: 16, marginBottom: 8, borderRadius: 12, padding: 14 }, - rowTitle: { fontSize: 15, fontWeight: "600", color: "#1C1C1E", marginBottom: 4 }, - rowProject: { fontSize: 12, color: "#8E8E93", marginBottom: 2 }, - rowDate: { fontSize: 12, color: "#C7C7CC", marginBottom: 12 }, + countBadge: { borderRadius: 9999, paddingHorizontal: 8, paddingVertical: 2 }, + countText: { fontSize: 13, fontWeight: "600" }, + tabRow: { flexDirection: "row", gap: 8, paddingHorizontal: 16, marginBottom: 12 }, + tabPill: { + borderRadius: 9999, + paddingHorizontal: 14, + paddingVertical: 7, + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#E5E5EA", + }, + tabPillActive: { backgroundColor: "#1C1C1E", borderColor: "#1C1C1E" }, + tabPillText: { fontSize: 13, fontWeight: "500", color: "#1C1C1E" }, + tabPillTextActive: { color: "#FFFFFF" }, + goalNumber: { fontSize: 12, fontWeight: "600", color: "#8E8E93" }, + briefBox: { + backgroundColor: "#F5F5F5", + borderRadius: 12, + padding: 12, + marginBottom: 14, + gap: 8, + }, + verdictBadge: { + alignSelf: "flex-start", + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 3, + }, + verdictText: { fontSize: 11, fontWeight: "700" }, + briefText: { fontSize: 13, color: "#3A3A3C", lineHeight: 19 }, + briefUnavailable: { fontSize: 12, color: "#8E8E93", fontStyle: "italic", marginBottom: 14 }, + autoApproved: { fontSize: 12, color: "#2F855A", fontWeight: "500" }, + listContent: { paddingHorizontal: 16, paddingBottom: 24, gap: 10 }, + card: { + backgroundColor: "#FFFFFF", + borderRadius: 16, + padding: 16, + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, + }, + cardHeader: { flexDirection: "row", alignItems: "flex-start", gap: 10, marginBottom: 10 }, + iconWrap: { + width: 32, + height: 32, + borderRadius: 8, + backgroundColor: "#FFF3E0", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + cardHeaderText: { flex: 1 }, + cardProject: { fontSize: 13, color: "#8E8E93", fontWeight: "500" }, + cardTime: { fontSize: 11, color: "#C7C7CC", marginTop: 1 }, + cardTitle: { fontSize: 15, fontWeight: "600", color: "#1C1C1E", lineHeight: 22, marginBottom: 14 }, actions: { flexDirection: "row", gap: 8 }, - actionBtn: { flex: 1, height: 38, borderRadius: 9999, alignItems: "center", justifyContent: "center" }, - rejectBtn: { backgroundColor: "#FFF0EF", borderWidth: 1, borderColor: "#FF3B30" }, - approveBtn: { backgroundColor: "#4CD964" }, - actionText: { fontWeight: "600", fontSize: 14 }, - rejectText: { color: "#FF3B30" }, - approveText: { color: "#FFFFFF" }, - center: { flex: 1, justifyContent: "center", alignItems: "center" }, + rejectBtn: { + flex: 1, + height: 42, + borderRadius: 9999, + backgroundColor: "#FFF0EF", + borderWidth: 1, + borderColor: "#FF3B30", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 5, + }, + rejectText: { fontWeight: "600", fontSize: 14, color: "#FF3B30" }, + approveBtn: { + flex: 1, + height: 42, + borderRadius: 9999, + backgroundColor: "#4CD964", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 5, + }, + approveText: { fontWeight: "600", fontSize: 14, color: "#FFFFFF" }, + center: { flex: 1, justifyContent: "center", alignItems: "center", paddingHorizontal: 32 }, emptyFill: { flex: 1 }, - emptyText: { fontSize: 16, color: "#8E8E93" }, + emptyTitle: { fontSize: 20, fontWeight: "700", color: "#1C1C1E", marginTop: 12 }, + emptySubtitle: { fontSize: 14, color: "#8E8E93", textAlign: "center", marginTop: 4 }, modalOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "flex-end" }, - modalCard: { backgroundColor: "#FFFFFF", borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 24 }, + modalCard: { + backgroundColor: "#FFFFFF", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + paddingBottom: 36, + }, + modalHandle: { + width: 36, + height: 4, + backgroundColor: "#E5E5EA", + borderRadius: 2, + alignSelf: "center", + marginBottom: 20, + }, modalTitle: { fontSize: 18, fontWeight: "700", color: "#1C1C1E", marginBottom: 4 }, modalSubtitle: { fontSize: 13, color: "#8E8E93", marginBottom: 16 }, - commentInput: { backgroundColor: "#F5F5F5", borderRadius: 12, padding: 12, fontSize: 15, color: "#1C1C1E", minHeight: 100, textAlignVertical: "top", marginBottom: 16 }, - modalActions: { flexDirection: "row", gap: 8 }, - modalCancel: { flex: 1, height: 44, borderRadius: 9999, backgroundColor: "#E5E5EA", alignItems: "center", justifyContent: "center" }, + commentInput: { + backgroundColor: "#F5F5F5", + borderRadius: 12, + padding: 14, + fontSize: 15, + color: "#1C1C1E", + minHeight: 100, + textAlignVertical: "top", + marginBottom: 16, + }, + modalActions: { flexDirection: "row", gap: 10 }, + modalCancel: { + flex: 1, + height: 48, + borderRadius: 9999, + backgroundColor: "#E5E5EA", + alignItems: "center", + justifyContent: "center", + }, modalCancelText: { fontSize: 15, fontWeight: "600", color: "#1C1C1E" }, - modalSubmit: { flex: 1, height: 44 }, + modalReject: { + flex: 1, + height: 48, + borderRadius: 9999, + backgroundColor: "#FF3B30", + alignItems: "center", + justifyContent: "center", + }, + modalRejectText: { fontSize: 15, fontWeight: "600", color: "#FFFFFF" }, }); diff --git a/app/(tabs)/feedback.tsx b/app/(tabs)/feedback.tsx new file mode 100644 index 0000000..5b55bda --- /dev/null +++ b/app/(tabs)/feedback.tsx @@ -0,0 +1,282 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +import { useState } from "react"; +import { + View, Text, FlatList, TouchableOpacity, ActivityIndicator, + RefreshControl, ScrollView, StyleSheet, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useFeedback, type FeedbackEntry } from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; +import { PageHeader } from "@/src/components/page-header"; + +// Source values that map to each tab — mirrors web feedback-client.tsx +type FeedbackTab = "internal" | "portal" | "idea"; + +const TABS: { key: FeedbackTab; label: string }[] = [ + { key: "internal", label: "Internal" }, + { key: "portal", label: "Portal queue" }, + { key: "idea", label: "Idea" }, +]; + +/** Source values that belong on the Portal queue tab. */ +const PORTAL_SOURCES = new Set(["portal", "portal_idea", "shared_link"]); +/** Source values that belong on the Idea tab. */ +const IDEA_SOURCES = new Set(["idea"]); + +function tabItems(items: FeedbackEntry[], tab: FeedbackTab): FeedbackEntry[] { + if (tab === "internal") return items.filter((e) => !PORTAL_SOURCES.has(e.source) && !IDEA_SOURCES.has(e.source)); + if (tab === "portal") return items.filter((e) => PORTAL_SOURCES.has(e.source)); + return items.filter((e) => IDEA_SOURCES.has(e.source)); +} + +const STATUS_CONFIG: Record = { + open: { label: "Open", color: "#8E8E93", bg: "#F5F5F5" }, + in_review: { label: "In Review", color: "#007AFF", bg: "#EBF4FF" }, + resolved: { label: "Resolved", color: "#4CD964", bg: "#E8F8EB" }, + declined: { label: "Declined", color: "#FF3B30", bg: "#FFF0EF" }, + planned: { label: "Planned", color: "#FF9500", bg: "#FFF3E0" }, +}; + +const SOURCE_CONFIG: Record["name"] }> = { + user: { label: "User", icon: "person" }, + slack: { label: "Slack", icon: "chatbubbles" }, + email: { label: "Email", icon: "mail" }, + meeting: { label: "Meeting", icon: "videocam" }, + support: { label: "Support", icon: "headset" }, + internal: { label: "Internal", icon: "business" }, + portal: { label: "Portal", icon: "globe" }, + portal_idea: { label: "Portal", icon: "globe" }, + shared_link: { label: "Link", icon: "link" }, + idea: { label: "Idea", icon: "bulb" }, +}; + +function FeedbackCard({ item }: { item: FeedbackEntry }) { + const status = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.open; + const source = SOURCE_CONFIG[item.source] ?? SOURCE_CONFIG.user; + const daysAgo = Math.floor( + (Date.now() - new Date(item.createdAt).getTime()) / 86_400_000 + ); + const timeLabel = daysAgo === 0 ? "Today" : daysAgo === 1 ? "Yesterday" : `${daysAgo}d ago`; + + return ( + + + + + {source.label} + {item.authorName && ( + · {item.authorName} + )} + + + {item.voteCount > 0 && ( + + + {item.voteCount} + + )} + {timeLabel} + + + + {item.title} + + {item.body && ( + {item.body} + )} + + + + {status.label} + + {item.rating != null && ( + + + {item.rating}/5 + + )} + {item.groupName && ( + {item.groupName} + )} + + + ); +} + +export default function FeedbackScreen() { + const globalProject = useProjectStore((s) => s.selected); + const [activeTab, setActiveTab] = useState("internal"); + + const activeProjectId = globalProject?.id ?? ""; + + const { data, isLoading, isRefetching, refetch } = useFeedback(activeProjectId); + const allItems = data?.data ?? []; + const tabFiltered = tabItems(allItems, activeTab); + + // Badge counts per tab (mirrors web "Internal 2", "Portal queue", "Idea") + const counts: Record = { + internal: tabItems(allItems, "internal").length, + portal: tabItems(allItems, "portal").length, + idea: tabItems(allItems, "idea").length, + }; + + return ( + + + + {/* Source tabs — matches web's Internal / Portal queue / Idea */} + + {TABS.map((t) => ( + setActiveTab(t.key)} + activeOpacity={0.7} + > + + {t.label} + + {counts[t.key] > 0 && ( + + + {counts[t.key]} + + + )} + + ))} + + + {!activeProjectId && !isLoading && ( + + + No project selected + + Select a project in the header to see feedback. + + + )} + + {!!activeProjectId && ( + isLoading ? ( + + + + ) : ( + item.id} + contentContainerStyle={tabFiltered.length === 0 ? styles.emptyFill : styles.listContent} + refreshControl={ + + } + renderItem={({ item }) => } + ListEmptyComponent={ + + + + {activeTab === "internal" ? "No internal feedback" : + activeTab === "portal" ? "No portal submissions" : + "No ideas"} + + + {activeTab === "internal" + ? "No feedback has been submitted for this project yet." + : activeTab === "portal" + ? "No feedback from the customer portal yet." + : "No ideas submitted yet."} + + + } + /> + ) + )} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#F5F5F5" }, + tabScroll: { maxHeight: 52, flexShrink: 0 }, + tabRow: { + paddingHorizontal: 16, + paddingVertical: 8, + gap: 8, + flexDirection: "row", + alignItems: "center", + }, + tabPill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 9999, + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#E5E5EA", + }, + tabPillActive: { + backgroundColor: "#1C1C1E", + borderColor: "#1C1C1E", + }, + tabLabel: { fontSize: 14, fontWeight: "500", color: "#1C1C1E" }, + tabLabelActive: { color: "#FFFFFF" }, + tabBadge: { + backgroundColor: "#E5E5EA", + borderRadius: 9999, + paddingHorizontal: 6, + paddingVertical: 1, + minWidth: 20, + alignItems: "center", + }, + tabBadgeActive: { backgroundColor: "#3D3D3F" }, + tabBadgeText: { fontSize: 11, fontWeight: "700", color: "#8E8E93" }, + tabBadgeTextActive: { color: "#FFFFFF" }, + listContent: { paddingHorizontal: 16, paddingBottom: 24, gap: 10 }, + card: { + backgroundColor: "#FFFFFF", + borderRadius: 16, + padding: 16, + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, + }, + cardHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }, + sourceRow: { flexDirection: "row", alignItems: "center", gap: 4, flex: 1 }, + sourceText: { fontSize: 12, color: "#8E8E93", fontWeight: "500" }, + authorText: { fontSize: 12, color: "#8E8E93", flex: 1 }, + metaRight: { flexDirection: "row", alignItems: "center", gap: 8 }, + voteWrap: { flexDirection: "row", alignItems: "center", gap: 2 }, + voteText: { fontSize: 12, color: "#007AFF", fontWeight: "600" }, + timeText: { fontSize: 11, color: "#C7C7CC" }, + cardTitle: { fontSize: 15, fontWeight: "600", color: "#1C1C1E", marginBottom: 6, lineHeight: 22 }, + cardBody: { fontSize: 13, color: "#8E8E93", lineHeight: 18, marginBottom: 12 }, + cardFooter: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }, + statusBadge: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 3 }, + statusText: { fontSize: 12, fontWeight: "600" }, + ratingWrap: { flexDirection: "row", alignItems: "center", gap: 3 }, + ratingText: { fontSize: 12, color: "#8E8E93" }, + groupText: { fontSize: 12, color: "#8E8E93", flex: 1 }, + center: { flex: 1, justifyContent: "center", alignItems: "center", paddingHorizontal: 32 }, + emptyFill: { flex: 1 }, + emptyTitle: { fontSize: 18, fontWeight: "600", color: "#1C1C1E", marginTop: 12 }, + emptySubtitle: { fontSize: 13, color: "#8E8E93", textAlign: "center", marginTop: 4 }, +}); diff --git a/app/(tabs)/goal.tsx b/app/(tabs)/goal.tsx new file mode 100644 index 0000000..bd99375 --- /dev/null +++ b/app/(tabs)/goal.tsx @@ -0,0 +1,424 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +import { useState } from "react"; +import { + View, Text, FlatList, TouchableOpacity, + ActivityIndicator, RefreshControl, StyleSheet, + Modal, TextInput, Alert, KeyboardAvoidingView, Platform, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useRouter } from "expo-router"; +import { useGoals, useProjects, useCreateGoal, type EngineGoal } from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; +import { PageHeader } from "@/src/components/page-header"; + +const STATUS_CONFIG: Record = { + open: { label: "Open", color: "#8E8E93", bg: "#F5F5F5" }, + in_progress: { label: "In Progress", color: "#007AFF", bg: "#EBF4FF" }, + accepted: { label: "Accepted", color: "#4CD964", bg: "#E8F8EB" }, + cancelled: { label: "Cancelled", color: "#FF3B30", bg: "#FFF0EF" }, +}; + +const PRIORITY_COLOR: Record = { + urgent: "#FF3B30", + high: "#FF9500", + medium: "#FFCC00", + low: "#C7C7CC", +}; + +const TYPE_CONFIG: Record["name"] }> = { + feature: { label: "Feature", color: "#007AFF", icon: "sparkles" }, + bug: { label: "Bug", color: "#FF3B30", icon: "bug" }, + chore: { label: "Chore", color: "#8E8E93", icon: "construct" }, +}; + +// Segmented lanes matching the web's below-lg goals view (#1939 order): +// Queue (fleet working) | Ready (awaiting accept) | Blocked (needs you). +// Default selection stays "Blocked" — the needs-you lane. +const LANES = ["Queue", "Ready", "Blocked"] as const; +type Lane = (typeof LANES)[number]; + +function laneOf(goal: EngineGoal): Lane | null { + if (goal.status === "cancelled") return null; + if (goal.mergeBlockedReasonKind != null || goal.pausedAt != null) return "Blocked"; + if (goal.status === "accepted") return "Ready"; + return "Queue"; // open / in_progress, unblocked +} + +const PR_STATE_CONFIG: Record = { + open: { label: "PR open", color: "#007AFF", bg: "#EBF4FF" }, + draft: { label: "PR draft", color: "#8E8E93", bg: "#F5F5F5" }, + ready_for_review: { label: "PR review", color: "#FF9500", bg: "#FFF3E0" }, + merged: { label: "PR merged", color: "#2F855A", bg: "#E8F8EB" }, +}; + +function GoalCard({ goal, onPress }: { goal: EngineGoal; onPress: () => void }) { + const status = STATUS_CONFIG[goal.status] ?? STATUS_CONFIG.open; + const type = TYPE_CONFIG[goal.type] ?? TYPE_CONFIG.chore; + const priorityColor = PRIORITY_COLOR[goal.priority] ?? "#C7C7CC"; + const pr = PR_STATE_CONFIG[goal.prState]; + + return ( + + {/* Top row: type + number + priority dot */} + + + + {type.label} + + {goal.displayNumber != null && ( + G#{goal.displayNumber} + )} + + + + + {goal.title} + + {/* Status + PR + release + target date */} + + + + {goal.pausedAt ? "Paused" : status.label} + + + {pr && ( + + {pr.label} + + )} + {goal.release && ( + {goal.release.name} + )} + {goal.targetDate && ( + + + {" "}{new Date(goal.targetDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + + )} + + + ); +} + +export default function GoalScreen() { + const router = useRouter(); + const [lane, setLane] = useState("Blocked"); + const [createOpen, setCreateOpen] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const createGoal = useCreateGoal(); + const pinnedProject = useProjectStore((s) => s.selected); + const { data: projectsData } = useProjects(); + + // The goals API is project-scoped (projectId required). Use the pinned + // project from the header switcher, falling back to the user's first project. + const activeProject = pinnedProject ?? projectsData?.data[0] ?? null; + + const { data, isLoading, isRefetching, refetch } = useGoals(activeProject?.id); + + const allGoals = data?.data ?? []; + const laneCounts: Record = { Blocked: 0, Ready: 0, Queue: 0 }; + for (const g of allGoals) { + const l = laneOf(g); + if (l) laneCounts[l]++; + } + const goals = allGoals.filter((g) => laneOf(g) === lane); + + return ( + + {/* Header — web PageBar + the round green create FAB */} + setCreateOpen(true)} + activeOpacity={0.85} + accessibilityLabel="Create goal" + > + + + ) : undefined + } + /> + {activeProject && ( + + {activeProject.name} + {!pinnedProject && " · pin a project from the top bar to change"} + + )} + + {/* Segmented lanes: Blocked | Ready | Queue (web mobile parity) */} + + {LANES.map((l) => { + const active = lane === l; + return ( + setLane(l)} + > + {l} + + + {laneCounts[l]} + + + + ); + })} + + + {isLoading ? ( + + + + ) : ( + g.id} + contentContainerStyle={goals.length === 0 ? styles.emptyFill : styles.listContent} + refreshControl={ + + } + renderItem={({ item }) => ( + router.push(`/goal/${item.id}` as never)} /> + )} + ListEmptyComponent={ + + + No goals + + {activeProject + ? `No ${lane.toLowerCase()} goals in ${activeProject.name}.` + : "Join a project to see its goals."} + + + } + /> + )} + {/* Create goal sheet */} + setCreateOpen(false)}> + + + + + New goal + + {activeProject ? `In ${activeProject.name}` : ""} + + + + { setCreateOpen(false); setNewTitle(""); }} + > + Cancel + + + createGoal.mutate( + { projectId: activeProject!.id, title: newTitle.trim() }, + { + onSuccess: () => { setCreateOpen(false); setNewTitle(""); }, + onError: (e) => Alert.alert("Couldn't create goal", e.message), + } + ) + } + > + {createGoal.isPending ? ( + + ) : ( + Create + )} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#F5F5F5" }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 12, + }, + heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, + projectLabel: { + fontSize: 12, + color: "#8E8E93", + paddingHorizontal: 16, + marginTop: -8, + marginBottom: 10, + }, + countBadge: { + backgroundColor: "#E5E5EA", + borderRadius: 9999, + paddingHorizontal: 8, + paddingVertical: 2, + }, + countText: { fontSize: 13, fontWeight: "600", color: "#8E8E93" }, + filterRow: { + flexDirection: "row", + paddingHorizontal: 16, + gap: 8, + marginBottom: 12, + }, + createFab: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: "#4CD964", + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOpacity: 0.12, + shadowRadius: 6, + shadowOffset: { width: 0, height: 2 }, + elevation: 4, + }, + modalOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "flex-end" }, + modalCard: { + backgroundColor: "#FFFFFF", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + paddingBottom: 36, + }, + modalHandle: { + width: 36, + height: 4, + backgroundColor: "#E5E5EA", + borderRadius: 2, + alignSelf: "center", + marginBottom: 20, + }, + modalTitle: { fontSize: 18, fontWeight: "700", color: "#1C1C1E", marginBottom: 4 }, + modalSubtitle: { fontSize: 13, color: "#8E8E93", marginBottom: 14 }, + modalInput: { + backgroundColor: "#F5F5F5", + borderRadius: 12, + padding: 14, + fontSize: 15, + color: "#1C1C1E", + minHeight: 90, + textAlignVertical: "top", + marginBottom: 16, + }, + modalActions: { flexDirection: "row", gap: 10 }, + modalCancel: { + flex: 1, + height: 48, + borderRadius: 9999, + backgroundColor: "#E5E5EA", + alignItems: "center", + justifyContent: "center", + }, + modalCancelText: { fontSize: 15, fontWeight: "600", color: "#1C1C1E" }, + modalCreate: { + flex: 1, + height: 48, + borderRadius: 9999, + backgroundColor: "#4CD964", + alignItems: "center", + justifyContent: "center", + }, + modalCreateText: { fontSize: 15, fontWeight: "600", color: "#FFFFFF" }, + lanePill: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + borderRadius: 9999, + paddingVertical: 10, + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#E5E5EA", + }, + lanePillActive: { backgroundColor: "#1C1C1E", borderColor: "#1C1C1E" }, + lanePillText: { fontSize: 14, fontWeight: "600", color: "#696969" }, + lanePillTextActive: { color: "#FFFFFF" }, + laneCount: { + borderRadius: 9999, + paddingHorizontal: 6, + paddingVertical: 1, + backgroundColor: "#F3F4F6", + }, + laneCountActive: { backgroundColor: "rgba(255,255,255,0.2)" }, + laneCountText: { fontSize: 11, fontWeight: "700", color: "#8E8E93" }, + laneCountTextActive: { color: "#FFFFFF" }, + filterPill: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 9999, + backgroundColor: "#E5E5EA", + }, + filterPillActive: { backgroundColor: "#1C1C1E" }, + filterPillText: { fontSize: 13, color: "#1C1C1E", fontWeight: "500" }, + filterPillTextActive: { color: "#FFFFFF" }, + listContent: { paddingHorizontal: 16, paddingBottom: 24, gap: 10 }, + card: { + backgroundColor: "#FFFFFF", + borderRadius: 16, + padding: 16, + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, + }, + cardHeader: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, + typeBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + borderRadius: 6, + paddingHorizontal: 7, + paddingVertical: 3, + }, + typeBadgeText: { fontSize: 11, fontWeight: "600" }, + displayNumber: { fontSize: 12, color: "#8E8E93", fontWeight: "500" }, + priorityDot: { width: 8, height: 8, borderRadius: 4 }, + cardTitle: { fontSize: 15, fontWeight: "600", color: "#1C1C1E", marginBottom: 10, lineHeight: 20 }, + cardMeta: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }, + statusBadge: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 3 }, + statusText: { fontSize: 12, fontWeight: "600" }, + ownerText: { fontSize: 12, color: "#8E8E93", flex: 1 }, + targetDate: { fontSize: 12, color: "#8E8E93" }, + progressSection: { marginTop: 12, flexDirection: "row", alignItems: "center", gap: 8 }, + progressTrack: { + flex: 1, + height: 4, + backgroundColor: "#E5E5EA", + borderRadius: 2, + overflow: "hidden", + }, + progressFill: { height: 4, backgroundColor: "#4CD964", borderRadius: 2 }, + progressLabel: { fontSize: 11, color: "#8E8E93", width: 60, textAlign: "right" }, + center: { flex: 1, justifyContent: "center", alignItems: "center", paddingHorizontal: 32 }, + emptyFill: { flex: 1 }, + emptyTitle: { fontSize: 18, fontWeight: "600", color: "#1C1C1E", marginTop: 12 }, + emptySubtitle: { fontSize: 13, color: "#8E8E93", textAlign: "center", marginTop: 4 }, +}); diff --git a/app/(tabs)/inbox.tsx b/app/(tabs)/inbox.tsx deleted file mode 100644 index 739953b..0000000 --- a/app/(tabs)/inbox.tsx +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2026 Interactor, Inc. -// SPDX-License-Identifier: AGPL-3.0-or-later -import { useState } from "react"; -import { - View, Text, FlatList, TouchableOpacity, ActivityIndicator, - RefreshControl, StyleSheet, SafeAreaView, -} from "react-native"; -import { useNotifications, useMarkNotificationRead, useMarkAllRead, type Notification } from "@/src/lib/queries"; - -function NotificationRow({ item, onRead }: { item: Notification; onRead: (id: string) => void }) { - const unread = item.readAt === null; - return ( - { if (unread) onRead(item.id); }} - activeOpacity={0.7} - > - {unread && } - - - {item.title} - - {item.message && ( - {item.message} - )} - - {new Date(item.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })} - - - - ); -} - -export default function InboxScreen() { - const [page, setPage] = useState(1); - const [unreadOnly, setUnreadOnly] = useState(false); - const { data, isLoading, isRefetching, refetch } = useNotifications(page, unreadOnly); - const markRead = useMarkNotificationRead(); - const markAll = useMarkAllRead(); - - const unreadCount = data?.meta.unreadCount ?? 0; - - return ( - - - Inbox - {unreadCount > 0 && ( - markAll.mutate()} disabled={markAll.isPending}> - - {markAll.isPending ? "Marking…" : "Mark all read"} - - - )} - - - - {(["All", "Unread"] as const).map((label) => { - const isActive = label === "Unread" ? unreadOnly : !unreadOnly; - return ( - { setUnreadOnly(label === "Unread"); setPage(1); }} - > - - {label}{label === "Unread" && unreadCount > 0 ? ` (${unreadCount})` : ""} - - - ); - })} - - - {isLoading ? ( - - ) : ( - item.id} - refreshControl={} - renderItem={({ item }) => ( - markRead.mutate(id)} /> - )} - ListEmptyComponent={ - - {unreadOnly ? "All caught up!" : "No notifications yet"} - - } - contentContainerStyle={data?.data.length === 0 ? styles.emptyFill : undefined} - /> - )} - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#F5F5F5" }, - header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 }, - heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, - markAllText: { fontSize: 14, color: "#4CD964", fontWeight: "600" }, - filterRow: { flexDirection: "row", paddingHorizontal: 16, gap: 8, marginBottom: 12 }, - filterChip: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 9999, backgroundColor: "#E5E5EA" }, - filterChipActive: { backgroundColor: "#4CD964" }, - filterChipText: { fontSize: 13, color: "#1C1C1E", fontWeight: "500" }, - filterChipTextActive: { color: "#FFFFFF" }, - row: { flexDirection: "row", alignItems: "flex-start", backgroundColor: "#FFFFFF", paddingHorizontal: 16, paddingVertical: 14, marginHorizontal: 16, marginBottom: 8, borderRadius: 12 }, - rowUnread: { borderLeftWidth: 3, borderLeftColor: "#4CD964" }, - unreadDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#4CD964", marginTop: 4, marginRight: 10 }, - rowContent: { flex: 1 }, - rowTitle: { fontSize: 15, fontWeight: "600", color: "#1C1C1E", marginBottom: 2 }, - rowTitleRead: { fontWeight: "400", color: "#8E8E93" }, - rowMessage: { fontSize: 13, color: "#8E8E93", marginBottom: 4 }, - rowTime: { fontSize: 12, color: "#C7C7CC" }, - center: { flex: 1, justifyContent: "center", alignItems: "center" }, - emptyFill: { flex: 1 }, - emptyText: { fontSize: 16, color: "#8E8E93" }, -}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index ef8e436..6a1e546 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,73 +1,273 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later -import { - View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet, SafeAreaView, -} from "react-native"; -import { useCounts, useTodos, useNotifications } from "@/src/lib/queries"; +/** + * Dashboard — native port of the web dashboard at mobile width: + * PageBar title + the "My Todos" card with a FOCUS NOW hero row and the + * numbered UP NEXT list (priority pill + title + org/project context). + * With a pinned project it adds the phase breakdown, like the web project + * dashboard. + */ +import { View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useTodos, usePhases, type TodoItem, type Phase } from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; +import { PageHeader } from "@/src/components/page-header"; +import { colors } from "@/src/lib/theme"; + +// Priority pills as rendered on the web dashboard's todo list +// (High orange, Medium yellow — distinct from the goals-page palette). +const TODO_PRIORITY: Record = { + urgent: { bg: "#FFE5E3", text: "#C53030", label: "Urgent" }, + high: { bg: "#FFEDD5", text: "#C2410C", label: "High" }, + medium: { bg: "#FEF9C3", text: "#A16207", label: "Medium" }, + low: { bg: "#F3F4F6", text: "#6E6E73", label: "Low" }, +}; + +const KIND_ICON: Record["name"]> = { + task: "checkbox-outline", + approval: "thumbs-up-outline", + decision_ack: "git-commit-outline", +}; + +const PHASE_STATUS: Record = { + completed: { color: "#2F855A", bg: "#E8F8EB", label: "Completed" }, + active: { color: "#007AFF", bg: "#EBF4FF", label: "Active" }, + planned: { color: "#8E8E93", bg: "#F5F5F5", label: "Planned" }, + archived: { color: "#C7C7CC", bg: "#F5F5F5", label: "Archived" }, +}; + +function TodoRow({ item, index, focus }: { item: TodoItem; index: number; focus?: boolean }) { + const priority = "priority" in item ? TODO_PRIORITY[item.priority] : null; + const icon = KIND_ICON[item.kind] ?? "checkbox-outline"; -function StatCard({ label, value, accent }: { label: string; value: number; accent?: string }) { return ( - - {value} - {label} + + #{index} + {priority && ( + + {priority.label} + + )} + + + {item.title} + + + {item.project.name} · {item.organization.name} + ); } -export default function HomeScreen() { - const counts = useCounts(); - const todos = useTodos(); - const notifications = useNotifications(1); - - const isLoading = counts.isLoading || todos.isLoading || notifications.isLoading; - const isRefetching = counts.isRefetching || todos.isRefetching || notifications.isRefetching; +function PhaseRow({ phase }: { phase: Phase }) { + const cfg = PHASE_STATUS[phase.status] ?? PHASE_STATUS.planned; + return ( + + + + {phase.name} + + {phase._count.tasks} tasks · {phase._count.deliverables} deliverables + + + + {cfg.label} + + + ); +} - function refetchAll() { - counts.refetch(); - todos.refetch(); - notifications.refetch(); - } +export default function DashboardScreen() { + const selectedProject = useProjectStore((s) => s.selected); + const todos = useTodos(); + const phases = usePhases(selectedProject?.id); - const totalTodos = counts.data?.globalTodosCount ?? 0; - const unreadCount = notifications.data?.meta.unreadCount ?? 0; - const approvalCount = (todos.data?.data ?? []).filter((t) => t.kind === "approval").length; - const taskCount = (todos.data?.data ?? []).filter((t) => t.kind === "task").length; + const items = todos.data?.data.items ?? []; + const focus = items[0]; + const upNext = items.slice(1); return ( - + } + refreshControl={ + { + todos.refetch(); + if (selectedProject) phases.refetch(); + }} + tintColor={colors.primary} + /> + } + showsVerticalScrollIndicator={false} > - - Home - + - {isLoading ? ( - + {todos.isLoading ? ( + + + ) : ( <> - Overview - - - 0 ? "#FF9500" : undefined} /> - - 0 ? "#4CD964" : undefined} /> + {/* My Todos card */} + + + My Todos + + {items.length} + + + + {items.length === 0 ? ( + + + All caught up! + Nothing is waiting on you. + + ) : ( + <> + {focus && ( + <> + + + FOCUS NOW + + + + )} + {upNext.length > 0 && ( + <> + + UP NEXT · {upNext.length} + + {upNext.map((item, i) => ( + + ))} + + )} + + )} + + {/* Phase breakdown — pinned project only */} + {selectedProject && (phases.data?.data.length ?? 0) > 0 && ( + + + Phases + + {phases.data!.data.length} + + + {phases.data!.data.map((p) => ( + + ))} + + )} )} + ); } const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#F5F5F5" }, - header: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 }, - heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, - sectionLabel: { fontSize: 13, fontWeight: "600", color: "#8E8E93", textTransform: "uppercase", letterSpacing: 0.5, marginHorizontal: 16, marginTop: 16, marginBottom: 8 }, - statGrid: { flexDirection: "row", flexWrap: "wrap", paddingHorizontal: 12, gap: 8 }, - statCard: { width: "47%", backgroundColor: "#FFFFFF", borderRadius: 16, padding: 16, alignItems: "center" }, - statValue: { fontSize: 36, fontWeight: "700", color: "#1C1C1E" }, - statLabel: { fontSize: 13, color: "#8E8E93", marginTop: 4 }, + container: { flex: 1, backgroundColor: colors.background }, center: { paddingTop: 60, alignItems: "center" }, + card: { + marginHorizontal: 16, + marginBottom: 16, + borderRadius: 16, + borderWidth: 1, + borderColor: "#EDEDF0", + backgroundColor: "#FFFFFF", + overflow: "hidden", + }, + cardHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 10, + }, + cardTitle: { fontSize: 22, fontWeight: "700", color: colors.text }, + countBadge: { + backgroundColor: "#F3F4F6", + borderRadius: 9999, + paddingHorizontal: 9, + paddingVertical: 2, + }, + countBadgeText: { fontSize: 13, fontWeight: "600", color: colors.textSecondary }, + focusBand: { + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: colors.primaryLight, + paddingHorizontal: 16, + paddingVertical: 8, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderLight, + }, + focusLabel: { + fontSize: 12, + fontWeight: "700", + color: colors.primaryText, + letterSpacing: 0.8, + }, + upNextBand: { + paddingHorizontal: 16, + paddingVertical: 8, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderLight, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderLight, + }, + upNextLabel: { + fontSize: 12, + fontWeight: "700", + color: colors.textSecondary, + letterSpacing: 0.8, + }, + todoRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderLight, + }, + todoRowFocus: { backgroundColor: "#F2FBF4" }, + todoNumber: { fontSize: 14, fontWeight: "700", color: colors.textSecondary, minWidth: 26 }, + todoNumberFocus: { color: colors.primaryText }, + priorityPill: { borderRadius: 9999, paddingHorizontal: 9, paddingVertical: 3 }, + priorityText: { fontSize: 12, fontWeight: "600" }, + todoTitle: { flex: 1, fontSize: 14, fontWeight: "600", color: colors.text }, + todoContext: { fontSize: 12, color: colors.textSecondary, maxWidth: 110 }, + emptyState: { alignItems: "center", paddingVertical: 32, gap: 4 }, + emptyTitle: { fontSize: 17, fontWeight: "700", color: colors.text, marginTop: 6 }, + emptySubtitle: { fontSize: 13, color: colors.textSecondary }, + phaseRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingHorizontal: 16, + paddingVertical: 11, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderLight, + }, + phaseDot: { width: 8, height: 8, borderRadius: 4 }, + phaseName: { fontSize: 14, fontWeight: "500", color: colors.text }, + phaseMeta: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, + phaseBadge: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 3 }, + phaseBadgeText: { fontSize: 11, fontWeight: "600" }, }); diff --git a/app/(tabs)/me.tsx b/app/(tabs)/me.tsx index b39336d..019ab0f 100644 --- a/app/(tabs)/me.tsx +++ b/app/(tabs)/me.tsx @@ -1,79 +1,326 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Me / settings screen. The "Device alerts" section integrates the phone's + * native alert stack: OS permission state (with enable / open-settings + * actions), the goal-attention push opt-out (engine_attention_push pref on + * the backend), a test alert, and the account's registered devices. + */ +import { useCallback, useState } from "react"; import { - View, Text, TouchableOpacity, StyleSheet, SafeAreaView, Alert, + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, + Switch, + Linking, + ScrollView, + Platform, } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useFocusEffect } from "expo-router"; +import Ionicons from "@expo/vector-icons/Ionicons"; import { useAuthStore } from "@/src/store/auth"; +import { + useDevices, + useDeleteDevice, + usePreferences, + useUpdatePreferences, + ENGINE_ATTENTION_PUSH_PREF_KEY, +} from "@/src/lib/queries"; +import { + getAlertPermission, + requestAlertPermission, + sendTestAlert, + type AlertPermission, +} from "@/src/lib/push"; +import { colors } from "@/src/lib/theme"; + +const PERMISSION_LABEL: Record = { + granted: { label: "On", color: "#2F855A" }, + denied: { label: "Off — enable in Settings", color: "#C53030" }, + undetermined: { label: "Not enabled", color: "#B7791F" }, +}; export default function MeScreen() { const { email, signOut } = useAuthStore(); + const [permission, setPermission] = useState("undetermined"); + const devices = useDevices(); + const deleteDevice = useDeleteDevice(); + const prefs = usePreferences(); + const updatePrefs = useUpdatePreferences(); - function handleSignOut() { - Alert.alert("Sign out", "Sign out of Build on this device?", [ + // Re-read the OS permission whenever the screen gains focus — the user may + // have flipped it in the system Settings app and come back. + useFocusEffect( + useCallback(() => { + void getAlertPermission().then(setPermission); + }, []) + ); + + const username = email?.split("@")[0] ?? ""; + const initial = username[0]?.toUpperCase() ?? "?"; + const permInfo = PERMISSION_LABEL[permission]; + + const notificationPrefs = prefs.data?.data.notificationPrefs ?? {}; + const attentionPushOn = notificationPrefs[ENGINE_ATTENTION_PUSH_PREF_KEY] !== false; + + async function handleEnableAlerts() { + if (permission === "denied") { + // iOS won't re-prompt after a denial — the system Settings app is the + // only place to flip it back on. + await Linking.openSettings(); + return; + } + const result = await requestAlertPermission(); + setPermission(result); + if (result === "granted") { + void devices.refetch(); + } + } + + function handleAttentionToggle(value: boolean) { + updatePrefs.mutate( + { + notificationPrefs: { + ...notificationPrefs, + [ENGINE_ATTENTION_PUSH_PREF_KEY]: value, + }, + }, + { onError: (e) => Alert.alert("Error", e.message) } + ); + } + + async function handleTestAlert() { + if (permission !== "granted") { + Alert.alert("Alerts are off", "Enable device alerts first, then try again."); + return; + } + await sendTestAlert(); + Alert.alert("Test sent", "A device alert will appear in a couple of seconds."); + } + + function handleRemoveDevice(id: string) { + Alert.alert("Remove device", "This device will stop receiving alerts.", [ { text: "Cancel", style: "cancel" }, { - text: "Sign out", + text: "Remove", style: "destructive", - onPress: () => void signOut(), + onPress: () => + deleteDevice.mutate(id, { onError: (e) => Alert.alert("Error", e.message) }), }, ]); } + function handleSignOut() { + Alert.alert("Sign out", "Sign out of Build on this device?", [ + { text: "Cancel", style: "cancel" }, + { text: "Sign out", style: "destructive", onPress: () => void signOut() }, + ]); + } + return ( - - - Me - + + + + Me + - {email && ( - + {/* Profile card */} + - - {email[0]?.toUpperCase() ?? "?"} - + {initial} + + + {username} + {email} - {email} - )} - - Sign out - + {/* Device alerts */} + + Device alerts + + {/* OS permission state */} + + + + Alerts on this {Platform.OS === "ios" ? "iPhone" : "device"} + {permInfo.label} + + {permission !== "granted" && ( + void handleEnableAlerts()}> + + {permission === "denied" ? "Open Settings" : "Enable"} + + + )} + + + + {/* Attention push opt-out (backend pref, default on) */} + + + + Goal attention alerts + When a goal is blocked and needs you + + + + + + {/* Test alert */} + void handleTestAlert()} activeOpacity={0.7}> + + Send test alert + + + + + + {/* Registered devices */} + + Registered devices + + {(devices.data?.data ?? []).length === 0 ? ( + + + + {devices.isLoading + ? "Loading…" + : "No devices registered for alerts yet."} + + + ) : ( + devices.data!.data.map((d, i) => ( + + {i > 0 && } + + + + + {d.platform === "ios" ? "iPhone" : "Android"} + {d.appVersion ? ` · v${d.appVersion}` : ""} + + + Registered {new Date(d.createdAt).toLocaleDateString()} + + + handleRemoveDevice(d.id)} + hitSlop={8} + accessibilityLabel="Remove device" + > + + + + + )) + )} + + + + {/* Sign out */} + + + + Sign out + + + ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#F5F5F5" }, - header: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 }, + scroll: { paddingBottom: 32 }, + header: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }, heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, - card: { + profileCard: { flexDirection: "row", alignItems: "center", - gap: 12, + gap: 14, backgroundColor: "#FFFFFF", marginHorizontal: 16, - marginTop: 16, borderRadius: 16, padding: 16, + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, }, avatarCircle: { - width: 44, - height: 44, - borderRadius: 22, + width: 52, + height: 52, + borderRadius: 26, backgroundColor: "#4CD964", alignItems: "center", justifyContent: "center", }, - avatarInitial: { fontSize: 20, fontWeight: "700", color: "#FFFFFF" }, - email: { fontSize: 15, color: "#1C1C1E", fontWeight: "500", flex: 1 }, - signOut: { - marginHorizontal: 16, - marginTop: 24, - backgroundColor: "#FF3B30", - borderRadius: 9999, + avatarInitial: { fontSize: 24, fontWeight: "700", color: "#FFFFFF" }, + profileText: { flex: 1 }, + username: { fontSize: 17, fontWeight: "700", color: "#1C1C1E" }, + email: { fontSize: 13, color: "#8E8E93", marginTop: 2 }, + section: { marginTop: 24, paddingHorizontal: 16 }, + sectionLabel: { + fontSize: 11, + fontWeight: "600", + color: "#8E8E93", + textTransform: "uppercase", + letterSpacing: 0.8, + marginBottom: 8, + }, + menuCard: { + backgroundColor: "#FFFFFF", + borderRadius: 16, + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, + }, + menuRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 16, paddingVertical: 14, + }, + rowText: { flex: 1 }, + menuLabel: { fontSize: 15, color: "#1C1C1E" }, + rowStatus: { fontSize: 12, fontWeight: "600", marginTop: 2 }, + rowHint: { fontSize: 12, color: "#8E8E93", marginTop: 2 }, + enableBtn: { + backgroundColor: "#4CD964", + borderRadius: 9999, + paddingHorizontal: 14, + paddingVertical: 7, + }, + enableBtnText: { color: "#FFFFFF", fontSize: 13, fontWeight: "600" }, + divider: { height: 1, backgroundColor: "#F5F5F5", marginHorizontal: 16 }, + signOutBtn: { + flexDirection: "row", alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: "#FFFFFF", + borderRadius: 9999, + paddingVertical: 14, + borderWidth: 1.5, + borderColor: "#FF3B30", }, - signOutText: { color: "#FFFFFF", fontWeight: "600", fontSize: 16 }, + signOutText: { color: "#FF3B30", fontWeight: "600", fontSize: 16 }, }); diff --git a/app/(tabs)/menu-tab.tsx b/app/(tabs)/menu-tab.tsx new file mode 100644 index 0000000..8c1b428 --- /dev/null +++ b/app/(tabs)/menu-tab.tsx @@ -0,0 +1,12 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Route stub for the Menu tab. The tab's button opens the Menu sheet instead + * of navigating (see the custom tabBarButton in the tab layout), so this + * screen is never actually shown. + */ +import { View } from "react-native"; + +export default function MenuTabScreen() { + return ; +} diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx new file mode 100644 index 0000000..da2e09e --- /dev/null +++ b/app/(tabs)/notifications.tsx @@ -0,0 +1,348 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +import { useState } from "react"; +import { + View, Text, FlatList, TouchableOpacity, ActivityIndicator, + RefreshControl, StyleSheet, Alert, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { + useNotifications, + useMarkNotificationRead, + useMarkAllRead, + type Notification, +} from "@/src/lib/queries"; + +type Filter = "all" | "unread"; + +function timeAgo(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function NotifIcon({ type }: { type: string }) { + if (type === "task_assigned") + return ; + if (type.startsWith("approval")) + return ; + if (type === "mention") + return ; + if (type === "flag_raised") + return ; + if (type.startsWith("review_request")) + return ; + if (type === "followed_item_updated") + return ; + return ; +} + +function NotifRow({ + item, + onRead, + pending, +}: { + item: Notification; + onRead: (id: string) => void; + pending: boolean; +}) { + const isUnread = !item.readAt; + return ( + { if (isUnread && !pending) onRead(item.id); }} + activeOpacity={0.75} + > + {/* Icon */} + + + + + {/* Content */} + + + + {item.title} + + {isUnread && } + + {item.message && ( + {item.message} + )} + {timeAgo(item.createdAt)} + + + {pending && ( + + )} + + ); +} + +export default function NotificationsScreen() { + const [filter, setFilter] = useState("all"); + const [page, setPage] = useState(1); + + const { data, isLoading, isRefetching, refetch } = useNotifications( + page, + filter === "unread" + ); + const markRead = useMarkNotificationRead(); + const markAll = useMarkAllRead(); + + const notifications = data?.data ?? []; + const total = data?.meta.total ?? 0; + const unreadCount = data?.meta.unreadCount ?? 0; + const totalPages = Math.ceil(total / (data?.meta.perPage ?? 20)); + + function handleMarkAll() { + Alert.alert("Mark all as read", "Mark all notifications as read?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Mark all", + onPress: () => + markAll.mutate(undefined, { + onError: () => Alert.alert("Error", "Could not mark notifications as read."), + }), + }, + ]); + } + + function switchFilter(f: Filter) { + setFilter(f); + setPage(1); + } + + return ( + + {/* Header */} + + + Notifications + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + + {unreadCount > 0 && ( + + {markAll.isPending ? ( + + ) : ( + + )} + + )} + + + {/* Filter pills */} + + switchFilter("all")} + > + All + + switchFilter("unread")} + > + + Unread{unreadCount > 0 ? ` (${unreadCount})` : ""} + + + + + {isLoading ? ( + + + + ) : ( + n.id} + contentContainerStyle={ + notifications.length === 0 ? styles.emptyFill : styles.listContent + } + refreshControl={ + { setPage(1); refetch(); }} tintColor="#4CD964" /> + } + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + markRead.mutate(id)} + pending={markRead.isPending && (markRead.variables === item.id)} + /> + )} + ListEmptyComponent={ + + + + {filter === "unread" ? "All caught up!" : "No notifications"} + + + {filter === "unread" + ? "You have no unread notifications." + : "You haven't received any notifications yet."} + + + } + ListFooterComponent={ + totalPages > 1 ? ( + + setPage((p) => Math.max(1, p - 1))} + disabled={page === 1} + > + + + + {page} / {totalPages} + + = totalPages && styles.pagBtnDisabled]} + onPress={() => setPage((p) => Math.min(totalPages, p + 1))} + disabled={page >= totalPages} + > + = totalPages ? "#C7C7CC" : "#1C1C1E"} + /> + + + ) : null + } + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#F5F5F5" }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 12, + }, + headerLeft: { flexDirection: "row", alignItems: "center", gap: 8 }, + heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, + badge: { + backgroundColor: "#4CD964", + borderRadius: 9999, + paddingHorizontal: 7, + paddingVertical: 2, + minWidth: 22, + alignItems: "center", + }, + badgeText: { fontSize: 12, fontWeight: "700", color: "#FFFFFF" }, + markAllBtn: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "#E8F8EB", + alignItems: "center", + justifyContent: "center", + }, + filterRow: { + flexDirection: "row", + paddingHorizontal: 16, + gap: 8, + marginBottom: 12, + }, + pill: { + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 9999, + backgroundColor: "#E5E5EA", + }, + pillActive: { backgroundColor: "#1C1C1E" }, + pillText: { fontSize: 13, fontWeight: "500", color: "#1C1C1E" }, + pillTextActive: { color: "#FFFFFF" }, + listContent: { paddingBottom: 24 }, + row: { + flexDirection: "row", + alignItems: "flex-start", + backgroundColor: "#FFFFFF", + paddingHorizontal: 16, + paddingVertical: 14, + gap: 12, + }, + rowUnread: { backgroundColor: "#FAFFFE" }, + iconWrap: { + width: 36, + height: 36, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + marginTop: 1, + }, + iconWrapUnread: { backgroundColor: "#E8F8EB" }, + iconWrapRead: { backgroundColor: "#F5F5F5" }, + rowContent: { flex: 1 }, + rowTop: { flexDirection: "row", alignItems: "flex-start", gap: 6 }, + rowTitle: { flex: 1, fontSize: 14, color: "#1C1C1E", lineHeight: 20 }, + rowTitleUnread: { fontWeight: "600" }, + unreadDot: { + width: 7, + height: 7, + borderRadius: 3.5, + backgroundColor: "#4CD964", + marginTop: 6, + flexShrink: 0, + }, + rowMessage: { fontSize: 13, color: "#8E8E93", marginTop: 3, lineHeight: 18 }, + rowTime: { fontSize: 11, color: "#C7C7CC", marginTop: 5 }, + spinner: { alignSelf: "center" }, + separator: { height: 1, backgroundColor: "#F5F5F5" }, + center: { flex: 1, justifyContent: "center", alignItems: "center", paddingHorizontal: 32 }, + emptyFill: { flex: 1 }, + emptyTitle: { fontSize: 18, fontWeight: "600", color: "#1C1C1E", marginTop: 12 }, + emptySubtitle: { fontSize: 13, color: "#8E8E93", textAlign: "center", marginTop: 4 }, + pagRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 16, + paddingVertical: 16, + }, + pagBtn: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "#FFFFFF", + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 3, + elevation: 1, + }, + pagBtnDisabled: { backgroundColor: "#F5F5F5" }, + pagLabel: { fontSize: 13, color: "#8E8E93", fontWeight: "500" }, +}); diff --git a/app/(tabs)/tasks.tsx b/app/(tabs)/tasks.tsx deleted file mode 100644 index 0086177..0000000 --- a/app/(tabs)/tasks.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2026 Interactor, Inc. -// SPDX-License-Identifier: AGPL-3.0-or-later -import { - View, Text, FlatList, ActivityIndicator, - RefreshControl, StyleSheet, SafeAreaView, -} from "react-native"; -import { useTodos, type TodoItem } from "@/src/lib/queries"; - -const PRIORITY_COLOR: Record = { - urgent: "#FF3B30", - high: "#FF9500", - medium: "#4CD964", - low: "#C7C7CC", -}; - -const STATUS_LABEL: Record = { - todo: "To do", - in_progress: "In progress", - in_review: "In review", - blocked: "Blocked", - done: "Done", -}; - -function TaskRow({ item }: { item: Extract }) { - return ( - - - - {item.title} - - {item.project.name} - - {STATUS_LABEL[item.status] ?? item.status} - - - {item.dueDate && ( - - Due {new Date(item.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })} - - )} - - - ); -} - -export default function TasksScreen() { - const { data, isLoading, isRefetching, refetch } = useTodos(); - - const tasks = (data?.data ?? []).filter( - (t): t is Extract => t.kind === "task" - ); - - return ( - - - My Tasks - {tasks.length > 0 && ( - {tasks.length} - )} - - - {isLoading ? ( - - ) : ( - item.id} - refreshControl={} - renderItem={({ item }) => } - ListEmptyComponent={ - - No tasks assigned to you - - } - contentContainerStyle={tasks.length === 0 ? styles.emptyFill : undefined} - /> - )} - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#F5F5F5" }, - header: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }, - heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, - count: { fontSize: 14, fontWeight: "600", color: "#8E8E93", backgroundColor: "#E5E5EA", paddingHorizontal: 8, paddingVertical: 2, borderRadius: 9999 }, - row: { flexDirection: "row", backgroundColor: "#FFFFFF", marginHorizontal: 16, marginBottom: 8, borderRadius: 12, overflow: "hidden" }, - priorityBar: { width: 4 }, - rowContent: { flex: 1, padding: 14 }, - rowTitle: { fontSize: 15, fontWeight: "600", color: "#1C1C1E", marginBottom: 6 }, - rowMeta: { flexDirection: "row", alignItems: "center", gap: 8 }, - metaProject: { flex: 1, fontSize: 12, color: "#8E8E93" }, - statusBadge: { backgroundColor: "#F5F5F5", borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 }, - statusText: { fontSize: 11, color: "#8E8E93", fontWeight: "500" }, - dueDate: { fontSize: 12, color: "#8E8E93", marginTop: 4 }, - dueDateOverdue: { color: "#FF3B30", fontWeight: "600" }, - center: { flex: 1, justifyContent: "center", alignItems: "center" }, - emptyFill: { flex: 1 }, - emptyText: { fontSize: 16, color: "#8E8E93" }, -}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 250e80b..74a13ba 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,9 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { useEffect, useRef } from "react"; import { Stack, useRouter, useSegments } from "expo-router"; +import * as Notifications from "expo-notifications"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useAuthStore } from "@/src/store/auth"; -import { registerPushToken } from "@/src/lib/push"; +import { registerPushToken, syncAppBadge } from "@/src/lib/push"; +import { useNotifications } from "@/src/lib/queries"; import { useNotificationSSE } from "@/src/lib/sse"; const queryClient = new QueryClient({ @@ -21,7 +23,7 @@ const queryClient = new QueryClient({ * push registration, and SSE foreground connection. */ function AuthGuard({ children }: { children: React.ReactNode }) { - const { token, userId, hydrated } = useAuthStore(); + const { mobileToken, userId, hydrated } = useAuthStore(); const segments = useSegments(); const router = useRouter(); const prevToken = useRef(null); @@ -30,24 +32,55 @@ function AuthGuard({ children }: { children: React.ReactNode }) { useEffect(() => { if (!hydrated) return; const inAuthGroup = segments[0] === "(auth)"; - if (!token && !inAuthGroup) { + if (!mobileToken && !inAuthGroup) { router.replace("/(auth)/login"); - } else if (token && inAuthGroup) { - router.replace("/(tabs)/"); + } else if (mobileToken && inAuthGroup) { + router.replace("/(tabs)" as never); } - }, [token, hydrated, segments, router]); + }, [mobileToken, hydrated, segments, router]); // Register push token when the user first signs in (token transitions null → value) useEffect(() => { - if (token && !prevToken.current) { + if (mobileToken && !prevToken.current) { void registerPushToken(); } - prevToken.current = token; - }, [token]); + prevToken.current = mobileToken; + }, [mobileToken]); // SSE foreground connection (noop when token/userId are null) useNotificationSSE(); + // Mirror the unread count onto the app icon badge (native alert surface). + // SSE invalidations keep the query fresh, so the badge tracks live state; + // cleared on sign-out. + const { data: notifData } = useNotifications(1); + const unread = notifData?.meta.unreadCount; + useEffect(() => { + if (!mobileToken) { + void syncAppBadge(0); + } else if (unread !== undefined) { + void syncAppBadge(unread); + } + }, [mobileToken, unread]); + + // Push-tap deep links. Backend attention pushes carry + // data: { type: "engine_attention", goalId?, pauseId?, remediationId? } — + // route goal-scoped taps to the goal detail, everything else to Attention. + useEffect(() => { + const sub = Notifications.addNotificationResponseReceivedListener((response) => { + const data = response.notification.request.content.data as + | { type?: string; goalId?: string } + | undefined; + if (!useAuthStore.getState().mobileToken) return; + if (data?.goalId) { + router.push(`/goal/${data.goalId}` as never); + } else { + router.push("/(tabs)/approvals" as never); + } + }); + return () => sub.remove(); + }, [router]); + return <>{children}; } diff --git a/app/goal/[goalId].tsx b/app/goal/[goalId].tsx new file mode 100644 index 0000000..c0c8fc1 --- /dev/null +++ b/app/goal/[goalId].tsx @@ -0,0 +1,292 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Goal detail — native counterpart of the web's engine goal detail page. + * Data: GET /api/v1/me/engine/goals/[goalId] (goal + completion rollup + + * review readiness + deploy gate + attachments). + */ +import { + View, + Text, + ScrollView, + TouchableOpacity, + ActivityIndicator, + RefreshControl, + StyleSheet, + Linking, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useGoalDetail } from "@/src/lib/queries"; +import { colors, priorityColors, radii } from "@/src/lib/theme"; + +const STATUS_CONFIG: Record = { + open: { label: "Open", color: "#8E8E93", bg: "#F5F5F5" }, + in_progress: { label: "In Progress", color: "#007AFF", bg: "#EBF4FF" }, + accepted: { label: "Accepted", color: "#2F855A", bg: "#E8F8EB" }, + cancelled: { label: "Cancelled", color: "#FF3B30", bg: "#FFF0EF" }, +}; + +const CI_CONFIG: Record = { + passed: { label: "CI passed", color: "#2F855A" }, + pending: { label: "CI pending", color: "#FF9500" }, + failed: { label: "CI failed", color: "#FF3B30" }, + error: { label: "CI error", color: "#FF3B30" }, +}; + +function Pill({ label, color, bg }: { label: string; color: string; bg: string }) { + return ( + + {label} + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +export default function GoalDetailScreen() { + const { goalId } = useLocalSearchParams<{ goalId: string }>(); + const router = useRouter(); + const { data, isLoading, isRefetching, refetch, error } = useGoalDetail(goalId); + + const detail = data?.data; + const goal = detail?.goal; + const status = goal ? (STATUS_CONFIG[goal.status] ?? STATUS_CONFIG.open) : null; + const priority = goal ? (priorityColors[goal.priority] ?? priorityColors.low) : null; + const ci = detail ? CI_CONFIG[detail.deployGate.ciState] : null; + const completion = detail?.completion; + const progress = + completion && completion.totalTasks > 0 + ? completion.doneTasks / completion.totalTasks + : 0; + + return ( + + {/* Back header */} + + router.back()} hitSlop={8} style={styles.backButton}> + + Goals + + {goal?.displayNumber != null && ( + G#{goal.displayNumber} + )} + + + {isLoading ? ( + + + + ) : error || !goal ? ( + + + Couldn't load this goal. + + ) : ( + + } + > + {/* Title + badges */} + {goal.title} + + {status && } + {priority && } + + {goal.autoDeliver && } + + + {goal.pausedAt && goal.pauseReason && ( + + + {goal.pauseReason} + + )} + + {/* Description */} + {goal.description && ( +
+ {goal.description} +
+ )} + + {/* Progress */} + {completion && completion.totalTasks > 0 && ( +
+ + + + + {completion.doneTasks}/{completion.totalTasks} tasks done + {completion.openTasks > 0 ? ` · ${completion.openTasks} open` : ""} + +
+ )} + + {/* Delivery */} +
+ + + + {detail!.deployGate.mergeable ? "Ready to merge" : "Not yet mergeable"} + + + {ci && ( + + + {ci.label} + + )} + {detail!.deployGate.unresolvedMustFix > 0 && ( + + + + {detail!.deployGate.unresolvedMustFix} unresolved must-fix finding + {detail!.deployGate.unresolvedMustFix === 1 ? "" : "s"} + + + )} + {goal.prUrl && ( + void Linking.openURL(goal.prUrl!)} + activeOpacity={0.8} + > + + + PR #{goal.prNumber ?? ""} · {goal.prState.replace(/_/g, " ")} + + + )} +
+ + {/* Meta */} +
+ {goal.release && } + {goal.targetDate && ( + + )} + + +
+ + {/* Attachments */} + {detail!.attachments.length > 0 && ( +
+ {detail!.attachments.map((a) => ( + + + {a.filename} + {Math.max(1, Math.round(a.sizeBytes / 1024))} KB + + ))} +
+ )} +
+ )} +
+ ); +} + +function MetaRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.surface }, + navBar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 10, + paddingVertical: 8, + backgroundColor: colors.background, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + backButton: { flexDirection: "row", alignItems: "center", gap: 2 }, + backText: { fontSize: 16, color: colors.text }, + navNumber: { fontSize: 13, fontWeight: "600", color: colors.textSecondary, paddingRight: 8 }, + center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 8 }, + errorText: { fontSize: 14, color: colors.textSecondary }, + content: { padding: 16, paddingBottom: 32 }, + title: { fontSize: 22, fontWeight: "700", color: colors.text, marginBottom: 10, lineHeight: 28 }, + pillRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginBottom: 16 }, + pill: { borderRadius: radii.pill, paddingHorizontal: 10, paddingVertical: 4 }, + pillText: { fontSize: 12, fontWeight: "600", textTransform: "capitalize" }, + pausedBox: { + flexDirection: "row", + gap: 8, + backgroundColor: colors.amberLight, + borderRadius: radii.md, + padding: 12, + marginBottom: 16, + }, + pausedText: { flex: 1, fontSize: 13, color: "#B7791F", lineHeight: 18 }, + section: { + backgroundColor: colors.background, + borderRadius: radii.lg, + padding: 14, + marginBottom: 12, + }, + sectionLabel: { + fontSize: 11, + fontWeight: "600", + color: colors.textSecondary, + textTransform: "uppercase", + letterSpacing: 0.6, + marginBottom: 10, + }, + description: { fontSize: 14, color: colors.text, lineHeight: 21 }, + progressTrack: { + height: 6, + borderRadius: 3, + backgroundColor: colors.surfaceSecondary, + overflow: "hidden", + marginBottom: 8, + }, + progressFill: { height: 6, borderRadius: 3, backgroundColor: colors.primary }, + progressLabel: { fontSize: 12, color: colors.textSecondary }, + deliveryRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 4 }, + deliveryText: { fontSize: 13, color: colors.text }, + prButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + height: 40, + borderRadius: radii.pill, + backgroundColor: colors.text, + marginTop: 10, + }, + prButtonText: { color: "#FFFFFF", fontSize: 13, fontWeight: "600", textTransform: "capitalize" }, + metaRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 6, + }, + metaLabel: { fontSize: 13, color: colors.textSecondary }, + metaValue: { fontSize: 13, color: colors.text, fontWeight: "500" }, + attachmentRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 6 }, + attachmentName: { flex: 1, fontSize: 13, color: colors.text }, + attachmentSize: { fontSize: 12, color: colors.textSecondary }, +}); diff --git a/assets/interactor-icon-green.png b/assets/interactor-icon-green.png new file mode 100644 index 0000000..8037e70 Binary files /dev/null and b/assets/interactor-icon-green.png differ diff --git a/assets/interactor-logo-green.png b/assets/interactor-logo-green.png new file mode 100644 index 0000000..a0733d9 Binary files /dev/null and b/assets/interactor-logo-green.png differ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..17131ee --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: ["react-native-reanimated/plugin"], + }; +}; diff --git a/package-lock.json b/package-lock.json index fe11951..9729287 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "product-manager-mobile", "version": "1.0.0", "dependencies": { + "@expo/vector-icons": "^15.0.2", "@tanstack/react-query": "^5.101.1", "expo": "~56.0.12", "expo-constants": "~56.0.18", @@ -17,6 +18,7 @@ "expo-secure-store": "~56.0.4", "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", + "expo-web-browser": "~56.0.5", "react": "19.2.3", "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", @@ -24,11 +26,13 @@ "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-sse": "^1.2.1", + "react-native-worklets": "0.8.3", "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { "@types/react": "~19.2.2", + "babel-preset-expo": "^56.0.15", "typescript": "~6.0.3" }, "engines": { @@ -560,6 +564,21 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-async-generator-functions": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", @@ -978,6 +997,36 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", @@ -1452,6 +1501,17 @@ "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", "license": "MIT" }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/xcpretty": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", @@ -2547,6 +2607,73 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/babel-preset-expo": { + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.15.tgz", + "integrity": "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-plugin-codegen": "0.85.3", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.33.3", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^56.0.18", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, "node_modules/badgin": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", @@ -3621,6 +3748,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-56.0.5.tgz", + "integrity": "sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "56.1.16", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz", @@ -3797,73 +3934,6 @@ "node": ">= 0.6" } }, - "node_modules/expo/node_modules/babel-preset-expo": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.15.tgz", - "integrity": "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==", - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.20.5", - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.28.6", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-plugin-codegen": "0.85.3", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.33.3", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "expo-widgets": "^56.0.18", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - }, - "expo-widgets": { - "optional": true - } - } - }, "node_modules/expo/node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -6072,6 +6142,31 @@ "integrity": "sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==", "license": "MIT" }, + "node_modules/react-native-worklets": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", + "integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.3" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.81 - 0.85" + } + }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", diff --git a/package.json b/package.json index 8a38880..36d73b2 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "main": "index.ts", "dependencies": { + "@expo/vector-icons": "^15.0.2", "@tanstack/react-query": "^5.101.1", "expo": "~56.0.12", "expo-constants": "~56.0.18", @@ -12,6 +13,7 @@ "expo-secure-store": "~56.0.4", "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", + "expo-web-browser": "~56.0.5", "react": "19.2.3", "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", @@ -19,17 +21,19 @@ "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-sse": "^1.2.1", + "react-native-worklets": "0.8.3", "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { "@types/react": "~19.2.2", + "babel-preset-expo": "^56.0.15", "typescript": "~6.0.3" }, "scripts": { - "start": "expo start --host lan", - "android": "expo start --android", - "ios": "expo start --ios", + "start": "expo start --host lan --dev-client", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "typecheck": "tsc --noEmit", "lint": "eslint .", diff --git a/src/components/ai-prompt-sheet.tsx b/src/components/ai-prompt-sheet.tsx new file mode 100644 index 0000000..08fe373 --- /dev/null +++ b/src/components/ai-prompt-sheet.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * "Ask the Senior PM" bottom sheet — native port of the AI prompt dialog the + * web app opens from the center tab of the mobile bottom nav + * (product-manager/src/components/layout/mobile-bottom-nav.tsx). + * Submitting routes to the AI screen with the prompt as a param. + */ +import { useState } from "react"; +import { + Modal, + View, + Text, + TextInput, + TouchableOpacity, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { colors, radii } from "@/src/lib/theme"; + +interface Props { + visible: boolean; + onClose: () => void; + onSubmit: (prompt: string) => void; +} + +export function AiPromptSheet({ visible, onClose, onSubmit }: Props) { + const insets = useSafeAreaInsets(); + const [text, setText] = useState(""); + + function handleSend() { + const prompt = text.trim(); + if (!prompt) return; + setText(""); + onSubmit(prompt); + } + + return ( + + {/* Overlay — 40% black like web's bg-black/40 */} + + + + + + + + Ask the Senior PM + + + + + + + + + + + + + + Opens the AI chat with your prompt + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.4)", + }, + sheetWrap: { flex: 1, justifyContent: "flex-end" }, + sheet: { + backgroundColor: colors.background, + borderTopLeftRadius: radii.xl, + borderTopRightRadius: radii.xl, + paddingHorizontal: 16, + paddingTop: 14, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 12, + }, + headerLeft: { flexDirection: "row", alignItems: "center", gap: 6 }, + title: { fontSize: 13, fontWeight: "600", color: colors.text }, + inputRow: { flexDirection: "row", alignItems: "flex-end", gap: 10 }, + input: { + flex: 1, + minHeight: 44, + maxHeight: 160, + borderRadius: radii.md, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: colors.text, + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primary, + alignItems: "center", + justifyContent: "center", + }, + sendDisabled: { opacity: 0.4 }, + helper: { + marginTop: 8, + fontSize: 11, + color: colors.textSecondary, + }, +}); diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx new file mode 100644 index 0000000..1594585 --- /dev/null +++ b/src/components/app-header.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Global top bar — native port of the web GNB below lg: + * centered two-line org/project switcher, fleet button with a live status + * dot (red alert / green working / gray idle) and the notifications bell on + * the right. The user menu lives in the Menu sheet (bottom nav), not here. + */ +import { useState } from "react"; +import { View, Text, TouchableOpacity, StyleSheet, Linking } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useProjectStore } from "@/src/store/project"; +import { useNotifications, useFleetMachines } from "@/src/lib/queries"; +import { ProjectSwitcherSheet } from "@/src/components/project-switcher-sheet"; +import { colors } from "@/src/lib/theme"; + +export function AppHeader({ onOpenFleet }: { onOpenFleet?: () => void }) { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { selected } = useProjectStore(); + const { data } = useNotifications(1); + const machines = useFleetMachines(true); + const unread = data?.meta.unreadCount ?? 0; + const [switcherOpen, setSwitcherOpen] = useState(false); + + // Fleet dot: red = machine needs attention, green = working, gray = idle. + const machineList = (machines.data?.data ?? []).filter((m) => m.status); + const alert = machineList.some( + (m) => m.status === "stuck" || m.status === "error" || m.status === "offline" + ); + const working = machineList.filter((m) => m.status === "working").length; + const dotColor = alert ? colors.danger : working > 0 ? colors.primary : "#D1D1D6"; + + return ( + + + {/* Left: AI chat + service launcher (web GNB below-lg parity) */} + + router.push("/(tabs)/ai" as never)} + hitSlop={8} + accessibilityLabel="AI chat" + > + + + void Linking.openURL("https://interactor.com")} + hitSlop={8} + accessibilityLabel="Interactor services" + > + + + + + {/* Center: two-line org/project switcher (web GNB parity) */} + setSwitcherOpen(true)} + activeOpacity={0.7} + accessibilityLabel="Switch project" + > + + + {selected ? selected.orgName : "Interactor"} + + + + {selected ? selected.name : "All projects"} + + + + + + + + {/* Fleet button + live status dot */} + {onOpenFleet && ( + + + + + )} + + {/* Notifications bell */} + router.push("/(tabs)/notifications" as never)} + accessibilityLabel="Notifications" + hitSlop={8} + > + + {unread > 0 && ( + + {unread > 99 ? "99+" : unread} + + )} + + + + + setSwitcherOpen(false)} /> + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.background, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + bar: { + height: 52, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 14, + gap: 10, + }, + leftIcons: { flexDirection: "row", alignItems: "center", gap: 12 }, + switcher: { flex: 1, alignItems: "center" }, + switcherText: { alignItems: "center", maxWidth: "90%" }, + orgLine: { fontSize: 11, color: colors.textSecondary }, + projectLine: { flexDirection: "row", alignItems: "center", gap: 3 }, + projectName: { fontSize: 15, fontWeight: "700", color: colors.text }, + actions: { flexDirection: "row", alignItems: "center", gap: 12 }, + iconButton: { padding: 2 }, + fleetDot: { + position: "absolute", + top: 0, + right: -1, + width: 9, + height: 9, + borderRadius: 5, + borderWidth: 1.5, + borderColor: colors.background, + }, + badge: { + position: "absolute", + top: -4, + right: -6, + minWidth: 16, + height: 16, + borderRadius: 8, + // Web GNB: the notifications count is GREEN (red is reserved for + // attention/tab badges and the fleet alert dot). + backgroundColor: colors.primary, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 3, + }, + badgeText: { color: "#FFFFFF", fontSize: 9, fontWeight: "700" }, +}); diff --git a/src/components/fleet-sidebar.tsx b/src/components/fleet-sidebar.tsx new file mode 100644 index 0000000..7022475 --- /dev/null +++ b/src/components/fleet-sidebar.tsx @@ -0,0 +1,396 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Fleet sidebar — native port of the web's right-hand fleet panel + * (product-manager/src/components/layout/fleet-sidebar.tsx). + * + * On web this is desktop-only; on native it slides in from the RIGHT edge — + * open it with a left-swipe from the right edge (see FleetEdgeSwipe in the + * tab layout) or the fleet button in the top bar. Swipe right on the panel + * or tap the scrim to close. + * + * Sections mirror the web: machine health dots + working/total count, + * machines needing attention, "Goals being worked on" (fleet queue with the + * exact claim order), and "Awaiting acceptance". + */ +import { useEffect, useRef } from "react"; +import { + Animated, + Dimensions, + PanResponder, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { + useFleetQueue, + useFleetMachines, + type FleetGoalRow, + type FleetMachine, +} from "@/src/lib/queries"; +import { colors, radii } from "@/src/lib/theme"; + +// Web parity: w-[85vw] max-w-[360px] +const PANEL_WIDTH = Math.min(360, Dimensions.get("window").width * 0.85); + +// Status pill colors — identical to the web sidebar's STATUS_PILL map. +const STATUS_PILL: Record = { + running: { label: "Fleet", color: "#2F855A", bg: "#E8F8EB" }, + recovering: { label: "Recovering", color: "#4338CA", bg: "#E0E7FF" }, + blocked: { label: "Blocked", color: "#C53030", bg: "#FDE8E8" }, + waiting_on_you: { label: "Waiting on you", color: "#B7791F", bg: "#FFF1E0" }, + queued: { label: "Queued", color: "#6E6E73", bg: "#F3F4F6" }, + waiting_on_deps: { label: "Waiting on deps", color: "#6E6E73", bg: "#F3F4F6" }, +}; + +// Machine dot colors — identical to the web MachineDot map. +const MACHINE_DOT: Record = { + working: "#4CD964", + idle: "#98989D", + stuck: "#FB923C", + error: "#FF3B30", + offline: "#D1D1D6", +}; + +function MachineDots({ machines }: { machines: FleetMachine[] }) { + const daemonMachines = machines.filter((m) => m.status); + const shown = daemonMachines.slice(0, 8); + const overflow = daemonMachines.length - shown.length; + const working = daemonMachines.filter((m) => m.status === "working").length; + + return ( + + + + {shown.map((m, i) => ( + + ))} + {overflow > 0 && +{overflow}} + + + {working} working · {daemonMachines.length} total + + + ); +} + +function GoalRow({ row, queuePosition }: { row: FleetGoalRow; queuePosition: number | null }) { + const pill = STATUS_PILL[row.status] ?? STATUS_PILL.queued; + const depsLabel = + row.status === "waiting_on_deps" && row.blockedBy.length > 0 + ? `Waiting on G#${row.blockedBy[0].displayNumber ?? "?"}` + : pill.label; + + return ( + + {/* Leading chip: queue position, spinner-ish icon, or status icon */} + + {row.status === "queued" && queuePosition != null ? ( + + {queuePosition} + + ) : ( + + )} + + + + + {row.displayNumber != null && ( + G#{row.displayNumber} + )} + {row.title} + + + {row.project.orgName} • {row.project.name} • {row.progress.done}/{row.progress.total} tasks + {row.prNumber != null ? ` • PR#${row.prNumber}` : ""} + + {row.status === "running" && row.currentTask && ( + + → {row.currentTask.title} + + )} + + + + + {row.status === "waiting_on_deps" ? depsLabel : pill.label} + + + + ); +} + +interface Props { + visible: boolean; + onClose: () => void; +} + +export function FleetSidebar({ visible, onClose }: Props) { + const insets = useSafeAreaInsets(); + const translateX = useRef(new Animated.Value(PANEL_WIDTH)).current; + const queue = useFleetQueue(visible); + const machines = useFleetMachines(visible); + + useEffect(() => { + Animated.timing(translateX, { + toValue: visible ? 0 : PANEL_WIDTH, + duration: 220, + useNativeDriver: true, + }).start(); + }, [visible, translateX]); + + // Swipe right on the open panel to close it (web: |dx| >= 50, vertical + // swipes rejected). + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_e, g) => g.dx > 0 && Math.abs(g.dx) > Math.abs(g.dy), + onPanResponderRelease: (_e, g) => { + if (g.dx >= 50) onClose(); + }, + }) + ).current; + + const goals = queue.data?.data.goals ?? []; + const awaiting = queue.data?.data.awaitingAcceptance ?? []; + const machineList = machines.data?.data ?? []; + const attention = machineList.filter( + (m) => m.status === "stuck" || m.status === "error" || m.status === "offline" + ); + + // Queue positions count only queued rows, in order (matches web numbering). + let queuedIndex = 0; + + if (!visible) { + // Keep mounted for the slide-out animation but drop the scrim immediately. + return null; + } + + return ( + + + + {/* Header */} + + Fleet + + + + + + + {/* Machine health */} + + + {machineList.filter((m) => m.status).length === 0 && !machines.isLoading && ( + + No machines connected + + A machine is a Docker agent that works on your goals. Add one from + Settings → Machines on the web app. + + + )} + + {/* Needs attention */} + {attention.length > 0 && ( + + + + Needs attention + + {attention.length} + + + {attention.slice(0, 3).map((m, i) => ( + + + + {m.name ?? "Machine"} + + {m.status === "offline" + ? "Offline — not reachable." + : m.status === "stuck" + ? "This machine went dark mid-run." + : "Recent failure on this machine."} + + + + ))} + + Fleets self-heal automatically. Still down after 15 min? Check + Settings → Machines. + + + )} + + {/* Goals being worked on */} + + + + Goals being worked on + {goals.length > 0 && ( + + {goals.length} + + )} + + {queue.isLoading ? ( + Loading… + ) : goals.length === 0 ? ( + Nothing in the fleet queue right now. + ) : ( + goals.map((row) => { + const pos = row.status === "queued" ? ++queuedIndex : null; + return ; + }) + )} + + + {/* Awaiting acceptance */} + {awaiting.length > 0 && ( + + + + Awaiting acceptance + + {awaiting.length} + + + {awaiting.map((row) => ( + + ))} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + scrim: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.35)", + }, + panel: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + backgroundColor: colors.background, + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: colors.border, + paddingHorizontal: 14, + shadowColor: "#000", + shadowOffset: { width: -2, height: 0 }, + shadowOpacity: 0.12, + shadowRadius: 10, + elevation: 12, + }, + panelHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingBottom: 10, + }, + panelTitle: { fontSize: 17, fontWeight: "700", color: colors.text }, + machineHeader: { flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 8 }, + dotRow: { flexDirection: "row", alignItems: "center", gap: 4 }, + dot: { width: 8, height: 8, borderRadius: 4 }, + overflowText: { fontSize: 10, color: colors.textSecondary }, + machineCount: { fontSize: 11, color: colors.textSecondary, marginLeft: "auto" }, + setupCard: { + backgroundColor: colors.surface, + borderRadius: radii.md, + padding: 12, + marginTop: 8, + }, + setupTitle: { fontSize: 13, fontWeight: "600", color: colors.text, marginBottom: 4 }, + setupText: { fontSize: 12, color: colors.textSecondary, lineHeight: 17 }, + section: { marginTop: 16 }, + sectionHeader: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 }, + sectionLabel: { fontSize: 11, fontWeight: "600", color: colors.textSecondary, textTransform: "uppercase", letterSpacing: 0.5 }, + countBadge: { + backgroundColor: "#F3F4F6", + borderRadius: radii.pill, + paddingHorizontal: 6, + paddingVertical: 1, + }, + countBadgeText: { fontSize: 10, fontWeight: "700", color: colors.textSecondary }, + attentionCard: { + flexDirection: "row", + gap: 8, + backgroundColor: "#FFF8EB", + borderRadius: radii.md, + padding: 10, + marginBottom: 6, + }, + attentionName: { fontSize: 11, fontWeight: "600", color: colors.text }, + attentionMsg: { fontSize: 11, color: colors.textSecondary, lineHeight: 15 }, + selfHeal: { fontSize: 10, color: colors.textSecondary, marginTop: 2, lineHeight: 14 }, + emptyText: { fontSize: 12, color: colors.textSecondary, paddingVertical: 6 }, + goalRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingVertical: 7, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderLight, + }, + goalChip: { width: 22, alignItems: "center" }, + positionBadge: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: colors.text, + alignItems: "center", + justifyContent: "center", + }, + positionText: { fontSize: 10, fontWeight: "700", color: "#FFFFFF" }, + goalContent: { flex: 1, minWidth: 0 }, + goalTitle: { fontSize: 12, color: colors.text, fontWeight: "500" }, + goalNumber: { color: colors.textSecondary }, + goalMeta: { fontSize: 10, color: colors.textSecondary, marginTop: 1 }, + goalTask: { fontSize: 10, color: colors.textSecondary, marginTop: 1 }, + pill: { borderRadius: radii.pill, paddingHorizontal: 6, paddingVertical: 3, maxWidth: 96 }, + pillText: { fontSize: 9, fontWeight: "600" }, +}); diff --git a/src/components/menu-sheet.tsx b/src/components/menu-sheet.tsx new file mode 100644 index 0000000..bfabb4e --- /dev/null +++ b/src/components/menu-sheet.tsx @@ -0,0 +1,271 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Menu sheet — native port of the web's MobileMenuSheet (opened from the + * bottom nav's Menu tab). Right-side sheet with the profile group on top and + * the project navigation sections below. Sections that only exist on the web + * (Release, Sprint, Meeting…) deep-link into the web app for the pinned + * project so nothing is unreachable from mobile. + */ +import { useEffect, useRef } from "react"; +import { + Animated, + Dimensions, + Linking, + Pressable, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useAuthStore } from "@/src/store/auth"; +import { useProjectStore } from "@/src/store/project"; +import { API_BASE_URL } from "@/src/lib/config"; +import { colors, radii } from "@/src/lib/theme"; + +const SHEET_WIDTH = Math.min(340, Dimensions.get("window").width * 0.85); + +type IoniconName = React.ComponentProps["name"]; + +// Web nav sections (nav-config.tsx) minus the bottom-bar duplicates +// (/goals, /approvals). Overview is native; the rest deep-link to web. +const WEB_SECTIONS: { title: string; items: { label: string; icon: IoniconName; segment: string }[] }[] = [ + { + title: "Customer Voice", + items: [{ label: "Log", icon: "reader-outline", segment: "log" }], + }, + { + title: "Scope", + items: [ + { label: "Release", icon: "rocket-outline", segment: "releases" }, + { label: "Sprint", icon: "repeat-outline", segment: "sprints" }, + ], + }, + { + title: "Collaborate", + items: [ + { label: "Meeting", icon: "videocam-outline", segment: "meetings" }, + { label: "Comment", icon: "chatbox-outline", segment: "comments" }, + { label: "Decision", icon: "git-commit-outline", segment: "decisions" }, + ], + }, + { + title: "Audit", + items: [ + { label: "History", icon: "time-outline", segment: "history" }, + { label: "Setting", icon: "settings-outline", segment: "settings" }, + ], + }, +]; + +function MenuRow({ + icon, + label, + onPress, + danger, + external, +}: { + icon: IoniconName; + label: string; + onPress: () => void; + danger?: boolean; + external?: boolean; +}) { + return ( + + + {label} + {external && } + + ); +} + +interface Props { + visible: boolean; + onClose: () => void; +} + +export function MenuSheet({ visible, onClose }: Props) { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { email, signOut } = useAuthStore(); + const project = useProjectStore((s) => s.selected); + const translateX = useRef(new Animated.Value(SHEET_WIDTH)).current; + + useEffect(() => { + Animated.timing(translateX, { + toValue: visible ? 0 : SHEET_WIDTH, + duration: 220, + useNativeDriver: true, + }).start(); + }, [visible, translateX]); + + if (!visible) return null; + + function go(path: string) { + onClose(); + router.push(path as never); + } + + function openWeb(segment: string) { + if (!project) return; + onClose(); + void Linking.openURL(`${API_BASE_URL}/o/${project.orgSlug}/p/${project.slug}/${segment}`); + } + + const initials = (email ?? "?").slice(0, 2).toUpperCase(); + + return ( + + + + {/* Profile header */} + + + {initials} + + + {email ?? "Account"} + + + + + + + + {/* Profile group */} + go("/(tabs)/me")} /> + go("/(tabs)/notifications")} + /> + + {/* Execute (Overview is native; Goals/Attention live in the tab bar) */} + Execute + go("/(tabs)")} /> + + {/* Web-backed sections, per pinned project */} + {project ? ( + WEB_SECTIONS.map((section) => ( + + {section.title} + {section.items.map((item) => ( + openWeb(item.segment)} + /> + ))} + + )) + ) : ( + + Pin a project from the top bar to access Release, Sprint, Meeting + and more. + + )} + + + { + onClose(); + void signOut(); + }} + /> + + + + ); +} + +const styles = StyleSheet.create({ + scrim: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.4)", + }, + sheet: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + backgroundColor: colors.background, + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: colors.border, + paddingHorizontal: 14, + shadowColor: "#000", + shadowOffset: { width: -2, height: 0 }, + shadowOpacity: 0.12, + shadowRadius: 10, + elevation: 12, + }, + profileHeader: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + marginBottom: 8, + }, + avatar: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: colors.primary, + alignItems: "center", + justifyContent: "center", + }, + avatarText: { fontSize: 13, fontWeight: "600", color: "#FFFFFF" }, + profileName: { flex: 1, fontSize: 15, fontWeight: "600", color: colors.text }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 11, + paddingHorizontal: 6, + borderRadius: radii.sm, + }, + rowText: { flex: 1, fontSize: 16, color: "#3A3A3C" }, + sectionHeader: { + fontSize: 11, + fontWeight: "600", + color: "#AEAEB2", + textTransform: "uppercase", + letterSpacing: 1.2, + marginTop: 14, + marginBottom: 4, + paddingHorizontal: 6, + }, + pinHint: { + fontSize: 12, + color: colors.textSecondary, + lineHeight: 17, + paddingHorizontal: 6, + marginTop: 12, + }, + divider: { height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginVertical: 10 }, +}); diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx new file mode 100644 index 0000000..65dcf57 --- /dev/null +++ b/src/components/page-header.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Page header — native port of the web PageBar below lg: big bold title with + * a gray one-line subtitle (text-2xl/leading-8 + text-base on web). + */ +import { View, Text, StyleSheet } from "react-native"; +import { colors } from "@/src/lib/theme"; + +export function PageHeader({ + title, + subtitle, + badge, + right, +}: { + title: string; + subtitle?: string; + badge?: number | null; + right?: React.ReactNode; +}) { + return ( + + + + + {title} + + {badge != null && badge > 0 && ( + + {badge} + + )} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {right} + + ); +} + +const styles = StyleSheet.create({ + wrap: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 12, + gap: 10, + }, + textCol: { flex: 1, minWidth: 0 }, + titleRow: { flexDirection: "row", alignItems: "center", gap: 8 }, + title: { fontSize: 32, fontWeight: "700", color: colors.text, lineHeight: 38 }, + badge: { + backgroundColor: colors.surfaceSecondary, + borderRadius: 9999, + paddingHorizontal: 9, + paddingVertical: 2, + }, + badgeText: { fontSize: 13, fontWeight: "600", color: colors.textSecondary }, + subtitle: { fontSize: 15, color: colors.textSecondary, marginTop: 2 }, +}); diff --git a/src/components/project-switcher-sheet.tsx b/src/components/project-switcher-sheet.tsx new file mode 100644 index 0000000..5b12697 --- /dev/null +++ b/src/components/project-switcher-sheet.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Project switcher bottom sheet — native counterpart of the web global nav's + * project switcher dropdown. Lists every project the user can see, grouped by + * organization, with an "All projects" row for global scope. + */ +import { Modal, View, Text, TouchableOpacity, Pressable, SectionList, StyleSheet } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useProjects, type ProjectSummary } from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; +import { colors, radii } from "@/src/lib/theme"; + +interface Props { + visible: boolean; + onClose: () => void; +} + +export function ProjectSwitcherSheet({ visible, onClose }: Props) { + const insets = useSafeAreaInsets(); + const { data, isLoading } = useProjects(); + const { selected, select } = useProjectStore(); + + const byOrg = new Map(); + for (const p of data?.data ?? []) { + const entry = byOrg.get(p.orgSlug) ?? { orgName: p.orgName, projects: [] }; + entry.projects.push(p); + byOrg.set(p.orgSlug, entry); + } + const sections = [...byOrg.entries()].map(([orgSlug, { orgName, projects }]) => ({ + title: orgName, + key: orgSlug, + data: projects, + })); + + function choose(project: ProjectSummary | null) { + select(project); + onClose(); + } + + return ( + + + + + Projects + + {/* Global scope */} + choose(null)} activeOpacity={0.7}> + + + + All projects + {!selected && } + + + p.id} + style={styles.list} + renderSectionHeader={({ section }) => ( + {section.title} + )} + renderItem={({ item }) => { + const active = selected?.id === item.id; + return ( + choose(item)} activeOpacity={0.7}> + + {item.name.slice(0, 1).toUpperCase()} + + + {item.name} + + {active && } + + ); + }} + ListEmptyComponent={ + + {isLoading ? "Loading projects…" : "No projects found."} + + } + /> + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.4)", + }, + sheet: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + maxHeight: "70%", + backgroundColor: colors.background, + borderTopLeftRadius: radii.xl, + borderTopRightRadius: radii.xl, + paddingHorizontal: 16, + paddingTop: 8, + }, + handle: { + alignSelf: "center", + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.border, + marginBottom: 10, + }, + title: { fontSize: 16, fontWeight: "700", color: colors.text, marginBottom: 8 }, + list: { flexGrow: 0 }, + sectionHeader: { + fontSize: 11, + fontWeight: "600", + color: colors.textSecondary, + textTransform: "uppercase", + letterSpacing: 0.5, + marginTop: 12, + marginBottom: 4, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingVertical: 10, + }, + projectIcon: { + width: 26, + height: 26, + borderRadius: 7, + backgroundColor: colors.primary, + alignItems: "center", + justifyContent: "center", + }, + projectIconText: { color: "#FFFFFF", fontWeight: "700", fontSize: 12 }, + rowText: { flex: 1, fontSize: 15, color: colors.text }, + rowTextActive: { fontWeight: "600" }, + empty: { paddingVertical: 16, fontSize: 13, color: colors.textSecondary }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 7b188db..b519fc9 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -3,12 +3,12 @@ /** * Typed API client for the Build backend. * - * All requests carry the pm_mobile_* Bearer token from the auth store. - * Error responses follow the uniform Build/Interactor envelope: - * { error: string, message: string, details?: unknown } + * Auth: all authenticated requests use `Authorization: Bearer pm_mobile_`. + * The token is minted by the backend (POST /api/v1/me/mobile-sessions) after + * any sign-in method (email/password or Interactor SSO) and stored in SecureStore. */ -import { useAuthStore } from "@/src/store/auth"; import { API_BASE_URL } from "@/src/lib/config"; +import { useAuthStore } from "@/src/store/auth"; export class ApiError extends Error { constructor( @@ -23,14 +23,22 @@ export class ApiError extends Error { } async function request(path: string, init?: RequestInit): Promise { - const { token } = useAuthStore.getState(); + const { mobileToken } = useAuthStore.getState(); + const headers: Record = { "Content-Type": "application/json", ...(init?.headers as Record), }; - if (token) headers["Authorization"] = `Bearer ${token}`; - const res = await fetch(`${API_BASE_URL}${path}`, { ...init, headers }); + if (mobileToken) { + headers["Authorization"] = `Bearer ${mobileToken}`; + } + + const res = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers, + }); + if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string; diff --git a/src/lib/config.ts b/src/lib/config.ts index 4be278e..3a56670 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,6 +3,3 @@ export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "https://build.interactor.com"; - -export const ACCOUNT_SERVER_URL = - process.env.EXPO_PUBLIC_ACCOUNT_SERVER_URL ?? "https://auth.interactor.com"; diff --git a/src/lib/push.ts b/src/lib/push.ts index a0bd8a3..8e44358 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -24,6 +24,49 @@ Notifications.setNotificationHandler({ }), }); +export type AlertPermission = "granted" | "denied" | "undetermined"; + +/** Current OS-level alert permission for this device. */ +export async function getAlertPermission(): Promise { + const { status, canAskAgain } = await Notifications.getPermissionsAsync(); + if (status === "granted") return "granted"; + if (status === "denied" && !canAskAgain) return "denied"; + return "undetermined"; +} + +/** + * Ask the OS for alert permission and (when granted) register the push token + * with the backend. Returns the resulting permission. When the OS refuses to + * re-prompt (iOS after a denial), callers should send the user to the system + * settings instead (Linking.openSettings()). + */ +export async function requestAlertPermission(): Promise { + const { status } = await Notifications.requestPermissionsAsync(); + if (status === "granted") { + await registerPushToken(); + return "granted"; + } + return getAlertPermission(); +} + +/** Fire a local device alert in ~2s — lets the user verify sounds/banners. */ +export async function sendTestAlert(): Promise { + await Notifications.scheduleNotificationAsync({ + content: { + title: "Build test alert", + body: "Device alerts are working. You'll be notified when a goal needs you.", + sound: "default", + data: { type: "test" }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: 2 }, + }); +} + +/** Mirror the unread notification count onto the app icon badge. */ +export async function syncAppBadge(count: number): Promise { + await Notifications.setBadgeCountAsync(count).catch(() => {}); +} + export async function registerPushToken(): Promise { // Android requires an explicit channel for heads-up notifications if (Platform.OS === "android") { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 77f8980..99fb1d9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,13 +1,9 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later -/** - * TanStack Query hooks for the Build API. - * All hooks read the pm_mobile_* token from the auth store via api.get/post/patch/delete. - */ -import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/src/lib/api-client"; -// ── Types ───────────────────────────────────────────────────────────────────── +// ── Notification types ──────────────────────────────────────────────────────── export interface Notification { id: string; @@ -26,6 +22,8 @@ export interface NotificationsResponse { meta: { total: number; page: number; perPage: number; unreadCount: number }; } +// ── Todo types ──────────────────────────────────────────────────────────────── + export type TodoKind = "task" | "approval" | "decision_ack"; export type TodoPriority = "low" | "medium" | "high" | "urgent"; @@ -47,24 +45,256 @@ export type TodoItem = | { kind: "approval"; id: string; title: string; createdAt: string; project: TodoProject; organization: TodoOrganization } | { kind: "decision_ack"; id: string; decisionId: string; title: string; decidedAt: string | null; project: TodoProject; organization: TodoOrganization }; +export interface TodoFacets { + organizations: { id: string; name: string; slug: string; count?: number }[]; + projects: { id: string; name: string; slug: string; count?: number }[]; + types: { kind: TodoKind; count: number }[]; +} + +/** Matches getTodosForUser: the route returns `{ data: { items, facets } }`. */ export interface TodosResponse { - data: TodoItem[]; - facets: { organizations: unknown[]; projects: unknown[]; types: { kind: TodoKind; count: number }[] }; - meta: { total: number }; + data: { items: TodoItem[]; facets: TodoFacets }; } export interface GlobalCounts { globalTodosCount: number; perOrg: Record; + /** Only present when the request included orgSlug + projectSlug. */ + untriagedFeedbackCount?: number; + myAttentionCount?: number; + runningTasksCount?: number; +} + +// ── Goal types ──────────────────────────────────────────────────────────────── +// Mirrors the bare EngineGoal rows GET /api/v1/me/engine/goals returns (subset +// of columns the app renders; extra fields on the wire are ignored). + +export type GoalStatus = "open" | "in_progress" | "accepted" | "cancelled"; +export type GoalType = "feature" | "bug" | "chore"; +export type GoalPrState = "none" | "open" | "draft" | "ready_for_review" | "closed" | "merged"; + +export interface EngineGoal { + id: string; + projectId: string; + title: string; + description: string | null; + status: GoalStatus; + priority: TodoPriority; + type: GoalType; + displayNumber: number | null; + targetDate: string | null; + prState: GoalPrState; + prUrl: string | null; + prNumber: number | null; + autoDeliver: boolean; + pausedAt: string | null; + pauseReason: string | null; + mergeBlockedReasonKind: string | null; + createdAt: string; + updatedAt: string; + release: { id: string; name: string; slug: string; endDate: string | null } | null; +} + +export interface GoalsResponse { + data: EngineGoal[]; + meta: { truncated: boolean }; +} + +export interface GoalCompletion { + totalTasks: number; + doneTasks: number; + openTasks: number; + tasksComplete: boolean; + complete: boolean; +} + +export interface GoalDetailResponse { + data: { + goal: EngineGoal; + completion: GoalCompletion; + reviewReadiness: { activeTasks: number; notYetAtReview: number; ready: boolean }; + deployGate: { + unresolvedMustFix: number; + ciState: "pending" | "passed" | "failed" | "error"; + reviewedAtHead: boolean; + mergeable: boolean; + }; + attachments: { + id: string; + filename: string; + contentType: string; + sizeBytes: number; + downloadUrl: string; + }[]; + }; +} + +// ── Admission approval types (GET /api/v1/me/engine/approvals) ─────────────── + +export interface AdmissionApproval { + type: "goal_admission_approval"; + /** Approval row id — what the decide route takes. */ + id: string; + createdAt: string; + stewardDomain: string; + projectId: string; + orgSlug: string; + projectSlug: string; + goalId: string; + goalTitle: string; + goalDisplayNumber: number | null; + requestedBy: { id: string; name: string | null; email: string } | null; + brief: { + summary: string; + verdict: "aligned" | "conflict" | "unclear" | null; + rationale: string; + sizeEstimate: string | null; + unavailable: boolean; + } | null; + autoApproved: { ruleSummary: string; decidedAt: string | null } | null; + escalatedAt: string | null; +} + +// ── Fleet types ─────────────────────────────────────────────────────────────── +// GET /api/v1/me/engine/fleet-queue → { data: { goals, awaitingAcceptance } } +// GET /api/v1/me/machines/fleet → { data: FleetMachine[] } + +export type FleetGoalStatus = + | "blocked" + | "running" + | "waiting_on_you" + | "queued" + | "recovering" + | "waiting_on_deps"; + +export interface FleetGoalRow { + goalId: string; + displayNumber: number | null; + title: string; + priority: string; + status: FleetGoalStatus; + project: { id: string; name: string; slug: string; orgSlug: string; orgName: string }; + ownership: "mine" | "shared" | "other"; + ownerName: string | null; + mine: boolean; + progress: { done: number; total: number }; + prNumber: number | null; + phase: string | null; + currentTask: { + taskId: string; + displayNumber: number | null; + title: string; + machineId: string | null; + mode: "daemon" | "interactive" | null; + } | null; + blockedBy: { goalId: string; displayNumber: number | null; title: string }[]; + mergeBlockedReasonKind: string | null; +} + +export type FleetMachineStatus = "working" | "idle" | "stuck" | "error" | "offline"; + +export interface FleetMachine { + id?: string; + name?: string; + status?: FleetMachineStatus; + sessionId?: string; + goal?: { id: string; displayNumber: number | null; title: string } | null; + task?: { id: string; displayNumber: number | null; title: string }; + leaseExpiresAt?: string | null; +} + +export interface FleetQueueResponse { + data: { goals: FleetGoalRow[]; awaitingAcceptance: FleetGoalRow[] }; +} + +// ── Phase types (GET /api/v1/projects/[id]/phases) ─────────────────────────── + +export type PhaseStatus = "planned" | "active" | "completed" | "archived"; + +export interface Phase { + id: string; + name: string; + description: string | null; + status: PhaseStatus; + startDate: string | null; + endDate: string | null; + order: number; + _count: { tasks: number; deliverables: number }; +} + +// ── Feedback types ──────────────────────────────────────────────────────────── + +export interface FeedbackEntry { + id: string; + title: string; + body: string | null; + status: string; + source: string; + rating: number | null; + voteCount: number; + authorName: string | null; + groupName: string | null; + createdAt: string; +} + +export interface FeedbackResponse { + data: FeedbackEntry[]; + meta: { total: number }; } -// ── Keys ───────────────────────────────────────────────────────────────────── +// ── Project types ───────────────────────────────────────────────────────────── + +export interface ProjectSummary { + id: string; + name: string; + slug: string; + orgSlug: string; + orgName: string; +} + +export interface ProjectsResponse { + data: ProjectSummary[]; +} + +// ── Device + preference types ───────────────────────────────────────────────── + +export interface UserDevice { + id: string; + platform: string; + appVersion: string | null; + createdAt: string; + updatedAt: string; +} + +export interface UserPreferences { + id: string; + theme: "system" | "light" | "dark"; + timezone: string | null; + emailDailyDigest: boolean; + emailWeeklyDigest: boolean; + notificationPrefs: Record | null; + defaultOrgSlug: string | null; + updatedAt: string; +} + +/** Per-user opt-out key for engine-attention pushes (default ON). Matches + * product-manager's ENGINE_ATTENTION_PUSH_PREF_KEY. */ +export const ENGINE_ATTENTION_PUSH_PREF_KEY = "engine_attention_push"; + +// ── Query keys ──────────────────────────────────────────────────────────────── export const keys = { notifications: (page: number, unreadOnly?: boolean) => ["notifications", page, unreadOnly] as const, todos: (scope: string) => ["todos", scope] as const, counts: () => ["counts"] as const, + goals: (projectId?: string, status?: string) => + ["goals", projectId, status] as const, + feedback: (projectId: string) => ["feedback", projectId] as const, + projects: () => ["projects"] as const, + goalDetail: (goalId: string) => ["goal", goalId] as const, + admissions: () => ["admissions"] as const, + phases: (projectId: string) => ["phases", projectId] as const, }; // ── Hooks ───────────────────────────────────────────────────────────────────── @@ -83,18 +313,142 @@ export function useNotifications(page = 1, unreadOnly = false) { export function useTodos(scope = "global") { return useQuery({ queryKey: keys.todos(scope), - queryFn: () => api.get(`/api/v1/me/todos?scope=${scope}&sort=urgency&limit=100`), + queryFn: () => + api.get(`/api/v1/me/todos?scope=${scope}&sort=priority&limit=100`), }); } -export function useCounts() { +export function useCounts(opts?: { orgSlug?: string; projectSlug?: string }) { + const { orgSlug, projectSlug } = opts ?? {}; return useQuery({ - queryKey: keys.counts(), - queryFn: () => api.get("/api/v1/me/counts"), + queryKey: [...keys.counts(), orgSlug, projectSlug], + queryFn: () => { + const params = new URLSearchParams(); + if (orgSlug) params.set("orgSlug", orgSlug); + if (projectSlug) params.set("projectSlug", projectSlug); + const qs = params.toString(); + return api.get(`/api/v1/me/counts${qs ? `?${qs}` : ""}`); + }, staleTime: 60_000, }); } +/** projectId is REQUIRED by the backend — the query stays disabled without one. */ +export function useGoals(projectId?: string, status?: string) { + return useQuery({ + queryKey: keys.goals(projectId, status), + queryFn: () => { + const params = new URLSearchParams({ projectId: projectId! }); + if (status) params.set("status", status); + return api.get(`/api/v1/me/engine/goals?${params}`); + }, + enabled: !!projectId, + }); +} + +export function useCreateGoal() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, title, description }: { projectId: string; title: string; description?: string }) => + api.post<{ data: { id: string } }>("/api/v1/me/engine/goals", { projectId, title, description }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["goals"] }), + }); +} + +export function useGoalDetail(goalId?: string) { + return useQuery({ + queryKey: keys.goalDetail(goalId ?? ""), + queryFn: () => api.get(`/api/v1/me/engine/goals/${goalId}`), + enabled: !!goalId, + }); +} + +/** Pending goal-admission approvals routed to me (with the AI admission brief). */ +export function useAdmissionApprovals() { + return useQuery({ + queryKey: keys.admissions(), + queryFn: () => api.get<{ data: AdmissionApproval[] }>("/api/v1/me/engine/approvals"), + }); +} + +/** Cross-project fleet queue: goals being advanced + goals awaiting acceptance. + * Polled while the fleet panel is open (web polls this every 60s). */ +export function useFleetQueue(enabled = true) { + return useQuery({ + queryKey: ["fleet-queue"], + queryFn: () => api.get("/api/v1/me/engine/fleet-queue"), + enabled, + refetchInterval: enabled ? 60_000 : false, + }); +} + +export function useFleetMachines(enabled = true) { + return useQuery({ + queryKey: ["fleet-machines"], + queryFn: () => api.get<{ data: FleetMachine[] }>("/api/v1/me/machines/fleet"), + enabled, + refetchInterval: enabled ? 60_000 : false, + }); +} + +export function usePhases(projectId?: string) { + return useQuery({ + queryKey: keys.phases(projectId ?? ""), + queryFn: () => api.get<{ data: Phase[] }>(`/api/v1/projects/${projectId}/phases`), + enabled: !!projectId, + }); +} + +export function useFeedback(projectId: string) { + return useQuery({ + queryKey: keys.feedback(projectId), + queryFn: () => + api.get(`/api/v1/projects/${projectId}/feedback`), + enabled: !!projectId, + }); +} + +/** Devices registered for push alerts on this account. */ +export function useDevices() { + return useQuery({ + queryKey: ["devices"], + queryFn: () => api.get<{ data: UserDevice[] }>("/api/v1/me/devices"), + }); +} + +export function useDeleteDevice() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/me/devices/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["devices"] }), + }); +} + +export function usePreferences() { + return useQuery({ + queryKey: ["preferences"], + queryFn: () => api.get<{ data: UserPreferences }>("/api/v1/me/preferences"), + }); +} + +export function useUpdatePreferences() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (patch: Partial>) => + api.patch<{ data: UserPreferences }>("/api/v1/me/preferences", patch), + onSuccess: () => qc.invalidateQueries({ queryKey: ["preferences"] }), + }); +} + +/** Flat, cross-org list of every project the user can see — powers the switcher. */ +export function useProjects() { + return useQuery({ + queryKey: keys.projects(), + queryFn: () => api.get("/api/v1/me/projects"), + staleTime: 5 * 60_000, + }); +} + export function useMarkNotificationRead() { const qc = useQueryClient(); return useMutation({ @@ -120,10 +474,19 @@ export function useMarkAllRead() { export function useDecideApproval() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, action, comment }: { id: string; action: "grant" | "reject"; comment?: string }) => + mutationFn: ({ + id, + action, + comment, + }: { + id: string; + action: "grant" | "reject"; + comment?: string; + }) => api.post(`/api/v1/me/engine/approvals/${id}/decide`, { action, comment }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["todos"] }); + qc.invalidateQueries({ queryKey: keys.admissions() }); qc.invalidateQueries({ queryKey: keys.counts() }); }, }); diff --git a/src/lib/responsive.ts b/src/lib/responsive.ts new file mode 100644 index 0000000..8fc3cb5 --- /dev/null +++ b/src/lib/responsive.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Responsive breakpoints mirrored from the Build web app + * (product-manager/src/lib/responsive/breakpoints.ts, tailwind screens). + * + * Web behavior being replicated: + * - < 768 (md): phone — single column, bottom tab bar + * - 768–1023 : tablet — bottom tab bar retained, wider content, 2-col grids + * - >= 1024 : tablet landscape / desktop-class — multi-column layouts + */ +import { useWindowDimensions } from "react-native"; + +export const BREAKPOINTS = { + md: 768, + lg: 1024, +} as const; + +export type Breakpoint = "mobile" | "tablet" | "tabletXL"; + +export function useBreakpoint(): { + breakpoint: Breakpoint; + isTablet: boolean; + width: number; + height: number; + /** Number of grid columns for card lists, matching web's md/lg grids. */ + columns: 1 | 2 | 3; +} { + const { width, height } = useWindowDimensions(); + const breakpoint: Breakpoint = + width >= BREAKPOINTS.lg ? "tabletXL" : width >= BREAKPOINTS.md ? "tablet" : "mobile"; + return { + breakpoint, + isTablet: breakpoint !== "mobile", + width, + height, + columns: breakpoint === "tabletXL" ? 3 : breakpoint === "tablet" ? 2 : 1, + }; +} diff --git a/src/lib/sse.ts b/src/lib/sse.ts index 98936bc..78ec662 100644 --- a/src/lib/sse.ts +++ b/src/lib/sse.ts @@ -3,15 +3,11 @@ /** * SSE foreground connection for real-time notification updates. * - * Connects to /api/sse/notifications: with a Bearer pm_mobile_* token - * while the app is foregrounded. On each `realtime` event, invalidates the - * TanStack Query notification + counts keys so all screens refresh live. - * - * Lifecycle: - * - Connect when token + userId are available and app is active - * - Disconnect when app backgrounds (AppState != 'active') - * - Reconnect when app foregrounds again - * - Tear down on unmount / sign-out (token becomes null) + * Connects to /api/sse/notifications: while the app is foregrounded. + * Auth is sent as `Authorization: Bearer pm_mobile_*` — the same token used + * by all other API calls. The SSE route accepts pm_mobile_* via resolveActor. + * On each `realtime` event, invalidates the TanStack Query notification + + * counts keys so all screens refresh live. */ import { useEffect, useRef } from "react"; import { AppState, type AppStateStatus } from "react-native"; @@ -21,24 +17,25 @@ import { useAuthStore } from "@/src/store/auth"; import { API_BASE_URL } from "@/src/lib/config"; export function useNotificationSSE() { - const { token, userId } = useAuthStore(); + const { userId } = useAuthStore(); const qc = useQueryClient(); const esRef = useRef(null); - // Keep latest token/userId accessible inside the AppState callback - const tokenRef = useRef(token); const userIdRef = useRef(userId); - tokenRef.current = token; userIdRef.current = userId; function connect() { - const t = tokenRef.current; const uid = userIdRef.current; - if (!t || !uid) return; + const { mobileToken } = useAuthStore.getState(); + if (!uid) return; esRef.current?.close(); + const headers: Record = {}; + if (mobileToken) { + headers["Authorization"] = `Bearer ${mobileToken}`; + } esRef.current = new EventSource( `${API_BASE_URL}/api/sse/notifications:${uid}`, - { headers: { Authorization: `Bearer ${t}` } } + { headers } ); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -54,7 +51,7 @@ export function useNotificationSSE() { } useEffect(() => { - if (!token || !userId) { + if (!userId) { disconnect(); return; } @@ -71,5 +68,5 @@ export function useNotificationSSE() { sub.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token, userId]); + }, [userId]); } diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..9f2fb9f --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Design tokens mirrored from the Build web app (product-manager). + * + * Source of truth: product-manager/src/app/globals.css and the tailwind + * utility values used across the responsive mobile/tablet web UI + * (mobile-bottom-nav.tsx, dashboard-client.tsx, engine-goals-client.tsx). + * Keep hex values in sync with the web so both frontends render identically. + */ + +export const colors = { + // Brand + primary: "#4CD964", + primaryDark: "#3DBF55", + primaryLight: "#E8F8EB", + primaryText: "#2F855A", + + // Surfaces + background: "#FFFFFF", + surface: "#F5F5F5", + surfaceSecondary: "#E8E8ED", + border: "#E5E5EA", + borderLight: "#F2F2F7", + + // Text + text: "#1C1C1E", + textSecondary: "#8E8E93", + textInverted: "#FFFFFF", + + // Status + info: "#007AFF", + warning: "#FF9500", + danger: "#FF3B30", + dangerLight: "#FFF0EF", + amber: "#FBBF24", + amberLight: "#FEF3C7", + orange: "#FB923C", +} as const; + +/** Priority pill colors — identical to web (`bg-[#FFE5E3] text-[#C53030]` etc.) */ +export const priorityColors: Record< + string, + { bg: string; text: string; label: string } +> = { + urgent: { bg: "#FFE5E3", text: "#C53030", label: "Urgent" }, + high: { bg: "#FFF1E0", text: "#B7791F", label: "High" }, + medium: { bg: "#E8F8EB", text: "#2F855A", label: "Medium" }, + low: { bg: "#F3F4F6", text: "#6E6E73", label: "Low" }, +}; + +export const radii = { + sm: 8, + md: 12, // rounded-lg cards + lg: 16, // rounded-xl panels + xl: 20, // rounded-2xl sheets/modals + pill: 9999, +} as const; diff --git a/src/store/auth.ts b/src/store/auth.ts index 5800a51..14803ec 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -1,127 +1,235 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later /** - * Auth store — manages the long-lived pm_mobile_* session token. + * Auth store for the Build mobile companion app. * - * Login flow: - * 1. POST credentials to /api/v1/admin/login on account-server → short-lived user JWT - * 2. Decode the user JWT to extract userId + email (no library needed — public payload) - * 3. Exchange the user JWT for a pm_mobile_* token via POST /api/v1/me/mobile-sessions - * 4. Persist {token, userId, email} in expo-secure-store (iOS Keychain / Android Keystore) + * All sign-in methods (email/password AND Interactor SSO) end up storing a + * long-lived pm_mobile_* Bearer token in SecureStore. This token is issued by + * POST /api/v1/me/mobile-sessions on the product-manager backend and is what + * every API call and SSE connection uses for auth. * - * The pm_mobile_* token is long-lived (decoupled from the ~1h user JWT TTL) and - * per-device revocable. It is the only credential stored; the user JWT is discarded - * after exchange. + * Email/password flow: + * 1. GET /api/auth/csrf → CSRF cookie in iOS cookie jar + * 2. POST /api/auth/callback/credentials → authjs.session-token cookie + * 3. GET /api/auth/session → confirm session, get user id/email + * 4. POST /api/v1/me/mobile-sessions (cookie jar auto-sends session) → pm_mobile_* + * + * Interactor SSO (Google) flow: + * 1. expo-web-browser opens /api/auth/signin/interactor?callbackUrl=/api/auth/mobile-callback + * 2. User signs in via OIDC → NextAuth sets session cookie in browser + * 3. /api/auth/mobile-callback mints pm_mobile_* and redirects to buildapp://auth/callback?token=... + * 4. openAuthSessionAsync captures the buildapp:// URL; we extract the token */ import { create } from "zustand"; import * as SecureStore from "expo-secure-store"; import { Platform } from "react-native"; -import { API_BASE_URL, ACCOUNT_SERVER_URL } from "@/src/lib/config"; +import { API_BASE_URL } from "@/src/lib/config"; -const SECURE_STORE_KEY = "pm_mobile_session"; +const SECURE_STORE_KEY = "pm_session_v3"; interface StoredSession { - token: string; userId: string; email: string; + name: string | null; + mobileToken: string; + mobileSessionId?: string; +} + +interface NextAuthSession { + user?: { + id?: string; + email?: string; + name?: string | null; + }; +} + +interface MobileSessionResponse { + data?: { + id?: string; + token?: string; + }; } interface AuthState { - token: string | null; - userId: string | null; email: string | null; + userId: string | null; + mobileToken: string | null; + mobileSessionId: string | null; hydrated: boolean; signIn: (email: string, password: string) => Promise; + ssoSignIn: (token: string, email: string, userId: string) => Promise; signOut: () => Promise; hydrate: () => Promise; } -function decodeJwtClaims(jwt: string): Record { - try { - const payload = jwt.split(".")[1]; - const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4); - return JSON.parse(atob(padded.replace(/-/g, "+").replace(/_/g, "/"))); - } catch { - return {}; +/** iOS NSURLSession drops the occasional request (-1005 "connection was + * lost", -1017 "cannot parse response") — especially the first request after + * cold start and on flaky cell networks. These are safe to retry. */ +const TRANSIENT_NETWORK_ERROR = + /network connection was lost|cannot parse response|network request failed|timed out/i; + +/** Tag which auth step failed and retry transient network errors. */ +async function step(name: string, fn: () => Promise): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) await new Promise((r) => setTimeout(r, 600 * attempt)); + try { + return await fn(); + } catch (err) { + lastErr = err; + const msg = err instanceof Error ? err.message : String(err); + if (!TRANSIENT_NETWORK_ERROR.test(msg)) break; + } } + const msg = lastErr instanceof Error ? lastErr.message : String(lastErr); + throw new Error(`[${name}] ${msg}`); +} + +async function fetchCsrfToken(): Promise { + return step("csrf", async () => { + const res = await fetch(`${API_BASE_URL}/api/auth/csrf`); + const data = (await res.json()) as { csrfToken: string }; + return data.csrfToken; + }); +} + +async function fetchSession(): Promise<{ userId: string; email: string; name: string | null } | null> { + return step("session", async () => { + const res = await fetch(`${API_BASE_URL}/api/auth/session`); + const data = (await res.json()) as NextAuthSession | null; + if (!data?.user?.id) return null; + return { + userId: data.user.id, + email: data.user.email ?? "", + name: data.user.name ?? null, + }; + }); +} + +async function exchangeForMobileToken( + platform: string +): Promise<{ token: string; sessionId: string } | null> { + const res = await fetch(`${API_BASE_URL}/api/v1/me/mobile-sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceName: "Build Mobile", platform }), + }); + if (!res.ok) return null; + const data = (await res.json()) as MobileSessionResponse; + if (!data.data?.token || !data.data?.id) return null; + return { token: data.data.token, sessionId: data.data.id }; +} + +async function validateMobileToken(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/api/v1/me/mobile-sessions`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return res.ok; } export const useAuthStore = create((set, get) => ({ - token: null, - userId: null, email: null, + userId: null, + mobileToken: null, + mobileSessionId: null, hydrated: false, hydrate: async () => { - const raw = await SecureStore.getItemAsync(SECURE_STORE_KEY); + const raw = await SecureStore.getItemAsync(SECURE_STORE_KEY).catch(() => null); if (raw) { try { - const session = JSON.parse(raw) as StoredSession; - set({ token: session.token, userId: session.userId, email: session.email, hydrated: true }); + const stored = JSON.parse(raw) as StoredSession; + set({ + email: stored.email, + userId: stored.userId, + mobileToken: stored.mobileToken, + mobileSessionId: stored.mobileSessionId ?? null, + hydrated: true, + }); + // Validate token in the background — clear state if revoked/expired + validateMobileToken(stored.mobileToken) + .then((valid) => { + if (!valid) { + set({ email: null, userId: null, mobileToken: null, mobileSessionId: null }); + void SecureStore.deleteItemAsync(SECURE_STORE_KEY); + } + }) + .catch(() => {}); return; } catch {} } - // Legacy: single-value token stored under old key - const legacy = await SecureStore.getItemAsync("pm_mobile_token"); - set({ token: legacy ?? null, hydrated: true }); + set({ hydrated: true }); }, - signIn: async (emailInput: string, password: string) => { - // Step 1: obtain short-lived user JWT from account-server - const loginRes = await fetch(`${ACCOUNT_SERVER_URL}/api/v1/admin/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: emailInput, password }), + signIn: async (email: string, password: string) => { + // Step 1: CSRF token (also sets csrf cookie in iOS cookie jar) + const csrfToken = await fetchCsrfToken(); + + // Step 2: Credentials login — session cookie set in iOS cookie jar + const body = new URLSearchParams({ + email, + password, + csrfToken, + redirect: "false", }); - if (!loginRes.ok) { - const body = (await loginRes.json().catch(() => ({}))) as { message?: string }; - throw new Error(body.message ?? "Invalid credentials"); - } - const { token: userJwt } = (await loginRes.json()) as { token: string }; - - // Step 2: decode user JWT to get userId + email - const claims = decodeJwtClaims(userJwt); - const userId = (claims.sub as string) ?? ""; - const email = (claims.email as string) ?? emailInput; - - // Step 3: exchange user JWT for a long-lived pm_mobile_* session token - const deviceName = - Platform.OS === "ios" ? "Build iPhone" : "Build Android"; - const sessionRes = await fetch(`${API_BASE_URL}/api/v1/me/mobile-sessions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${userJwt}`, - }, - body: JSON.stringify({ - deviceName, - platform: Platform.OS, - appVersion: "1.0.0", - }), + await step("credentials", () => + fetch(`${API_BASE_URL}/api/auth/callback/credentials`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }) + ); + + // Step 3: Confirm session + const session = await fetchSession(); + if (!session) throw new Error("Invalid email or password"); + + // Step 4: Exchange session cookie for long-lived pm_mobile_* token + const platform = Platform.OS === "ios" ? "ios" : "android"; + const exchange = await exchangeForMobileToken(platform); + if (!exchange) throw new Error("Failed to initialize mobile session. Please try again."); + + const stored: StoredSession = { + userId: session.userId, + email: session.email, + name: session.name, + mobileToken: exchange.token, + mobileSessionId: exchange.sessionId, + }; + await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(stored)); + set({ + email: session.email, + userId: session.userId, + mobileToken: exchange.token, + mobileSessionId: exchange.sessionId, }); - if (!sessionRes.ok) { - const body = (await sessionRes.json().catch(() => ({}))) as { message?: string }; - throw new Error(body.message ?? "Session exchange failed"); - } - const { token: mobileToken } = (await sessionRes.json()) as { token: string }; + }, - const session: StoredSession = { token: mobileToken, userId, email }; - await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(session)); - set({ token: mobileToken, userId, email }); + ssoSignIn: async (token: string, email: string, userId: string) => { + // token is already a pm_mobile_* token minted by GET /api/auth/mobile-callback + const stored: StoredSession = { + userId, + email, + name: null, + mobileToken: token, + }; + await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(stored)); + set({ email, userId, mobileToken: token, mobileSessionId: null }); }, signOut: async () => { - const { token } = get(); - if (token) { - // Best-effort revoke — don't block sign-out if the server is unreachable - fetch(`${API_BASE_URL}/api/v1/me/mobile-sessions/current`, { + const { mobileToken, mobileSessionId } = get(); + + // Revoke the mobile session if we know its ID + if (mobileToken && mobileSessionId) { + fetch(`${API_BASE_URL}/api/v1/me/mobile-sessions/${mobileSessionId}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${mobileToken}` }, }).catch(() => {}); } - await SecureStore.deleteItemAsync(SECURE_STORE_KEY); - await SecureStore.deleteItemAsync("pm_mobile_token"); // clear legacy key too - set({ token: null, userId: null, email: null }); + + await SecureStore.deleteItemAsync(SECURE_STORE_KEY).catch(() => {}); + set({ email: null, userId: null, mobileToken: null, mobileSessionId: null }); }, })); diff --git a/src/store/project.ts b/src/store/project.ts new file mode 100644 index 0000000..1fd1b0c --- /dev/null +++ b/src/store/project.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Selected-project store — native counterpart of the web app's project + * switcher in the global nav. `null` means "All projects" (global scope, + * served by the /api/v1/me/* aggregate endpoints). Project-scoped screens + * (Goals, Feedback) filter by the selection when one is set. + */ +import { create } from "zustand"; +import * as SecureStore from "expo-secure-store"; +import type { ProjectSummary } from "@/src/lib/queries"; + +const STORE_KEY = "pm_selected_project_v1"; + +interface ProjectState { + selected: ProjectSummary | null; + hydrated: boolean; + select: (project: ProjectSummary | null) => void; + hydrate: () => Promise; +} + +export const useProjectStore = create((set) => ({ + selected: null, + hydrated: false, + + hydrate: async () => { + const raw = await SecureStore.getItemAsync(STORE_KEY).catch(() => null); + if (raw) { + try { + set({ selected: JSON.parse(raw) as ProjectSummary, hydrated: true }); + return; + } catch {} + } + set({ hydrated: true }); + }, + + select: (project) => { + set({ selected: project }); + if (project) { + void SecureStore.setItemAsync(STORE_KEY, JSON.stringify(project)).catch(() => {}); + } else { + void SecureStore.deleteItemAsync(STORE_KEY).catch(() => {}); + } + }, +})); + +useProjectStore.getState().hydrate(); diff --git a/tsconfig.json b/tsconfig.json index afc8c50..468864d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,16 @@ "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.d.ts", + "expo-env.d.ts", + ".expo/types/**/*.ts" + ] }