From da7cf1fe5403a1034923e6549e7d5d944b7a39c4 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 27 Jun 2026 16:27:20 -0700 Subject: [PATCH 01/15] feat(auth): add Google SSO via OIDC PKCE against auth.interactor.com - Add src/lib/oidc.ts: useOidcRequest hook + exchangeCodeForToken using expo-auth-session with PKCE (S256) against https://auth.interactor.com - Add signInWithJwt() to auth store; refactor signIn() to reuse it so both password and OIDC paths share the same pm_mobile_* exchange logic - Update login screen with "Continue with Google" button above email/password - Fix pre-existing TypeScript error in _layout.tsx (router.replace type) - Install expo-auth-session, expo-crypto, expo-web-browser; add EXPO_PUBLIC_INTERACTOR_CLIENT_ID to config Redirect URIs to register with account-server: buildapp://auth/callback (standalone app) exp://*/--/auth/callback (Expo Go / simulator) --- app.json | 3 +- app/(auth)/login.tsx | 92 ++++++++++++++++++++++++++++++++++++++++++-- app/_layout.tsx | 2 +- package-lock.json | 40 +++++++++++++++++++ package.json | 3 ++ src/lib/config.ts | 3 ++ src/lib/oidc.ts | 52 +++++++++++++++++++++++++ src/store/auth.ts | 36 +++++++++-------- tsconfig.json | 12 +++++- 9 files changed, 219 insertions(+), 24 deletions(-) create mode 100644 src/lib/oidc.ts diff --git a/app.json b/app.json index a707555..405b05e 100644 --- a/app.json +++ b/app.json @@ -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..ead0432 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, useState } from "react"; import { View, Text, @@ -12,14 +12,40 @@ import { Platform, } from "react-native"; import { useAuthStore } from "@/src/store/auth"; +import { useOidcRequest, exchangeCodeForToken } from "@/src/lib/oidc"; export default function LoginScreen() { - const { signIn } = useAuthStore(); + const { signIn, signInWithJwt } = useAuthStore(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); const [error, setError] = useState(null); + const [request, response, promptAsync] = useOidcRequest(); + + useEffect(() => { + if (!response) return; + if (response.type === "success") { + const code = response.params.code; + const verifier = request?.codeVerifier; + if (!code || !verifier) { + setError("OAuth response missing required parameters"); + return; + } + setOidcLoading(true); + setError(null); + exchangeCodeForToken(code, verifier) + .then((jwt) => signInWithJwt(jwt)) + .catch((err) => + setError(err instanceof Error ? err.message : "Google sign-in failed"), + ) + .finally(() => setOidcLoading(false)); + } else if (response.type === "error") { + setError(response.error?.message ?? "Google sign-in failed"); + } + }, [response]); + async function handleSignIn() { if (!email || !password) return; setLoading(true); @@ -33,6 +59,13 @@ export default function LoginScreen() { } } + async function handleGoogleSignIn() { + setError(null); + await promptAsync(); + } + + const busy = loading || oidcLoading; + return ( {error}} + + {oidcLoading ? ( + + ) : ( + <> + G + Continue with Google + + )} + + + + + or + + + {loading ? ( @@ -95,6 +149,36 @@ const styles = StyleSheet.create({ marginBottom: 16, textAlign: "center", }, + googleButton: { + height: 52, + borderRadius: 9999, + borderWidth: 1.5, + borderColor: "#E5E5EA", + backgroundColor: "#FFFFFF", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 10, + marginBottom: 24, + }, + googleIcon: { + fontSize: 18, + fontWeight: "800", + color: "#4285F4", + }, + googleButtonText: { + color: "#1C1C1E", + fontWeight: "600", + fontSize: 16, + }, + dividerRow: { + flexDirection: "row", + alignItems: "center", + marginBottom: 24, + gap: 12, + }, + dividerLine: { flex: 1, height: 1, backgroundColor: "#E5E5EA" }, + dividerText: { fontSize: 14, color: "#8E8E93" }, input: { height: 52, borderRadius: 12, diff --git a/app/_layout.tsx b/app/_layout.tsx index 250e80b..7664331 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -33,7 +33,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) { if (!token && !inAuthGroup) { router.replace("/(auth)/login"); } else if (token && inAuthGroup) { - router.replace("/(tabs)/"); + router.replace("/(tabs)" as never); } }, [token, hydrated, segments, router]); diff --git a/package-lock.json b/package-lock.json index fe11951..66dacd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,16 @@ "dependencies": { "@tanstack/react-query": "^5.101.1", "expo": "~56.0.12", + "expo-auth-session": "~56.0.14", "expo-constants": "~56.0.18", + "expo-crypto": "~56.0.4", "expo-linking": "~56.0.14", "expo-notifications": "~56.0.18", "expo-router": "~56.2.11", "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", @@ -3279,6 +3282,24 @@ "expo": "*" } }, + "node_modules/expo-auth-session": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-56.0.14.tgz", + "integrity": "sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==", + "license": "MIT", + "dependencies": { + "expo-application": "~56.0.3", + "expo-constants": "~56.0.18", + "expo-crypto": "~56.0.4", + "expo-linking": "~56.0.14", + "expo-web-browser": "~56.0.5", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "56.0.18", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", @@ -3292,6 +3313,15 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz", + "integrity": "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-font": { "version": "56.0.7", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", @@ -3621,6 +3651,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", diff --git a/package.json b/package.json index 8a38880..a880a65 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,16 @@ "dependencies": { "@tanstack/react-query": "^5.101.1", "expo": "~56.0.12", + "expo-auth-session": "~56.0.14", "expo-constants": "~56.0.18", + "expo-crypto": "~56.0.4", "expo-linking": "~56.0.14", "expo-notifications": "~56.0.18", "expo-router": "~56.2.11", "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", diff --git a/src/lib/config.ts b/src/lib/config.ts index 4be278e..57f498a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -6,3 +6,6 @@ export const API_BASE_URL = export const ACCOUNT_SERVER_URL = process.env.EXPO_PUBLIC_ACCOUNT_SERVER_URL ?? "https://auth.interactor.com"; + +export const INTERACTOR_CLIENT_ID = + process.env.EXPO_PUBLIC_INTERACTOR_CLIENT_ID ?? ""; diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 0000000..51c6603 --- /dev/null +++ b/src/lib/oidc.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +import * as AuthSession from "expo-auth-session"; +import * as WebBrowser from "expo-web-browser"; +import { ACCOUNT_SERVER_URL, INTERACTOR_CLIENT_ID } from "@/src/lib/config"; + +WebBrowser.maybeCompleteAuthSession(); + +const DISCOVERY = { + authorizationEndpoint: `${ACCOUNT_SERVER_URL}/oauth/authorize`, + tokenEndpoint: `${ACCOUNT_SERVER_URL}/oauth/token`, +}; + +export function makeRedirectUri() { + return AuthSession.makeRedirectUri({ + scheme: "buildapp", + path: "auth/callback", + }); +} + +export function useOidcRequest() { + const redirectUri = makeRedirectUri(); + return AuthSession.useAuthRequest( + { + clientId: INTERACTOR_CLIENT_ID, + scopes: ["openid", "profile", "email"], + redirectUri, + usePKCE: true, + }, + DISCOVERY, + ); +} + +export async function exchangeCodeForToken( + code: string, + codeVerifier: string, +): Promise { + const redirectUri = makeRedirectUri(); + const res = await AuthSession.exchangeCodeAsync( + { + clientId: INTERACTOR_CLIENT_ID, + code, + redirectUri, + extraParams: { code_verifier: codeVerifier }, + }, + DISCOVERY, + ); + // id_token is the user JWT we exchange for a pm_mobile_* token + const idToken = res.idToken ?? res.accessToken; + if (!idToken) throw new Error("No token in OIDC response"); + return idToken; +} diff --git a/src/store/auth.ts b/src/store/auth.ts index 5800a51..b875aa8 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -32,6 +32,7 @@ interface AuthState { email: string | null; hydrated: boolean; signIn: (email: string, password: string) => Promise; + signInWithJwt: (userJwt: string) => Promise; signOut: () => Promise; hydrate: () => Promise; } @@ -66,25 +67,13 @@ export const useAuthStore = create((set, get) => ({ set({ token: legacy ?? null, 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 }), - }); - 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 + signInWithJwt: async (userJwt: string) => { + // Decode user JWT (from OIDC id_token or password login) to extract userId + email const claims = decodeJwtClaims(userJwt); const userId = (claims.sub as string) ?? ""; - const email = (claims.email as string) ?? emailInput; + const email = (claims.email as string) ?? ""; - // Step 3: exchange user JWT for a long-lived pm_mobile_* session token + // Exchange 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`, { @@ -110,6 +99,21 @@ export const useAuthStore = create((set, get) => ({ set({ token: mobileToken, userId, email }); }, + signIn: async (emailInput: string, password: string) => { + // Obtain short-lived user JWT from account-server via password + const loginRes = await fetch(`${ACCOUNT_SERVER_URL}/api/v1/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: emailInput, password }), + }); + 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 }; + await get().signInWithJwt(userJwt); + }, + signOut: async () => { const { token } = get(); if (token) { 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" + ] } From da080ff2142bccd0a9b66bb748b34f739b3d93e1 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 27 Jun 2026 21:32:53 -0700 Subject: [PATCH 02/15] feat(auth): wire up OIDC flow end-to-end and add tab icons - Fix env var names (EXPO_PUBLIC_INTERACTOR_CLIENT_ID/SECRET) to match config.ts - Disable PKCE (account-server does not support code_challenge) - Pass request.redirectUri to token exchange to guarantee URI consistency - Bypass mobile-session exchange with user JWT directly (TODO: restore once POST /api/v1/me/mobile-sessions is deployed on build.interactor.com) - Add babel.config.js with babel-preset-expo + reanimated plugin - Add react-native-worklets peer dep for reanimated 4.x - Update npm start to --dev-client for native build compatibility - Add Ionicons to tab bar (home/checkbox/thumbs-up/notifications/person) - Fix SafeAreaView import on home screen (react-native-safe-area-context) --- app/(auth)/login.tsx | 26 ++++-- app/(tabs)/_layout.tsx | 44 ++++++++- app/(tabs)/index.tsx | 3 +- babel.config.js | 7 ++ package-lock.json | 206 +++++++++++++++++++++++++++-------------- package.json | 8 +- src/lib/config.ts | 5 + src/lib/oidc.ts | 14 ++- src/store/auth.ts | 28 +----- 9 files changed, 226 insertions(+), 115 deletions(-) create mode 100644 babel.config.js diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index ead0432..ae4574e 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -26,23 +26,33 @@ export default function LoginScreen() { useEffect(() => { if (!response) return; + console.log("[login] auth response type:", response.type); if (response.type === "success") { + console.log("[login] auth params:", JSON.stringify(response.params)); + console.log("[login] request redirectUri:", request?.redirectUri); const code = response.params.code; - const verifier = request?.codeVerifier; - if (!code || !verifier) { - setError("OAuth response missing required parameters"); + if (!code) { + setError("OAuth response missing code parameter"); return; } + const redirectUri = request?.redirectUri ?? ""; setOidcLoading(true); setError(null); - exchangeCodeForToken(code, verifier) - .then((jwt) => signInWithJwt(jwt)) - .catch((err) => - setError(err instanceof Error ? err.message : "Google sign-in failed"), - ) + exchangeCodeForToken(code, redirectUri) + .then((jwt) => { + console.log("[login] got jwt, exchanging for mobile token"); + return signInWithJwt(jwt); + }) + .catch((err) => { + console.error("[login] sign-in error:", err); + setError(err instanceof Error ? err.message : "Google sign-in failed"); + }) .finally(() => setOidcLoading(false)); } else if (response.type === "error") { + console.error("[login] auth error:", JSON.stringify(response.error)); setError(response.error?.message ?? "Google sign-in failed"); + } else { + console.log("[login] auth response dismissed or cancelled"); } }, [response]); diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index ab0cc8b..0212cfe 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -2,10 +2,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { Tabs } from "expo-router"; import { Platform } from "react-native"; +import Ionicons from "@expo/vector-icons/Ionicons"; const PRIMARY = "#4CD964"; const INACTIVE = "#8E8E93"; +type IconName = React.ComponentProps["name"]; + +function icon(focused: boolean, on: IconName, off: IconName) { + return ({ color }: { color: string }) => ( + + ); +} + export default function TabLayout() { return ( + icon(focused, "home", "home-outline")({ color }), + }} /> + icon(focused, "checkbox", "checkbox-outline")({ color }), + }} /> + icon(focused, "thumbs-up", "thumbs-up-outline")({ color }), + }} /> + icon(focused, "notifications", "notifications-outline")({ color }), + }} /> + icon(focused, "person", "person-outline")({ color }), + }} /> ); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index ef8e436..dda5130 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,8 +1,9 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later import { - View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet, SafeAreaView, + View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet, } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { useCounts, useTodos, useNotifications } from "@/src/lib/queries"; function StatCard({ label, value, accent }: { label: string; value: number; accent?: string }) { 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 66dacd8..b956bbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,11 +27,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": { @@ -563,6 +565,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", @@ -981,6 +998,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", @@ -2550,6 +2597,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", @@ -3837,73 +3951,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", @@ -6112,6 +6159,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 a880a65..f2fc2c9 100644 --- a/package.json +++ b/package.json @@ -22,17 +22,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/lib/config.ts b/src/lib/config.ts index 57f498a..42ac323 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -9,3 +9,8 @@ export const ACCOUNT_SERVER_URL = export const INTERACTOR_CLIENT_ID = process.env.EXPO_PUBLIC_INTERACTOR_CLIENT_ID ?? ""; + +// TODO: replace with server-side token exchange proxy so the secret isn't +// bundled in the app binary. Acceptable for internal distribution only. +export const INTERACTOR_CLIENT_SECRET = + process.env.EXPO_PUBLIC_INTERACTOR_CLIENT_SECRET ?? ""; diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts index 51c6603..f21be03 100644 --- a/src/lib/oidc.ts +++ b/src/lib/oidc.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as AuthSession from "expo-auth-session"; import * as WebBrowser from "expo-web-browser"; -import { ACCOUNT_SERVER_URL, INTERACTOR_CLIENT_ID } from "@/src/lib/config"; +import { ACCOUNT_SERVER_URL, INTERACTOR_CLIENT_ID, INTERACTOR_CLIENT_SECRET } from "@/src/lib/config"; WebBrowser.maybeCompleteAuthSession(); @@ -20,28 +20,26 @@ export function makeRedirectUri() { export function useOidcRequest() { const redirectUri = makeRedirectUri(); + console.log("[oidc] redirect URI:", redirectUri); return AuthSession.useAuthRequest( { clientId: INTERACTOR_CLIENT_ID, scopes: ["openid", "profile", "email"], redirectUri, - usePKCE: true, + usePKCE: false, }, DISCOVERY, ); } -export async function exchangeCodeForToken( - code: string, - codeVerifier: string, -): Promise { - const redirectUri = makeRedirectUri(); +export async function exchangeCodeForToken(code: string, redirectUri: string): Promise { + console.log("[oidc] exchanging code with redirectUri:", redirectUri); const res = await AuthSession.exchangeCodeAsync( { clientId: INTERACTOR_CLIENT_ID, + clientSecret: INTERACTOR_CLIENT_SECRET || undefined, code, redirectUri, - extraParams: { code_verifier: codeVerifier }, }, DISCOVERY, ); diff --git a/src/store/auth.ts b/src/store/auth.ts index b875aa8..b6e1ee8 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -73,30 +73,12 @@ export const useAuthStore = create((set, get) => ({ const userId = (claims.sub as string) ?? ""; const email = (claims.email as string) ?? ""; - // Exchange 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", - }), - }); - 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 }; + // TODO: replace with proper mobile-session exchange once + // POST /api/v1/me/mobile-sessions is deployed on build.interactor.com. + // For now, store the user JWT directly so the app is usable during dev. + const session: StoredSession = { token: userJwt, userId, email }; await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(session)); - set({ token: mobileToken, userId, email }); + set({ token: userJwt, userId, email }); }, signIn: async (emailInput: string, password: string) => { From fdda3283482913bc6274da29b26eb5aa8163a57c Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 27 Jun 2026 21:44:26 -0700 Subject: [PATCH 03/15] feat(tabs): restructure to Overview/Goal/Approval/Feedback/Me MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Tasks and Inbox tabs; add Goal and Feedback tabs - Overview: stats grid (todos, approvals, tasks, unread) + recent todo list with pull-to-refresh - Goal: filter pills (All/Open/In Progress/Accepted) with GoalCard showing type badge, display number, status, priority dot, progress bar - Approvals: approve/reject actions with reject comment modal (bottom sheet) - Feedback: project picker scroll pills → feedback list with source, votes, status, rating - Me: fix SafeAreaView import (react-native → react-native-safe-area-context), add settings section rows - queries.ts: add EngineGoal, FeedbackEntry types; useGoals, useFeedback hooks; fix TodoProject typing --- app/(tabs)/_layout.tsx | 34 ++--- app/(tabs)/approvals.tsx | 270 +++++++++++++++++++++++++++++---------- app/(tabs)/feedback.tsx | 245 +++++++++++++++++++++++++++++++++++ app/(tabs)/goal.tsx | 232 +++++++++++++++++++++++++++++++++ app/(tabs)/inbox.tsx | 116 ----------------- app/(tabs)/index.tsx | 268 ++++++++++++++++++++++++++++++++++---- app/(tabs)/me.tsx | 118 +++++++++++++---- app/(tabs)/tasks.tsx | 100 --------------- src/lib/queries.ts | 97 ++++++++++++-- 9 files changed, 1117 insertions(+), 363 deletions(-) create mode 100644 app/(tabs)/feedback.tsx create mode 100644 app/(tabs)/goal.tsx delete mode 100644 app/(tabs)/inbox.tsx delete mode 100644 app/(tabs)/tasks.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 0212cfe..9f98aab 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -7,11 +7,11 @@ import Ionicons from "@expo/vector-icons/Ionicons"; const PRIMARY = "#4CD964"; const INACTIVE = "#8E8E93"; -type IconName = React.ComponentProps["name"]; +type IoniconName = React.ComponentProps["name"]; -function icon(focused: boolean, on: IconName, off: IconName) { +function tabIcon(focused: boolean, active: IoniconName, inactive: IoniconName) { return ({ color }: { color: string }) => ( - + ); } @@ -32,37 +32,37 @@ export default function TabLayout() { - icon(focused, "home", "home-outline")({ color }), + tabIcon(focused, "grid", "grid-outline")({ color }), }} /> - icon(focused, "checkbox", "checkbox-outline")({ color }), + tabIcon(focused, "flag", "flag-outline")({ color }), }} /> - icon(focused, "thumbs-up", "thumbs-up-outline")({ color }), + tabIcon(focused, "checkmark-circle", "checkmark-circle-outline")({ color }), }} /> - icon(focused, "notifications", "notifications-outline")({ color }), + tabIcon(focused, "chatbubble-ellipses", "chatbubble-ellipses-outline")({ color }), }} /> - icon(focused, "person", "person-outline")({ color }), + tabIcon(focused, "person", "person-outline")({ color }), }} /> diff --git a/app/(tabs)/approvals.tsx b/app/(tabs)/approvals.tsx index a8f77a8..56845cc 100644 --- a/app/(tabs)/approvals.tsx +++ b/app/(tabs)/approvals.tsx @@ -3,38 +3,73 @@ 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 { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; import { useTodos, useDecideApproval, type TodoItem } from "@/src/lib/queries"; type ApprovalItem = Extract; -function ApprovalRow({ +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 + + )} @@ -45,36 +80,32 @@ export default function ApprovalsScreen() { const { data, isLoading, isRefetching, refetch } = useTodos(); 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( (t): t is ApprovalItem => t.kind === "approval" ); - 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), - }), - }, - ]); - } + 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), } ); @@ -82,36 +113,55 @@ export default function ApprovalsScreen() { return ( + {/* Header */} Approvals {approvals.length > 0 && ( - {approvals.length} + + {approvals.length} + )} {isLoading ? ( - + + + ) : ( 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={ - No pending approvals + + All clear! + No pending approvals right now. } - 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 +198,116 @@ 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" }, + 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..d940d0f --- /dev/null +++ b/app/(tabs)/feedback.tsx @@ -0,0 +1,245 @@ +// 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 { useTodos, useFeedback, type FeedbackEntry } from "@/src/lib/queries"; + +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" }, +}; + +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 ( + + {/* Header row: source + time + vote count */} + + + + {source.label} + {item.authorName && ( + · {item.authorName} + )} + + + {item.voteCount > 0 && ( + + + {item.voteCount} + + )} + {timeLabel} + + + + {/* Title */} + {item.title} + + {/* Body preview */} + {item.body && ( + {item.body} + )} + + {/* Footer: status badge + rating */} + + + {status.label} + + {item.rating != null && ( + + + {item.rating}/5 + + )} + {item.groupName && ( + {item.groupName} + )} + + + ); +} + +export default function FeedbackScreen() { + const { data: todosData } = useTodos(); + const projects = todosData?.facets.projects ?? []; + + const [selectedProjectId, setSelectedProjectId] = useState(() => projects[0]?.id ?? ""); + const activeProjectId = selectedProjectId || projects[0]?.id || ""; + + const { data, isLoading, isRefetching, refetch } = useFeedback(activeProjectId); + const feedback = data?.data ?? []; + + return ( + + {/* Header */} + + Feedback + {feedback.length > 0 && ( + + {feedback.length} + + )} + + + {/* Project picker */} + {projects.length > 1 && ( + + {projects.map((p) => ( + setSelectedProjectId(p.id)} + > + + {p.name} + + + ))} + + )} + + {/* No projects fallback */} + {projects.length === 0 && !isLoading && ( + + + No projects + + You are not assigned to any projects yet. + + + )} + + {/* Feedback list */} + {projects.length > 0 && ( + isLoading ? ( + + + + ) : ( + item.id} + contentContainerStyle={feedback.length === 0 ? styles.emptyFill : styles.listContent} + refreshControl={ + + } + renderItem={({ item }) => } + ListEmptyComponent={ + + + No feedback + + No feedback has been submitted for this project yet. + + + } + /> + ) + )} + + ); +} + +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" }, + countBadge: { + backgroundColor: "#E5E5EA", + borderRadius: 9999, + paddingHorizontal: 8, + paddingVertical: 2, + }, + countText: { fontSize: 13, fontWeight: "600", color: "#8E8E93" }, + projectScroll: { maxHeight: 44 }, + projectRow: { + paddingHorizontal: 16, + gap: 8, + paddingBottom: 12, + }, + projectPill: { + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 9999, + backgroundColor: "#E5E5EA", + maxWidth: 200, + }, + projectPillActive: { backgroundColor: "#1C1C1E" }, + projectPillText: { fontSize: 13, color: "#1C1C1E", fontWeight: "500" }, + projectPillTextActive: { 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..d4e713a --- /dev/null +++ b/app/(tabs)/goal.tsx @@ -0,0 +1,232 @@ +// 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, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useGoals, type EngineGoal } from "@/src/lib/queries"; + +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" }, +}; + +const FILTERS = ["All", "Open", "In Progress", "Accepted"] as const; +type Filter = typeof FILTERS[number]; + +const FILTER_STATUS: Record = { + All: undefined, + Open: "open", + "In Progress": "in_progress", + Accepted: "accepted", +}; + +function GoalCard({ goal }: { goal: EngineGoal }) { + 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 { doneTasks, totalTasks } = goal.completion; + const progress = totalTasks > 0 ? doneTasks / totalTasks : 0; + + return ( + + {/* Top row: number + title + priority dot */} + + + + {type.label} + + {goal.displayNumber != null && ( + G#{goal.displayNumber} + )} + + + + + {goal.title} + + {/* Status + owner row */} + + + {status.label} + + {goal.ownerName && ( + {goal.ownerName} + )} + {goal.targetDate && ( + + + {" "}{new Date(goal.targetDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + + )} + + + {/* Progress bar (only if tasks exist) */} + {totalTasks > 0 && ( + + + + + {doneTasks}/{totalTasks} tasks + + )} + + ); +} + +export default function GoalScreen() { + const [filter, setFilter] = useState("All"); + const { data, isLoading, isRefetching, refetch } = useGoals(undefined, FILTER_STATUS[filter]); + + const goals = data?.data ?? []; + + return ( + + {/* Header */} + + Goals + {goals.length > 0 && ( + + {goals.length} + + )} + + + {/* Filter pills */} + + {FILTERS.map((f) => ( + setFilter(f)} + > + + {f} + + + ))} + + + {isLoading ? ( + + + + ) : ( + g.id} + contentContainerStyle={goals.length === 0 ? styles.emptyFill : styles.listContent} + refreshControl={ + + } + renderItem={({ item }) => } + ListEmptyComponent={ + + + No goals + + {filter === "All" ? "No goals found across your projects." : `No ${filter.toLowerCase()} goals.`} + + + } + /> + )} + + ); +} + +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" }, + 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, + }, + 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 dda5130..da1a6e7 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,21 +1,100 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later -import { - View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet, -} from "react-native"; +import { View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { useCounts, useTodos, useNotifications } from "@/src/lib/queries"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useCounts, useTodos, useNotifications, type TodoItem } from "@/src/lib/queries"; +import { useAuthStore } from "@/src/store/auth"; -function StatCard({ label, value, accent }: { label: string; value: number; accent?: string }) { +const PRIORITY_COLOR: Record = { + urgent: "#FF3B30", + high: "#FF9500", + medium: "#FFCC00", + low: "#C7C7CC", +}; + +const STATUS_LABEL: Record = { + todo: "To do", + in_progress: "In progress", + in_review: "In review", + blocked: "Blocked", + done: "Done", +}; + +function StatCard({ + icon, + label, + value, + accent, + bg, +}: { + icon: React.ComponentProps["name"]; + label: string; + value: number; + accent: string; + bg: string; +}) { return ( - - {value} + + + + + {value} {label} ); } -export default function HomeScreen() { +function TodoRow({ item }: { item: TodoItem }) { + if (item.kind === "approval") { + return ( + + + + {item.title} + {item.project.name} · Approval + + + Approval + + + ); + } + if (item.kind === "decision_ack") { + return ( + + + + {item.title} + {item.project.name} · Decision + + + Decision + + + ); + } + return ( + + + + {item.title} + {item.project.name} · {STATUS_LABEL[item.status] ?? item.status} + + {item.dueDate && ( + + {new Date(item.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + + )} + + ); +} + +export default function OverviewScreen() { + const { email } = useAuthStore(); const counts = useCounts(); const todos = useTodos(); const notifications = useNotifications(1); @@ -29,31 +108,83 @@ export default function HomeScreen() { notifications.refetch(); } - const totalTodos = counts.data?.globalTodosCount ?? 0; + const allTodos = todos.data?.data ?? []; 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 approvalCount = allTodos.filter((t) => t.kind === "approval").length; + const taskCount = allTodos.filter((t) => t.kind === "task").length; + const totalTodos = counts.data?.globalTodosCount ?? 0; + + const firstName = email?.split("@")[0]?.split(".")[0] ?? ""; + const greeting = firstName + ? `Hi, ${firstName.charAt(0).toUpperCase() + firstName.slice(1)}` + : "Overview"; return ( } + refreshControl={ + + } + showsVerticalScrollIndicator={false} > + {/* Header */} - Home + + {greeting} + Here's what needs your attention + + {unreadCount > 0 && ( + + + + {unreadCount > 9 ? "9+" : unreadCount} + + + )} {isLoading ? ( - + + + ) : ( <> - Overview + {/* Stats grid */} + Summary - - 0 ? "#FF9500" : undefined} /> - - 0 ? "#4CD964" : undefined} /> + + 0 ? "#FF9500" : "#C7C7CC"} bg={approvalCount > 0 ? "#FFF3E0" : "#F5F5F5"} /> + + 0 ? "#4CD964" : "#C7C7CC"} bg={unreadCount > 0 ? "#E8F8EB" : "#F5F5F5"} /> + + {/* Recent todos */} + {allTodos.length > 0 && ( + <> + + Recent + {allTodos.length} items + + + {allTodos.slice(0, 8).map((item) => ( + + ))} + {allTodos.length > 8 && ( + + +{allTodos.length - 8} more items + + )} + + + )} + + {allTodos.length === 0 && !isLoading && ( + + + All caught up! + No pending todos across your projects. + + )} )} @@ -63,12 +194,97 @@ export default function HomeScreen() { 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 }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 20, + }, + greeting: { fontSize: 26, fontWeight: "700", color: "#1C1C1E" }, + subGreeting: { fontSize: 13, color: "#8E8E93", marginTop: 2 }, + bellWrap: { position: "relative", padding: 4 }, + bellBadge: { + position: "absolute", + top: 0, + right: 0, + backgroundColor: "#4CD964", + borderRadius: 9999, + minWidth: 16, + height: 16, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 3, + }, + bellBadgeText: { fontSize: 10, fontWeight: "700", color: "#FFFFFF" }, + sectionLabel: { + fontSize: 11, + fontWeight: "600", + color: "#8E8E93", + textTransform: "uppercase", + letterSpacing: 0.8, + marginHorizontal: 16, + marginBottom: 8, + }, + sectionRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginHorizontal: 16, + marginBottom: 8, + marginTop: 8, + }, + sectionCount: { fontSize: 12, color: "#8E8E93" }, + statGrid: { + flexDirection: "row", + flexWrap: "wrap", + paddingHorizontal: 12, + gap: 8, + marginBottom: 24, + }, + statCard: { + width: "47%", + backgroundColor: "#FFFFFF", + borderRadius: 16, + padding: 16, + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, + }, + statIconWrap: { + width: 32, + height: 32, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + marginBottom: 10, + }, + statValue: { fontSize: 32, fontWeight: "700", color: "#1C1C1E", marginBottom: 2 }, + statLabel: { fontSize: 12, color: "#8E8E93", fontWeight: "500" }, + todoList: { marginHorizontal: 16, gap: 1, borderRadius: 16, overflow: "hidden", marginBottom: 24 }, + todoRow: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#FFFFFF", + paddingHorizontal: 14, + paddingVertical: 13, + gap: 10, + }, + kindDot: { width: 8, height: 8, borderRadius: 4, flexShrink: 0 }, + todoContent: { flex: 1 }, + todoTitle: { fontSize: 14, fontWeight: "500", color: "#1C1C1E", marginBottom: 2 }, + todoMeta: { fontSize: 12, color: "#8E8E93" }, + kindBadge: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 3 }, + kindBadgeText: { fontSize: 11, fontWeight: "600" }, + dueLabel: { fontSize: 11, color: "#8E8E93", fontWeight: "500" }, + dueLabelOverdue: { color: "#FF3B30", fontWeight: "600" }, + moreRow: { backgroundColor: "#FFFFFF", paddingVertical: 12, alignItems: "center" }, + moreText: { fontSize: 13, color: "#8E8E93" }, center: { paddingTop: 60, alignItems: "center" }, + emptyState: { alignItems: "center", paddingTop: 60, paddingHorizontal: 32 }, + emptyTitle: { fontSize: 20, fontWeight: "700", color: "#1C1C1E", marginTop: 12 }, + emptySubtitle: { fontSize: 14, color: "#8E8E93", textAlign: "center", marginTop: 6 }, }); diff --git a/app/(tabs)/me.tsx b/app/(tabs)/me.tsx index b39336d..a016c31 100644 --- a/app/(tabs)/me.tsx +++ b/app/(tabs)/me.tsx @@ -1,13 +1,18 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later import { - View, Text, TouchableOpacity, StyleSheet, SafeAreaView, Alert, + View, Text, TouchableOpacity, StyleSheet, Alert, } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Ionicons from "@expo/vector-icons/Ionicons"; import { useAuthStore } from "@/src/store/auth"; export default function MeScreen() { const { email, signOut } = useAuthStore(); + const username = email?.split("@")[0] ?? ""; + const initial = username[0]?.toUpperCase() ?? "?"; + function handleSignOut() { Alert.alert("Sign out", "Sign out of Build on this device?", [ { text: "Cancel", style: "cancel" }, @@ -25,55 +30,114 @@ export default function MeScreen() { Me - {email && ( - - - - {email[0]?.toUpperCase() ?? "?"} - - + {/* Profile card */} + + + {initial} + + + {username} {email} - )} + + + {/* Settings section */} + + Account + + + + Notification preferences + + + + + + Privacy & security + + + + - - Sign out - + {/* Sign out */} + + + + Sign out + + ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#F5F5F5" }, - header: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 }, + 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, + }, + menuLabel: { flex: 1, fontSize: 15, color: "#1C1C1E" }, + 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)/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/src/lib/queries.ts b/src/lib/queries.ts index 77f8980..f0c9a30 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"; @@ -49,7 +47,7 @@ export type TodoItem = export interface TodosResponse { data: TodoItem[]; - facets: { organizations: unknown[]; projects: unknown[]; types: { kind: TodoKind; count: number }[] }; + facets: { organizations: unknown[]; projects: TodoProject[]; types: { kind: TodoKind; count: number }[] }; meta: { total: number }; } @@ -58,13 +56,61 @@ export interface GlobalCounts { perOrg: Record; } -// ── Keys ───────────────────────────────────────────────────────────────────── +// ── Goal types ──────────────────────────────────────────────────────────────── + +export interface EngineGoal { + id: string; + title: string; + description: string | null; + status: string; + priority: string; + type: string; + displayNumber: number | null; + targetDate: string | null; + ownerName: string | null; + prState: string; + completion: { + doneTasks: number; + totalTasks: number; + complete: boolean; + }; +} + +export interface GoalsResponse { + data: EngineGoal[]; + meta: { total: 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 }; +} + +// ── 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, }; // ── Hooks ───────────────────────────────────────────────────────────────────── @@ -83,7 +129,8 @@ 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=urgency&limit=100`), }); } @@ -95,6 +142,28 @@ export function useCounts() { }); } +export function useGoals(projectId?: string, status?: string) { + return useQuery({ + queryKey: keys.goals(projectId, status), + queryFn: () => { + const params = new URLSearchParams(); + if (projectId) params.set("projectId", projectId); + if (status) params.set("status", status); + const qs = params.toString(); + return api.get(`/api/v1/me/engine/goals${qs ? `?${qs}` : ""}`); + }, + }); +} + +export function useFeedback(projectId: string) { + return useQuery({ + queryKey: keys.feedback(projectId), + queryFn: () => + api.get(`/api/v1/projects/${projectId}/feedback`), + enabled: !!projectId, + }); +} + export function useMarkNotificationRead() { const qc = useQueryClient(); return useMutation({ @@ -120,7 +189,15 @@ 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"] }); From 66e83375674eb476122905e610d8178dda4c9a08 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 27 Jun 2026 21:51:23 -0700 Subject: [PATCH 04/15] chore(deps): install @expo/vector-icons for Ionicons tab icons --- package-lock.json | 12 ++++++++++++ package.json | 1 + 2 files changed, 13 insertions(+) diff --git a/package-lock.json b/package-lock.json index b956bbe..cd12d82 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-auth-session": "~56.0.14", @@ -1502,6 +1503,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", diff --git a/package.json b/package.json index f2fc2c9..2381355 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-auth-session": "~56.0.14", From c4c727ef4bdfd8b98e1278037b5db9337d3c173c Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 27 Jun 2026 22:22:58 -0700 Subject: [PATCH 05/15] refactor(auth): replace custom OIDC/Bearer with NextAuth credentials + cookie session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile app is the frontend for the product-manager backend which uses NextAuth cookie-based auth. iOS's NSURLSession cookie jar automatically stores and sends the authjs.session-token cookie, so no manual token forwarding needed. - Login: GET /api/auth/csrf → POST /api/auth/callback/credentials → cookie jar stores the session token → GET /api/auth/session confirms the session - API client: remove Authorization Bearer header (cookie jar handles auth) - SSE: remove Bearer header (cookie jar handles auth) - Remove: expo-auth-session, expo-crypto, expo-web-browser, src/lib/oidc.ts - Remove: pm_mobile_* token storage, signInWithJwt, ACCOUNT_SERVER_URL config - Login screen: clean email/password form, no OIDC button --- app/(auth)/login.tsx | 173 +++++++++++++++--------------------------- package-lock.json | 40 ---------- package.json | 3 - src/lib/api-client.ts | 23 +++--- src/lib/config.ts | 11 --- src/lib/oidc.ts | 50 ------------ src/lib/sse.ts | 29 +++---- src/store/auth.ts | 157 +++++++++++++++++++++----------------- 8 files changed, 168 insertions(+), 318 deletions(-) delete mode 100644 src/lib/oidc.ts diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index ae4574e..232dca3 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 { useEffect, useState } from "react"; +import { useRef, useState } from "react"; import { View, Text, @@ -10,58 +10,24 @@ import { StyleSheet, KeyboardAvoidingView, Platform, + type TextInput as TextInputType, } from "react-native"; import { useAuthStore } from "@/src/store/auth"; -import { useOidcRequest, exchangeCodeForToken } from "@/src/lib/oidc"; export default function LoginScreen() { - const { signIn, signInWithJwt } = useAuthStore(); + const { signIn } = useAuthStore(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); - const [oidcLoading, setOidcLoading] = useState(false); const [error, setError] = useState(null); - - const [request, response, promptAsync] = useOidcRequest(); - - useEffect(() => { - if (!response) return; - console.log("[login] auth response type:", response.type); - if (response.type === "success") { - console.log("[login] auth params:", JSON.stringify(response.params)); - console.log("[login] request redirectUri:", request?.redirectUri); - const code = response.params.code; - if (!code) { - setError("OAuth response missing code parameter"); - return; - } - const redirectUri = request?.redirectUri ?? ""; - setOidcLoading(true); - setError(null); - exchangeCodeForToken(code, redirectUri) - .then((jwt) => { - console.log("[login] got jwt, exchanging for mobile token"); - return signInWithJwt(jwt); - }) - .catch((err) => { - console.error("[login] sign-in error:", err); - setError(err instanceof Error ? err.message : "Google sign-in failed"); - }) - .finally(() => setOidcLoading(false)); - } else if (response.type === "error") { - console.error("[login] auth error:", JSON.stringify(response.error)); - setError(response.error?.message ?? "Google sign-in failed"); - } else { - console.log("[login] auth response dismissed or cancelled"); - } - }, [response]); + const passwordRef = useRef(null); 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 { @@ -69,68 +35,60 @@ export default function LoginScreen() { } } - async function handleGoogleSignIn() { - setError(null); - await promptAsync(); - } - - const busy = loading || oidcLoading; - return ( + + + B + + + Build Project management for software teams - {error && {error}} - - - {oidcLoading ? ( - - ) : ( - <> - G - Continue with Google - - )} - - - - - or - - + {error && ( + + {error} + + )} { setEmail(v); setError(null); }} + onSubmitEditing={() => passwordRef.current?.focus()} + editable={!loading} /> { setPassword(v); setError(null); }} onSubmitEditing={handleSignIn} + editable={!loading} /> {loading ? ( @@ -145,50 +103,38 @@ export default function LoginScreen() { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#FFFFFF" }, - inner: { flex: 1, justifyContent: "center", padding: 24 }, - title: { - fontSize: 40, - fontWeight: "800", - color: "#1C1C1E", - marginBottom: 8, - }, - subtitle: { fontSize: 16, color: "#8E8E93", marginBottom: 40 }, - error: { - color: "#FF3B30", - fontSize: 14, - marginBottom: 16, - textAlign: "center", - }, - googleButton: { - height: 52, - borderRadius: 9999, - borderWidth: 1.5, - borderColor: "#E5E5EA", - backgroundColor: "#FFFFFF", - flexDirection: "row", + 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", - gap: 10, - marginBottom: 24, }, - googleIcon: { - fontSize: 18, + logoText: { fontSize: 32, fontWeight: "800", color: "#FFFFFF" }, + title: { + fontSize: 34, fontWeight: "800", - color: "#4285F4", - }, - googleButtonText: { color: "#1C1C1E", - fontWeight: "600", - fontSize: 16, + textAlign: "center", + marginBottom: 6, }, - dividerRow: { - flexDirection: "row", - alignItems: "center", - marginBottom: 24, - gap: 12, + subtitle: { + fontSize: 15, + color: "#8E8E93", + textAlign: "center", + marginBottom: 36, + }, + errorBox: { + backgroundColor: "#FFF0EF", + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + marginBottom: 16, }, - dividerLine: { flex: 1, height: 1, backgroundColor: "#E5E5EA" }, - dividerText: { fontSize: 14, color: "#8E8E93" }, + errorText: { color: "#FF3B30", fontSize: 14, textAlign: "center" }, input: { height: 52, borderRadius: 12, @@ -200,6 +146,7 @@ const styles = StyleSheet.create({ backgroundColor: "#F5F5F5", marginBottom: 12, }, + inputError: { borderColor: "#FF3B30" }, button: { height: 52, borderRadius: 9999, @@ -208,6 +155,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/package-lock.json b/package-lock.json index cd12d82..958cee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,13 @@ "@expo/vector-icons": "^15.0.2", "@tanstack/react-query": "^5.101.1", "expo": "~56.0.12", - "expo-auth-session": "~56.0.14", "expo-constants": "~56.0.18", - "expo-crypto": "~56.0.4", "expo-linking": "~56.0.14", "expo-notifications": "~56.0.18", "expo-router": "~56.2.11", "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", @@ -3408,24 +3405,6 @@ "expo": "*" } }, - "node_modules/expo-auth-session": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-56.0.14.tgz", - "integrity": "sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==", - "license": "MIT", - "dependencies": { - "expo-application": "~56.0.3", - "expo-constants": "~56.0.18", - "expo-crypto": "~56.0.4", - "expo-linking": "~56.0.14", - "expo-web-browser": "~56.0.5", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-constants": { "version": "56.0.18", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", @@ -3439,15 +3418,6 @@ "react-native": "*" } }, - "node_modules/expo-crypto": { - "version": "56.0.4", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz", - "integrity": "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-font": { "version": "56.0.7", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", @@ -3777,16 +3747,6 @@ "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", diff --git a/package.json b/package.json index 2381355..f83e890 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,13 @@ "@expo/vector-icons": "^15.0.2", "@tanstack/react-query": "^5.101.1", "expo": "~56.0.12", - "expo-auth-session": "~56.0.14", "expo-constants": "~56.0.18", - "expo-crypto": "~56.0.4", "expo-linking": "~56.0.14", "expo-notifications": "~56.0.18", "expo-router": "~56.2.11", "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", diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 7b188db..220d429 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -3,11 +3,11 @@ /** * 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: the NextAuth session cookie (authjs.session-token) is stored in iOS's + * NSURLSession shared cookie storage and Android's OkHttp cookie jar after + * login. Every fetch() to the same origin automatically includes it — no + * manual token forwarding needed. */ -import { useAuthStore } from "@/src/store/auth"; import { API_BASE_URL } from "@/src/lib/config"; export class ApiError extends Error { @@ -23,14 +23,13 @@ export class ApiError extends Error { } async function request(path: string, init?: RequestInit): Promise { - const { token } = 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 }); + const res = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers as Record), + }, + }); 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 42ac323..3a56670 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,14 +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"; - -export const INTERACTOR_CLIENT_ID = - process.env.EXPO_PUBLIC_INTERACTOR_CLIENT_ID ?? ""; - -// TODO: replace with server-side token exchange proxy so the secret isn't -// bundled in the app binary. Acceptable for internal distribution only. -export const INTERACTOR_CLIENT_SECRET = - process.env.EXPO_PUBLIC_INTERACTOR_CLIENT_SECRET ?? ""; diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts deleted file mode 100644 index f21be03..0000000 --- a/src/lib/oidc.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2026 Interactor, Inc. -// SPDX-License-Identifier: AGPL-3.0-or-later -import * as AuthSession from "expo-auth-session"; -import * as WebBrowser from "expo-web-browser"; -import { ACCOUNT_SERVER_URL, INTERACTOR_CLIENT_ID, INTERACTOR_CLIENT_SECRET } from "@/src/lib/config"; - -WebBrowser.maybeCompleteAuthSession(); - -const DISCOVERY = { - authorizationEndpoint: `${ACCOUNT_SERVER_URL}/oauth/authorize`, - tokenEndpoint: `${ACCOUNT_SERVER_URL}/oauth/token`, -}; - -export function makeRedirectUri() { - return AuthSession.makeRedirectUri({ - scheme: "buildapp", - path: "auth/callback", - }); -} - -export function useOidcRequest() { - const redirectUri = makeRedirectUri(); - console.log("[oidc] redirect URI:", redirectUri); - return AuthSession.useAuthRequest( - { - clientId: INTERACTOR_CLIENT_ID, - scopes: ["openid", "profile", "email"], - redirectUri, - usePKCE: false, - }, - DISCOVERY, - ); -} - -export async function exchangeCodeForToken(code: string, redirectUri: string): Promise { - console.log("[oidc] exchanging code with redirectUri:", redirectUri); - const res = await AuthSession.exchangeCodeAsync( - { - clientId: INTERACTOR_CLIENT_ID, - clientSecret: INTERACTOR_CLIENT_SECRET || undefined, - code, - redirectUri, - }, - DISCOVERY, - ); - // id_token is the user JWT we exchange for a pm_mobile_* token - const idToken = res.idToken ?? res.accessToken; - if (!idToken) throw new Error("No token in OIDC response"); - return idToken; -} diff --git a/src/lib/sse.ts b/src/lib/sse.ts index 98936bc..1ce13b0 100644 --- a/src/lib/sse.ts +++ b/src/lib/sse.ts @@ -3,15 +3,10 @@ /** * 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. + * The native cookie jar automatically includes the NextAuth session cookie. + * 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 +16,20 @@ 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; + if (!uid) return; esRef.current?.close(); + // The native cookie jar automatically sends the session cookie esRef.current = new EventSource( - `${API_BASE_URL}/api/sse/notifications:${uid}`, - { headers: { Authorization: `Bearer ${t}` } } + `${API_BASE_URL}/api/sse/notifications:${uid}` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -54,7 +45,7 @@ export function useNotificationSSE() { } useEffect(() => { - if (!token || !userId) { + if (!userId) { disconnect(); return; } @@ -71,5 +62,5 @@ export function useNotificationSSE() { sub.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token, userId]); + }, [userId]); } diff --git a/src/store/auth.ts b/src/store/auth.ts index b6e1ee8..dc6dd65 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -1,113 +1,130 @@ // 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 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) - * - * 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. + * Session strategy: + * - Login via POST /api/auth/callback/credentials → NextAuth sets the + * authjs.session-token cookie in iOS's NSURLSession shared cookie storage. + * - All subsequent fetch() calls to the same origin automatically include + * the cookie — no manual token forwarding needed. + * - We persist { userId, email } in SecureStore as the "logged-in marker" + * so we can restore the UI state without a network round-trip on every open. + * - On hydration, we validate with GET /api/auth/session to confirm the + * cookie is still valid. */ 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_user_v2"; -interface StoredSession { - token: string; +interface StoredUser { userId: string; email: string; + name: string | null; +} + +interface NextAuthSession { + user?: { + id?: string; + email?: string; + name?: string | null; + }; } interface AuthState { - token: string | null; - userId: string | null; email: string | null; + userId: string | null; hydrated: boolean; signIn: (email: string, password: string) => Promise; - signInWithJwt: (userJwt: 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 {}; - } +async function fetchCsrfToken(): Promise { + const res = await fetch(`${API_BASE_URL}/api/auth/csrf`); + const data = (await res.json()) as { csrfToken: string }; + return data.csrfToken; } -export const useAuthStore = create((set, get) => ({ - token: null, - userId: null, +async function fetchSession(): Promise { + 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, + }; +} + +export const useAuthStore = create((set) => ({ email: null, + userId: null, hydrated: false, hydrate: async () => { - const raw = await SecureStore.getItemAsync(SECURE_STORE_KEY); + // Fast path: restore from SecureStore without a network round-trip + 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 user = JSON.parse(raw) as StoredUser; + set({ email: user.email, userId: user.userId, hydrated: true }); + // Validate cookie is still active in the background + fetchSession() + .then((session) => { + if (!session) { + set({ email: null, userId: 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 }); + // No stored user — check live session (e.g. reinstall with cookie still valid) + const session = await fetchSession().catch(() => null); + if (session) { + set({ email: session.email, userId: session.userId, hydrated: true }); + void SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(session)); + } else { + set({ hydrated: true }); + } }, - signInWithJwt: async (userJwt: string) => { - // Decode user JWT (from OIDC id_token or password login) to extract userId + email - const claims = decodeJwtClaims(userJwt); - const userId = (claims.sub as string) ?? ""; - const email = (claims.email as string) ?? ""; + signIn: async (email: string, password: string) => { + // Step 1: get CSRF token — this also sets authjs.csrf-token in the cookie jar + const csrfToken = await fetchCsrfToken(); - // TODO: replace with proper mobile-session exchange once - // POST /api/v1/me/mobile-sessions is deployed on build.interactor.com. - // For now, store the user JWT directly so the app is usable during dev. - const session: StoredSession = { token: userJwt, userId, email }; - await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(session)); - set({ token: userJwt, userId, email }); - }, - - signIn: async (emailInput: string, password: string) => { - // Obtain short-lived user JWT from account-server via password - const loginRes = await fetch(`${ACCOUNT_SERVER_URL}/api/v1/admin/login`, { + // Step 2: submit credentials — cookie jar automatically includes the CSRF + // cookie; on success NextAuth sets authjs.session-token in the cookie jar + const body = new URLSearchParams({ email, password, csrfToken, redirect: "false" }); + await fetch(`${API_BASE_URL}/api/auth/callback/credentials`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: emailInput, password }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), }); - 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 }; - await get().signInWithJwt(userJwt); + + // Step 3: confirm session was established (cookie jar auto-sends the new cookie) + const session = await fetchSession(); + if (!session) throw new Error("Invalid email or password"); + + await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(session)); + set({ email: session.email, userId: session.userId }); }, 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`, { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }).catch(() => {}); - } - await SecureStore.deleteItemAsync(SECURE_STORE_KEY); - await SecureStore.deleteItemAsync("pm_mobile_token"); // clear legacy key too - set({ token: null, userId: null, email: null }); + // POST to NextAuth signout — it responds with Set-Cookie: Max-Age=0 + // which clears the session cookie from the native cookie jar + const csrfToken = await fetchCsrfToken().catch(() => ""); + await fetch(`${API_BASE_URL}/api/auth/signout`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ csrfToken }).toString(), + }).catch(() => {}); + await SecureStore.deleteItemAsync(SECURE_STORE_KEY).catch(() => {}); + set({ email: null, userId: null }); }, })); From f674b68b919fd777d7bdca8da2ff950b7b8f0800 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 27 Jun 2026 22:40:36 -0700 Subject: [PATCH 06/15] feat(auth): add Interactor SSO via product-manager backend + pm_mobile_* Bearer auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All sign-in methods (email/password and Interactor SSO) now end with a long-lived pm_mobile_* Bearer token stored in SecureStore. Every API call and SSE connection uses Authorization: Bearer pm_mobile_. Email/password flow: credentials POST → session cookie → exchange via POST /api/v1/me/mobile-sessions → store pm_mobile_*. Interactor SSO flow: openAuthSessionAsync opens the product-manager OIDC sign-in page → backend mobile-callback route mints pm_mobile_* and redirects to buildapp://auth/callback?token=...&email=...&userId=... → app captures it. The backend GET /api/auth/mobile-callback route is tracked in T#105 and requires a separate PR before the SSO button is fully functional. --- app/(auth)/login.tsx | 112 +++++++++++++++++++++++++-- app/(tabs)/_layout.tsx | 5 +- app/_layout.tsx | 14 ++-- package-lock.json | 11 +++ package.json | 1 + src/lib/api-client.ts | 25 ++++-- src/lib/sse.ts | 12 ++- src/store/auth.ts | 168 ++++++++++++++++++++++++++++++----------- 8 files changed, 276 insertions(+), 72 deletions(-) diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 232dca3..46f0114 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -12,13 +12,18 @@ import { 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); @@ -35,6 +40,46 @@ 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 ( Build Project management for software teams + {/* Interactor SSO (Google + others) */} + + {ssoLoading ? ( + + ) : ( + <> + + I + + Sign in with Interactor SSO + + )} + + + + + or continue with email + + + {error && ( {error} @@ -68,7 +138,7 @@ export default function LoginScreen() { value={email} onChangeText={(v) => { setEmail(v); setError(null); }} onSubmitEditing={() => passwordRef.current?.focus()} - editable={!loading} + editable={!busy} /> { setPassword(v); setError(null); }} onSubmitEditing={handleSignIn} - editable={!loading} + editable={!busy} /> {loading ? ( @@ -125,8 +195,38 @@ const styles = StyleSheet.create({ fontSize: 15, color: "#8E8E93", textAlign: "center", - marginBottom: 36, + 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, diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 9f98aab..91d9ced 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -10,8 +10,9 @@ const INACTIVE = "#8E8E93"; type IoniconName = React.ComponentProps["name"]; function tabIcon(focused: boolean, active: IoniconName, inactive: IoniconName) { - return ({ color }: { color: string }) => ( - + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ({ color }: { color: any }) => ( + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 7664331..db16b2d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -21,7 +21,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,20 +30,20 @@ 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) { + } 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(); diff --git a/package-lock.json b/package-lock.json index 958cee7..9729287 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,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", @@ -3747,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", diff --git a/package.json b/package.json index f83e890..36d73b2 100644 --- a/package.json +++ b/package.json @@ -13,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", diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 220d429..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. * - * Auth: the NextAuth session cookie (authjs.session-token) is stored in iOS's - * NSURLSession shared cookie storage and Android's OkHttp cookie jar after - * login. Every fetch() to the same origin automatically includes it — no - * manual token forwarding needed. + * 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 { API_BASE_URL } from "@/src/lib/config"; +import { useAuthStore } from "@/src/store/auth"; export class ApiError extends Error { constructor( @@ -23,13 +23,22 @@ export class ApiError extends Error { } async function request(path: string, init?: RequestInit): Promise { + const { mobileToken } = useAuthStore.getState(); + + const headers: Record = { + "Content-Type": "application/json", + ...(init?.headers as Record), + }; + + if (mobileToken) { + headers["Authorization"] = `Bearer ${mobileToken}`; + } + const res = await fetch(`${API_BASE_URL}${path}`, { ...init, - headers: { - "Content-Type": "application/json", - ...(init?.headers as Record), - }, + headers, }); + if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string; diff --git a/src/lib/sse.ts b/src/lib/sse.ts index 1ce13b0..78ec662 100644 --- a/src/lib/sse.ts +++ b/src/lib/sse.ts @@ -4,7 +4,8 @@ * SSE foreground connection for real-time notification updates. * * Connects to /api/sse/notifications: while the app is foregrounded. - * The native cookie jar automatically includes the NextAuth session cookie. + * 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. */ @@ -24,12 +25,17 @@ export function useNotificationSSE() { function connect() { const uid = userIdRef.current; + const { mobileToken } = useAuthStore.getState(); if (!uid) return; esRef.current?.close(); - // The native cookie jar automatically sends the session cookie + const headers: Record = {}; + if (mobileToken) { + headers["Authorization"] = `Bearer ${mobileToken}`; + } esRef.current = new EventSource( - `${API_BASE_URL}/api/sse/notifications:${uid}` + `${API_BASE_URL}/api/sse/notifications:${uid}`, + { headers } ); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/store/auth.ts b/src/store/auth.ts index dc6dd65..943fd7c 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -1,28 +1,38 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later /** - * Auth store for the mobile companion app. + * Auth store for the Build mobile companion app. * - * Session strategy: - * - Login via POST /api/auth/callback/credentials → NextAuth sets the - * authjs.session-token cookie in iOS's NSURLSession shared cookie storage. - * - All subsequent fetch() calls to the same origin automatically include - * the cookie — no manual token forwarding needed. - * - We persist { userId, email } in SecureStore as the "logged-in marker" - * so we can restore the UI state without a network round-trip on every open. - * - On hydration, we validate with GET /api/auth/session to confirm the - * cookie is still valid. + * 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. + * + * 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 } from "@/src/lib/config"; -const SECURE_STORE_KEY = "pm_session_user_v2"; +const SECURE_STORE_KEY = "pm_session_v3"; -interface StoredUser { +interface StoredSession { userId: string; email: string; name: string | null; + mobileToken: string; + mobileSessionId?: string; } interface NextAuthSession { @@ -33,11 +43,21 @@ interface NextAuthSession { }; } +interface MobileSessionResponse { + data?: { + id?: string; + token?: string; + }; +} + interface AuthState { 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; } @@ -48,7 +68,7 @@ async function fetchCsrfToken(): Promise { return data.csrfToken; } -async function fetchSession(): Promise { +async function fetchSession(): Promise<{ userId: string; email: string; name: string | null } | null> { const res = await fetch(`${API_BASE_URL}/api/auth/session`); const data = (await res.json()) as NextAuthSession | null; if (!data?.user?.id) return null; @@ -59,23 +79,51 @@ async function fetchSession(): Promise { }; } -export const useAuthStore = create((set) => ({ +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) => ({ email: null, userId: null, + mobileToken: null, + mobileSessionId: null, hydrated: false, hydrate: async () => { - // Fast path: restore from SecureStore without a network round-trip const raw = await SecureStore.getItemAsync(SECURE_STORE_KEY).catch(() => null); if (raw) { try { - const user = JSON.parse(raw) as StoredUser; - set({ email: user.email, userId: user.userId, hydrated: true }); - // Validate cookie is still active in the background - fetchSession() - .then((session) => { - if (!session) { - set({ email: null, userId: null }); + 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); } }) @@ -83,48 +131,76 @@ export const useAuthStore = create((set) => ({ return; } catch {} } - // No stored user — check live session (e.g. reinstall with cookie still valid) - const session = await fetchSession().catch(() => null); - if (session) { - set({ email: session.email, userId: session.userId, hydrated: true }); - void SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(session)); - } else { - set({ hydrated: true }); - } + set({ hydrated: true }); }, signIn: async (email: string, password: string) => { - // Step 1: get CSRF token — this also sets authjs.csrf-token in the cookie jar + // Step 1: CSRF token (also sets csrf cookie in iOS cookie jar) const csrfToken = await fetchCsrfToken(); - // Step 2: submit credentials — cookie jar automatically includes the CSRF - // cookie; on success NextAuth sets authjs.session-token in the cookie jar - const body = new URLSearchParams({ email, password, csrfToken, redirect: "false" }); + // Step 2: Credentials login — session cookie set in iOS cookie jar + const body = new URLSearchParams({ + email, + password, + csrfToken, + redirect: "false", + }); await 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 was established (cookie jar auto-sends the new cookie) + // Step 3: Confirm session const session = await fetchSession(); if (!session) throw new Error("Invalid email or password"); - await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(session)); - set({ email: session.email, userId: session.userId }); + // 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, + }); + }, + + 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 () => { - // POST to NextAuth signout — it responds with Set-Cookie: Max-Age=0 - // which clears the session cookie from the native cookie jar - const csrfToken = await fetchCsrfToken().catch(() => ""); - await fetch(`${API_BASE_URL}/api/auth/signout`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ csrfToken }).toString(), - }).catch(() => {}); + 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 ${mobileToken}` }, + }).catch(() => {}); + } + await SecureStore.deleteItemAsync(SECURE_STORE_KEY).catch(() => {}); - set({ email: null, userId: null }); + set({ email: null, userId: null, mobileToken: null, mobileSessionId: null }); }, })); From 1f381a06d5f528bd3738efc240080e9b42b5b52c Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Mon, 29 Jun 2026 01:50:42 -0700 Subject: [PATCH 07/15] feat(notifications): add Notifications inbox tab replacing Feedback Full notification inbox with All/Unread filter pills, per-notification mark-as-read, mark-all-as-read, type-based icons, and page navigation. Replaces the Feedback tab with Inbox (more action-critical on mobile). --- app/(tabs)/_layout.tsx | 8 +- app/(tabs)/notifications.tsx | 348 +++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 app/(tabs)/notifications.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 91d9ced..cb3d2c8 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -58,12 +58,12 @@ export default function TabLayout() { }} /> - tabIcon(focused, "chatbubble-ellipses", "chatbubble-ellipses-outline")({ color }), + tabIcon(focused, "notifications", "notifications-outline")({ color }), }} /> ; + 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" }, +}); From 8b15a20e884f9b7868d5da17e44b6a2ce45ac1d9 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Fri, 10 Jul 2026 20:12:20 -0700 Subject: [PATCH 08/15] =?UTF-8?q?feat(ui):=20mirror=20web=20mobile=20shell?= =?UTF-8?q?=20=E2=80=94=20Overview/Goal/AI/Approval/Feedback=20tabs,=20glo?= =?UTF-8?q?bal=20header,=20web=20design=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab bar now matches product-manager's mobile-bottom-nav: Overview, Goal, center AI action (Ask the Senior PM bottom sheet), Approval, Feedback - Notifications + Me move to a global top bar (bell with unread badge, avatar) - src/lib/theme.ts: design tokens copied from the web palette - src/lib/responsive.ts: md/lg breakpoints + useBreakpoint for tablet grids - app/(tabs)/ai.tsx: AI screen stub that hands off to web /ai chat - app.json: enable all orientations for iPad/tablet --- app.json | 2 +- app/(tabs)/_layout.tsx | 187 +++++++++++++++++++---------- app/(tabs)/ai.tsx | 96 +++++++++++++++ app/(tabs)/approvals.tsx | 2 +- app/(tabs)/feedback.tsx | 2 +- app/(tabs)/goal.tsx | 2 +- app/(tabs)/index.tsx | 2 +- app/(tabs)/me.tsx | 2 +- app/(tabs)/notifications.tsx | 2 +- src/components/ai-prompt-sheet.tsx | 145 ++++++++++++++++++++++ src/components/app-header.tsx | 119 ++++++++++++++++++ src/lib/responsive.ts | 39 ++++++ src/lib/theme.ts | 58 +++++++++ 13 files changed, 590 insertions(+), 68 deletions(-) create mode 100644 app/(tabs)/ai.tsx create mode 100644 src/components/ai-prompt-sheet.tsx create mode 100644 src/components/app-header.tsx create mode 100644 src/lib/responsive.ts create mode 100644 src/lib/theme.ts diff --git a/app.json b/app.json index 405b05e..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", diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index cb3d2c8..c05be1e 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,80 +1,145 @@ // 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 + * (product-manager/src/components/layout/mobile-bottom-nav.tsx): + * + * Overview · Goal · [AI center action] · Approval · Feedback + * + * As on web, the center AI button opens the "Ask the Senior PM" prompt sheet + * instead of navigating, and Notifications + Me live in the global top bar + * (bell + avatar), not in the tab bar. + */ +import { useState } from "react"; +import { Tabs, useRouter } from "expo-router"; +import { View, Text, TouchableOpacity, Platform, StyleSheet } from "react-native"; import Ionicons from "@expo/vector-icons/Ionicons"; - -const PRIMARY = "#4CD964"; -const INACTIVE = "#8E8E93"; +import { AppHeader } from "@/src/components/app-header"; +import { AiPromptSheet } from "@/src/components/ai-prompt-sheet"; +import { colors } from "@/src/lib/theme"; type IoniconName = React.ComponentProps["name"]; function tabIcon(focused: boolean, active: IoniconName, inactive: IoniconName) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return ({ color }: { color: any }) => ( - + + ); +} + +/** Center AI action — protruding green circle, like the web bottom nav. */ +function AiTabButton({ onPress }: { onPress: () => void }) { + return ( + + + + + AI + ); } export default function TabLayout() { + const [aiOpen, setAiOpen] = useState(false); + const router = useRouter(); + return ( - - - tabIcon(focused, "grid", "grid-outline")({ color }), - }} - /> - - tabIcon(focused, "flag", "flag-outline")({ color }), + + + - - tabIcon(focused, "checkmark-circle", "checkmark-circle-outline")({ color }), - }} - /> - - tabIcon(focused, "notifications", "notifications-outline")({ color }), - }} - /> - - tabIcon(focused, "person", "person-outline")({ color }), + > + + tabIcon(focused, "grid", "grid-outline")({ color }), + }} + /> + + tabIcon(focused, "flag", "flag-outline")({ color }), + }} + /> + setAiOpen(true)} />, + }} + /> + + tabIcon(focused, "checkmark-circle", "checkmark-circle-outline")({ color }), + }} + /> + + tabIcon(focused, "chatbubble-ellipses", "chatbubble-ellipses-outline")({ color }), + }} + /> + {/* Reachable from the top bar (bell / avatar), hidden from the tab bar */} + + + + + 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", justifyContent: "flex-end", paddingBottom: 2 }, + aiCircle: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.primary, + alignItems: "center", + justifyContent: "center", + marginTop: -18, + shadowColor: "#000", + shadowOpacity: 0.15, + shadowRadius: 6, + shadowOffset: { width: 0, height: 2 }, + elevation: 4, + }, + aiLabel: { fontSize: 10, fontWeight: "500", color: colors.textSecondary, marginTop: 2 }, +}); 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 56845cc..a6ebed5 100644 --- a/app/(tabs)/approvals.tsx +++ b/app/(tabs)/approvals.tsx @@ -112,7 +112,7 @@ export default function ApprovalsScreen() { } return ( - + {/* Header */} Approvals diff --git a/app/(tabs)/feedback.tsx b/app/(tabs)/feedback.tsx index d940d0f..cc0bef9 100644 --- a/app/(tabs)/feedback.tsx +++ b/app/(tabs)/feedback.tsx @@ -94,7 +94,7 @@ export default function FeedbackScreen() { const feedback = data?.data ?? []; return ( - + {/* Header */} Feedback diff --git a/app/(tabs)/goal.tsx b/app/(tabs)/goal.tsx index d4e713a..8eaeb63 100644 --- a/app/(tabs)/goal.tsx +++ b/app/(tabs)/goal.tsx @@ -99,7 +99,7 @@ export default function GoalScreen() { const goals = data?.data ?? []; return ( - + {/* Header */} Goals diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index da1a6e7..bdf3724 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -120,7 +120,7 @@ export default function OverviewScreen() { : "Overview"; return ( - + diff --git a/app/(tabs)/me.tsx b/app/(tabs)/me.tsx index a016c31..b5cad78 100644 --- a/app/(tabs)/me.tsx +++ b/app/(tabs)/me.tsx @@ -25,7 +25,7 @@ export default function MeScreen() { } return ( - + Me diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx index f07d803..da2e09e 100644 --- a/app/(tabs)/notifications.tsx +++ b/app/(tabs)/notifications.tsx @@ -121,7 +121,7 @@ export default function NotificationsScreen() { } return ( - + {/* Header */} 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..f12cab1 --- /dev/null +++ b/src/components/app-header.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Global top bar, mirroring the web app's global nav on mobile: + * brand on the left, notifications bell (unread badge) + user avatar on the + * right. On web, Notifications and the user menu live here rather than in + * the bottom tab bar — same on native. + */ +import { View, Text, TouchableOpacity, StyleSheet } 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 { useNotifications } from "@/src/lib/queries"; +import { colors } from "@/src/lib/theme"; + +export function AppHeader() { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { email } = useAuthStore(); + const { data } = useNotifications(1); + const unread = data?.meta.unreadCount ?? 0; + + const initials = (email ?? "?").slice(0, 2).toUpperCase(); + + return ( + + + {/* Brand */} + + + B + + + Interactor BUILD + + + + + {/* Notifications bell */} + router.push("/(tabs)/notifications" as never)} + accessibilityLabel="Notifications" + hitSlop={8} + > + + {unread > 0 && ( + + {unread > 99 ? "99+" : unread} + + )} + + + {/* User avatar → Me */} + router.push("/(tabs)/me" as never)} + accessibilityLabel="Account" + hitSlop={8} + > + {initials} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.background, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + bar: { + height: 48, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + }, + brand: { flexDirection: "row", alignItems: "center", gap: 8 }, + brandIcon: { + width: 24, + height: 24, + borderRadius: 6, + backgroundColor: colors.primary, + alignItems: "center", + justifyContent: "center", + }, + brandIconText: { color: "#FFFFFF", fontWeight: "800", fontSize: 13 }, + brandText: { fontSize: 14, color: colors.text }, + brandBold: { fontWeight: "800" }, + actions: { flexDirection: "row", alignItems: "center", gap: 14 }, + iconButton: { padding: 2 }, + badge: { + position: "absolute", + top: -4, + right: -6, + minWidth: 16, + height: 16, + borderRadius: 8, + backgroundColor: colors.danger, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 3, + }, + badgeText: { color: "#FFFFFF", fontSize: 9, fontWeight: "700" }, + avatar: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: colors.surfaceSecondary, + alignItems: "center", + justifyContent: "center", + }, + avatarText: { fontSize: 11, fontWeight: "700", color: colors.text }, +}); 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/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; From 79cb1cdbc2e29ddb456018d346f5806a3d568be1 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Fri, 10 Jul 2026 20:25:03 -0700 Subject: [PATCH 09/15] feat(projects): add global project switcher mirroring the web nav - useProjects hook (GET /api/v1/me/projects) + persisted selected-project store (SecureStore), null = All projects / global scope - Header brand becomes the switcher trigger showing the pinned project, opening an org-grouped bottom sheet (web project-switcher parity) - Goals filter by the pinned project; Feedback uses it and hides its local pills while a project is pinned --- app/(tabs)/feedback.tsx | 14 +- app/(tabs)/goal.tsx | 13 +- src/components/app-header.tsx | 31 ++++- src/components/project-switcher-sheet.tsx | 148 ++++++++++++++++++++++ src/lib/queries.ts | 24 ++++ src/store/project.ts | 47 +++++++ 6 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 src/components/project-switcher-sheet.tsx create mode 100644 src/store/project.ts diff --git a/app/(tabs)/feedback.tsx b/app/(tabs)/feedback.tsx index cc0bef9..eace0a5 100644 --- a/app/(tabs)/feedback.tsx +++ b/app/(tabs)/feedback.tsx @@ -8,6 +8,7 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import Ionicons from "@expo/vector-icons/Ionicons"; import { useTodos, useFeedback, type FeedbackEntry } from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; const STATUS_CONFIG: Record = { open: { label: "Open", color: "#8E8E93", bg: "#F5F5F5" }, @@ -85,10 +86,13 @@ function FeedbackCard({ item }: { item: FeedbackEntry }) { export default function FeedbackScreen() { const { data: todosData } = useTodos(); + const globalProject = useProjectStore((s) => s.selected); const projects = todosData?.facets.projects ?? []; const [selectedProjectId, setSelectedProjectId] = useState(() => projects[0]?.id ?? ""); - const activeProjectId = selectedProjectId || projects[0]?.id || ""; + // Header switcher selection wins; local pills refine within global scope. + const activeProjectId = + globalProject?.id ?? (selectedProjectId || projects[0]?.id || ""); const { data, isLoading, isRefetching, refetch } = useFeedback(activeProjectId); const feedback = data?.data ?? []; @@ -105,8 +109,8 @@ export default function FeedbackScreen() { )} - {/* Project picker */} - {projects.length > 1 && ( + {/* Project picker (hidden when the header switcher pins a project) */} + {!globalProject && projects.length > 1 && ( No projects @@ -142,7 +146,7 @@ export default function FeedbackScreen() { )} {/* Feedback list */} - {projects.length > 0 && ( + {!!activeProjectId && ( isLoading ? ( diff --git a/app/(tabs)/goal.tsx b/app/(tabs)/goal.tsx index 8eaeb63..e16be8a 100644 --- a/app/(tabs)/goal.tsx +++ b/app/(tabs)/goal.tsx @@ -8,6 +8,7 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import Ionicons from "@expo/vector-icons/Ionicons"; import { useGoals, type EngineGoal } from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; const STATUS_CONFIG: Record = { open: { label: "Open", color: "#8E8E93", bg: "#F5F5F5" }, @@ -94,7 +95,11 @@ function GoalCard({ goal }: { goal: EngineGoal }) { export default function GoalScreen() { const [filter, setFilter] = useState("All"); - const { data, isLoading, isRefetching, refetch } = useGoals(undefined, FILTER_STATUS[filter]); + const selectedProject = useProjectStore((s) => s.selected); + const { data, isLoading, isRefetching, refetch } = useGoals( + selectedProject?.id, + FILTER_STATUS[filter] + ); const goals = data?.data ?? []; @@ -143,7 +148,11 @@ export default function GoalScreen() { No goals - {filter === "All" ? "No goals found across your projects." : `No ${filter.toLowerCase()} goals.`} + {filter === "All" + ? selectedProject + ? `No goals found in ${selectedProject.name}.` + : "No goals found across your projects." + : `No ${filter.toLowerCase()} goals.`} } diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index f12cab1..5aa36d4 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -6,35 +6,52 @@ * right. On web, Notifications and the user menu live here rather than in * the bottom tab bar — same on native. */ +import { useState } from "react"; import { View, Text, TouchableOpacity, StyleSheet } 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 { useNotifications } from "@/src/lib/queries"; +import { ProjectSwitcherSheet } from "@/src/components/project-switcher-sheet"; import { colors } from "@/src/lib/theme"; export function AppHeader() { const insets = useSafeAreaInsets(); const router = useRouter(); const { email } = useAuthStore(); + const { selected } = useProjectStore(); const { data } = useNotifications(1); const unread = data?.meta.unreadCount ?? 0; + const [switcherOpen, setSwitcherOpen] = useState(false); const initials = (email ?? "?").slice(0, 2).toUpperCase(); return ( - {/* Brand */} - + {/* Brand + project switcher (like the web global nav) */} + setSwitcherOpen(true)} + activeOpacity={0.7} + accessibilityLabel="Switch project" + > B - - Interactor BUILD + + {selected ? ( + {selected.name} + ) : ( + <> + Interactor BUILD + + )} - + + {/* Notifications bell */} @@ -63,6 +80,8 @@ export function AppHeader() { + + setSwitcherOpen(false)} /> ); } @@ -80,7 +99,7 @@ const styles = StyleSheet.create({ justifyContent: "space-between", paddingHorizontal: 16, }, - brand: { flexDirection: "row", alignItems: "center", gap: 8 }, + brand: { flexDirection: "row", alignItems: "center", gap: 8, flexShrink: 1, paddingRight: 8 }, brandIcon: { width: 24, height: 24, 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/queries.ts b/src/lib/queries.ts index f0c9a30..50d2132 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -101,6 +101,20 @@ export interface FeedbackResponse { meta: { total: number }; } +// ── Project types ───────────────────────────────────────────────────────────── + +export interface ProjectSummary { + id: string; + name: string; + slug: string; + orgSlug: string; + orgName: string; +} + +export interface ProjectsResponse { + data: ProjectSummary[]; +} + // ── Query keys ──────────────────────────────────────────────────────────────── export const keys = { @@ -111,6 +125,7 @@ export const keys = { goals: (projectId?: string, status?: string) => ["goals", projectId, status] as const, feedback: (projectId: string) => ["feedback", projectId] as const, + projects: () => ["projects"] as const, }; // ── Hooks ───────────────────────────────────────────────────────────────────── @@ -164,6 +179,15 @@ export function useFeedback(projectId: string) { }); } +/** 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({ 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(); From d7302e451a8b02affbf6f56568ae8e6d95399671 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Fri, 10 Jul 2026 20:40:09 -0700 Subject: [PATCH 10/15] feat(screens): goal detail, admissions tab, phases section + fix API shapes to match backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against product-manager origin/main route handlers: - /me/todos returns { data: { items, facets } } — fix TodosResponse and all consumers (Overview, Approvals, Feedback) - /me/engine/goals REQUIRES projectId and returns bare EngineGoal rows (no completion/ownerName) — Goals tab now scopes to the pinned project (falling back to the user's first project) and renders real fields (PR state, release, paused) - New goal detail screen (app/goal/[goalId]) on GET /me/engine/goals/[id]: status/priority/type pills, description, completion progress, deploy gate (mergeable/CI/must-fix), PR link, release/target date, attachments - Approvals: Requests | Admissions tabs — admissions list from GET /me/engine/approvals with the AI admission brief (verdict + summary), auto-approved provenance hides decide buttons - Overview: phase breakdown section for the pinned project (GET /projects/[id]/phases) --- app/(tabs)/approvals.tsx | 182 +++++++++++++++++++++++- app/(tabs)/feedback.tsx | 2 +- app/(tabs)/goal.tsx | 84 +++++++---- app/(tabs)/index.tsx | 50 ++++++- app/goal/[goalId].tsx | 292 +++++++++++++++++++++++++++++++++++++++ src/lib/queries.ts | 148 +++++++++++++++++--- 6 files changed, 702 insertions(+), 56 deletions(-) create mode 100644 app/goal/[goalId].tsx diff --git a/app/(tabs)/approvals.tsx b/app/(tabs)/approvals.tsx index a6ebed5..1c6e0f5 100644 --- a/app/(tabs)/approvals.tsx +++ b/app/(tabs)/approvals.tsx @@ -7,10 +7,102 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Ionicons from "@expo/vector-icons/Ionicons"; -import { useTodos, useDecideApproval, type TodoItem } from "@/src/lib/queries"; +import { + useTodos, + useDecideApproval, + useAdmissionApprovals, + type TodoItem, + type AdmissionApproval, +} from "@/src/lib/queries"; type ApprovalItem = Extract; +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, onApprove, @@ -76,16 +168,27 @@ function ApprovalCard({ ); } +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 [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 ?? []; + + 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?", [ @@ -116,18 +219,31 @@ export default function ApprovalsScreen() { {/* Header */} Approvals - {approvals.length > 0 && ( + {badgeCount > 0 && ( - {approvals.length} + {badgeCount} )} + {/* Tabs: general approval requests vs goal-admission approvals */} + + {TABS.map((t) => ( + setTab(t)} + > + {t} + + ))} + + {isLoading ? ( - ) : ( + ) : tab === "Requests" ? ( item.id} @@ -151,6 +267,30 @@ export default function ApprovalsScreen() { } /> + ) : ( + 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={ + + + All clear! + No goal admissions waiting on you. + + } + /> )} {/* Reject bottom sheet */} @@ -209,6 +349,36 @@ const styles = StyleSheet.create({ heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, 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", diff --git a/app/(tabs)/feedback.tsx b/app/(tabs)/feedback.tsx index eace0a5..d6d3e14 100644 --- a/app/(tabs)/feedback.tsx +++ b/app/(tabs)/feedback.tsx @@ -87,7 +87,7 @@ function FeedbackCard({ item }: { item: FeedbackEntry }) { export default function FeedbackScreen() { const { data: todosData } = useTodos(); const globalProject = useProjectStore((s) => s.selected); - const projects = todosData?.facets.projects ?? []; + const projects = todosData?.data.facets.projects ?? []; const [selectedProjectId, setSelectedProjectId] = useState(() => projects[0]?.id ?? ""); // Header switcher selection wins; local pills refine within global scope. diff --git a/app/(tabs)/goal.tsx b/app/(tabs)/goal.tsx index e16be8a..5c49d83 100644 --- a/app/(tabs)/goal.tsx +++ b/app/(tabs)/goal.tsx @@ -7,7 +7,8 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Ionicons from "@expo/vector-icons/Ionicons"; -import { useGoals, type EngineGoal } from "@/src/lib/queries"; +import { useRouter } from "expo-router"; +import { useGoals, useProjects, type EngineGoal } from "@/src/lib/queries"; import { useProjectStore } from "@/src/store/project"; const STATUS_CONFIG: Record = { @@ -40,16 +41,22 @@ const FILTER_STATUS: Record = { Accepted: "accepted", }; -function GoalCard({ goal }: { goal: EngineGoal }) { +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 { doneTasks, totalTasks } = goal.completion; - const progress = totalTasks > 0 ? doneTasks / totalTasks : 0; + const pr = PR_STATE_CONFIG[goal.prState]; return ( - - {/* Top row: number + title + priority dot */} + + {/* Top row: type + number + priority dot */} @@ -64,13 +71,20 @@ function GoalCard({ goal }: { goal: EngineGoal }) { {goal.title} - {/* Status + owner row */} + {/* Status + PR + release + target date */} - {status.label} + + {goal.pausedAt ? "Paused" : status.label} + - {goal.ownerName && ( - {goal.ownerName} + {pr && ( + + {pr.label} + + )} + {goal.release && ( + {goal.release.name} )} {goal.targetDate && ( @@ -79,25 +93,22 @@ function GoalCard({ goal }: { goal: EngineGoal }) { )} - - {/* Progress bar (only if tasks exist) */} - {totalTasks > 0 && ( - - - - - {doneTasks}/{totalTasks} tasks - - )} - + ); } export default function GoalScreen() { + const router = useRouter(); const [filter, setFilter] = useState("All"); - const selectedProject = useProjectStore((s) => s.selected); + 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( - selectedProject?.id, + activeProject?.id, FILTER_STATUS[filter] ); @@ -114,6 +125,12 @@ export default function GoalScreen() { )} + {activeProject && ( + + {activeProject.name} + {!pinnedProject && " · pin a project from the top bar to change"} + + )} {/* Filter pills */} @@ -142,17 +159,19 @@ export default function GoalScreen() { refreshControl={ } - renderItem={({ item }) => } + renderItem={({ item }) => ( + router.push(`/goal/${item.id}` as never)} /> + )} ListEmptyComponent={ No goals - {filter === "All" - ? selectedProject - ? `No goals found in ${selectedProject.name}.` - : "No goals found across your projects." - : `No ${filter.toLowerCase()} goals.`} + {activeProject + ? filter === "All" + ? `No goals found in ${activeProject.name}.` + : `No ${filter.toLowerCase()} goals.` + : "Join a project to see its goals."} } @@ -173,6 +192,13 @@ const styles = StyleSheet.create({ 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, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index bdf3724..e58517d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -3,8 +3,9 @@ import { View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Ionicons from "@expo/vector-icons/Ionicons"; -import { useCounts, useTodos, useNotifications, type TodoItem } from "@/src/lib/queries"; +import { useCounts, useTodos, useNotifications, usePhases, type TodoItem, type Phase } from "@/src/lib/queries"; import { useAuthStore } from "@/src/store/auth"; +import { useProjectStore } from "@/src/store/project"; const PRIORITY_COLOR: Record = { urgent: "#FF3B30", @@ -45,6 +46,33 @@ function StatCard({ ); } +// Phase status colors mirror the web dashboard's phase breakdown +// (completed green, active blue, planned gray, archived muted). +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 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 TodoRow({ item }: { item: TodoItem }) { if (item.kind === "approval") { return ( @@ -95,9 +123,11 @@ function TodoRow({ item }: { item: TodoItem }) { export default function OverviewScreen() { const { email } = useAuthStore(); + const selectedProject = useProjectStore((s) => s.selected); const counts = useCounts(); const todos = useTodos(); const notifications = useNotifications(1); + const phases = usePhases(selectedProject?.id); const isLoading = counts.isLoading || todos.isLoading || notifications.isLoading; const isRefetching = counts.isRefetching || todos.isRefetching || notifications.isRefetching; @@ -106,9 +136,10 @@ export default function OverviewScreen() { counts.refetch(); todos.refetch(); notifications.refetch(); + if (selectedProject) phases.refetch(); } - const allTodos = todos.data?.data ?? []; + const allTodos = todos.data?.data.items ?? []; const unreadCount = notifications.data?.meta.unreadCount ?? 0; const approvalCount = allTodos.filter((t) => t.kind === "approval").length; const taskCount = allTodos.filter((t) => t.kind === "task").length; @@ -158,6 +189,21 @@ export default function OverviewScreen() { 0 ? "#4CD964" : "#C7C7CC"} bg={unreadCount > 0 ? "#E8F8EB" : "#F5F5F5"} /> + {/* Phase breakdown — project-scoped, like the web dashboard */} + {selectedProject && (phases.data?.data.length ?? 0) > 0 && ( + <> + + Phases · {selectedProject.name} + {phases.data!.data.length} + + + {phases.data!.data.map((p) => ( + + ))} + + + )} + {/* Recent todos */} {allTodos.length > 0 && ( <> 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/src/lib/queries.ts b/src/lib/queries.ts index 50d2132..bb679fa 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -45,10 +45,15 @@ 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: TodoProject[]; types: { kind: TodoKind; count: number }[] }; - meta: { total: number }; + data: { items: TodoItem[]; facets: TodoFacets }; } export interface GlobalCounts { @@ -57,28 +62,107 @@ export interface GlobalCounts { } // ── 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: string; - priority: string; - type: string; + status: GoalStatus; + priority: TodoPriority; + type: GoalType; displayNumber: number | null; targetDate: string | null; - ownerName: string | null; - prState: string; - completion: { - doneTasks: number; - totalTasks: number; - complete: boolean; - }; + prState: GoalPrState; + prUrl: string | null; + prNumber: number | null; + autoDeliver: boolean; + pausedAt: string | null; + pauseReason: string | null; + createdAt: string; + updatedAt: string; + release: { id: string; name: string; slug: string; endDate: string | null } | null; } export interface GoalsResponse { data: EngineGoal[]; - meta: { total: number }; + 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; +} + +// ── 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 ──────────────────────────────────────────────────────────── @@ -126,6 +210,9 @@ export const keys = { ["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 ───────────────────────────────────────────────────────────────────── @@ -157,16 +244,40 @@ export function useCounts() { }); } +/** 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(); - if (projectId) params.set("projectId", projectId); + const params = new URLSearchParams({ projectId: projectId! }); if (status) params.set("status", status); - const qs = params.toString(); - return api.get(`/api/v1/me/engine/goals${qs ? `?${qs}` : ""}`); + return api.get(`/api/v1/me/engine/goals?${params}`); }, + enabled: !!projectId, + }); +} + +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"), + }); +} + +export function usePhases(projectId?: string) { + return useQuery({ + queryKey: keys.phases(projectId ?? ""), + queryFn: () => api.get<{ data: Phase[] }>(`/api/v1/projects/${projectId}/phases`), + enabled: !!projectId, }); } @@ -225,6 +336,7 @@ export function useDecideApproval() { 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() }); }, }); From 0bc1ae3d00e1bb4eb4681175f4da5894b088a878 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Fri, 10 Jul 2026 22:19:28 -0700 Subject: [PATCH 11/15] feat(shell): fleet sidebar with right-edge swipe + final web-parity nav, goal lanes, push deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syncs with the completed web mobile/tablet UI (product-manager #1966-#1969): - Tab bar now matches the final web bottom nav: Attention · Goals · AI hero (56px ring button) · Feedback · Home - Fleet sidebar (src/components/fleet-sidebar.tsx): right-side panel with machine health dots + working/total counts, needs-attention machine cards, 'Goals being worked on' queue in exact claim order (status pills: Fleet/ Interactive/Blocked/Waiting on you/Recovering/Queued/Waiting on deps), and 'Awaiting acceptance'. Opens via left-swipe from the right edge or the header fleet button; swipe right / tap scrim to close. Data from /me/engine/fleet-queue + /me/machines/fleet, polled 60s while open - Goals: segmented Blocked | Ready | Queue lane switcher with counts (web phase-3 parity), replacing status filter pills - Push: notification-tap deep links (engine_attention → goal detail or Attention tab) --- app/(tabs)/_layout.tsx | 98 +++++--- app/(tabs)/goal.tsx | 91 ++++--- app/_layout.tsx | 19 ++ src/components/app-header.tsx | 14 +- src/components/fleet-sidebar.tsx | 394 +++++++++++++++++++++++++++++++ src/lib/queries.ts | 73 ++++++ 6 files changed, 623 insertions(+), 66 deletions(-) create mode 100644 src/components/fleet-sidebar.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index c05be1e..33442cb 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,21 +1,22 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later /** - * Tab shell mirroring the web app's mobile bottom nav - * (product-manager/src/components/layout/mobile-bottom-nav.tsx): + * Tab shell mirroring the web app's final mobile bottom nav (phases 1-3 of + * the mobile/tablet UI, product-manager mobile-bottom-nav.tsx): * - * Overview · Goal · [AI center action] · Approval · Feedback + * Attention · Goals · [AI hero] · Feedback · Home * - * As on web, the center AI button opens the "Ask the Senior PM" prompt sheet - * instead of navigating, and Notifications + Me live in the global top bar - * (bell + avatar), not in the tab bar. + * The center AI button opens the "Ask the Senior PM" sheet. Notifications + + * Me live in the global top bar. The fleet sidebar slides in from the RIGHT — + * left-swipe from the right edge (or the header's fleet button) opens it. */ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Tabs, useRouter } from "expo-router"; -import { View, Text, TouchableOpacity, Platform, StyleSheet } from "react-native"; +import { View, Text, TouchableOpacity, PanResponder, Platform, StyleSheet } 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 { colors } from "@/src/lib/theme"; type IoniconName = React.ComponentProps["name"]; @@ -23,11 +24,11 @@ type IoniconName = React.ComponentProps["name"]; function tabIcon(focused: boolean, active: IoniconName, inactive: IoniconName) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return ({ color }: { color: any }) => ( - + ); } -/** Center AI action — protruding green circle, like the web bottom nav. */ +/** Center AI hero — protruding green circle, like the web bottom nav. */ function AiTabButton({ onPress }: { onPress: () => void }) { return ( @@ -37,7 +38,7 @@ function AiTabButton({ onPress }: { onPress: () => void }) { activeOpacity={0.85} accessibilityLabel="Ask the Senior PM" > - + AI @@ -46,11 +47,22 @@ function AiTabButton({ onPress }: { onPress: () => void }) { export default function TabLayout() { const [aiOpen, setAiOpen] = useState(false); + const [fleetOpen, setFleetOpen] = useState(false); const router = useRouter(); + // Left-swipe from the right screen edge opens the fleet sidebar. + const edgeSwipe = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_e, g) => g.dx < -10 && Math.abs(g.dy) < 40, + onPanResponderRelease: (_e, g) => { + if (g.dx < -30) setFleetOpen(true); + }, + }) + ).current; + return ( - + setFleetOpen(true)} /> - tabIcon(focused, "grid", "grid-outline")({ color }), + tabIcon(focused, "notifications", "notifications-outline")({ color }), }} /> - tabIcon(focused, "flag", "flag-outline")({ color }), + tabIcon(focused, "locate", "locate-outline")({ color }), }} /> - tabIcon(focused, "checkmark-circle", "checkmark-circle-outline")({ color }), + tabIcon(focused, "chatbubble-ellipses", "chatbubble-ellipses-outline")({ color }), }} /> - tabIcon(focused, "chatbubble-ellipses", "chatbubble-ellipses-outline")({ color }), + tabIcon(focused, "home", "home-outline")({ color }), }} /> {/* Reachable from the top bar (bell / avatar), hidden from the tab bar */} @@ -112,6 +124,11 @@ export default function TabLayout() { + {/* Right-edge swipe zone (only while the panel is closed) */} + {!fleetOpen && } + + setFleetOpen(false)} /> + setAiOpen(false)} @@ -128,18 +145,27 @@ const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.background }, aiWrap: { flex: 1, alignItems: "center", justifyContent: "flex-end", paddingBottom: 2 }, aiCircle: { - width: 44, - height: 44, - borderRadius: 22, + width: 56, + height: 56, + borderRadius: 28, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center", - marginTop: -18, + marginTop: -24, + borderWidth: 4, + borderColor: "#FFFFFF", shadowColor: "#000", - shadowOpacity: 0.15, - shadowRadius: 6, - shadowOffset: { width: 0, height: 2 }, - elevation: 4, + shadowOpacity: 0.18, + shadowRadius: 8, + shadowOffset: { width: 0, height: 3 }, + elevation: 6, + }, + aiLabel: { fontSize: 11, fontWeight: "600", color: colors.textSecondary, marginTop: 2 }, + edgeZone: { + position: "absolute", + top: 100, + right: 0, + bottom: 80, + width: 24, }, - aiLabel: { fontSize: 10, fontWeight: "500", color: colors.textSecondary, marginTop: 2 }, }); diff --git a/app/(tabs)/goal.tsx b/app/(tabs)/goal.tsx index 5c49d83..8677fe6 100644 --- a/app/(tabs)/goal.tsx +++ b/app/(tabs)/goal.tsx @@ -31,15 +31,17 @@ const TYPE_CONFIG: Record = { - All: undefined, - Open: "open", - "In Progress": "in_progress", - Accepted: "accepted", -}; +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" }, @@ -99,7 +101,7 @@ function GoalCard({ goal, onPress }: { goal: EngineGoal; onPress: () => void }) export default function GoalScreen() { const router = useRouter(); - const [filter, setFilter] = useState("All"); + const [lane, setLane] = useState("Blocked"); const pinnedProject = useProjectStore((s) => s.selected); const { data: projectsData } = useProjects(); @@ -107,12 +109,15 @@ export default function GoalScreen() { // 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, - FILTER_STATUS[filter] - ); + const { data, isLoading, isRefetching, refetch } = useGoals(activeProject?.id); - const goals = data?.data ?? []; + 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 ( @@ -132,19 +137,25 @@ export default function GoalScreen() { )} - {/* Filter pills */} + {/* Segmented lanes: Blocked | Ready | Queue (web mobile parity) */} - {FILTERS.map((f) => ( - setFilter(f)} - > - - {f} - - - ))} + {LANES.map((l) => { + const active = lane === l; + return ( + setLane(l)} + > + {l} + + + {laneCounts[l]} + + + + ); + })} {isLoading ? ( @@ -168,9 +179,7 @@ export default function GoalScreen() { No goals {activeProject - ? filter === "All" - ? `No goals found in ${activeProject.name}.` - : `No ${filter.toLowerCase()} goals.` + ? `No ${lane.toLowerCase()} goals in ${activeProject.name}.` : "Join a project to see its goals."} @@ -212,6 +221,30 @@ const styles = StyleSheet.create({ gap: 8, marginBottom: 12, }, + 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, diff --git a/app/_layout.tsx b/app/_layout.tsx index db16b2d..a277837 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,6 +2,7 @@ // 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"; @@ -48,6 +49,24 @@ function AuthGuard({ children }: { children: React.ReactNode }) { // SSE foreground connection (noop when token/userId are null) useNotificationSSE(); + // 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/src/components/app-header.tsx b/src/components/app-header.tsx index 5aa36d4..2f495d5 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -17,7 +17,7 @@ import { useNotifications } from "@/src/lib/queries"; import { ProjectSwitcherSheet } from "@/src/components/project-switcher-sheet"; import { colors } from "@/src/lib/theme"; -export function AppHeader() { +export function AppHeader({ onOpenFleet }: { onOpenFleet?: () => void }) { const insets = useSafeAreaInsets(); const router = useRouter(); const { email } = useAuthStore(); @@ -54,6 +54,18 @@ export function AppHeader() { + {/* Fleet sidebar toggle (also opens via right-edge left-swipe) */} + {onOpenFleet && ( + + + + )} + {/* Notifications bell */} = { + 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. + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_e, g) => g.dx > 12 && Math.abs(g.dy) < 40, + onPanResponderRelease: (_e, g) => { + if (g.dx > 40) 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/lib/queries.ts b/src/lib/queries.ts index bb679fa..d2a0d53 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -85,6 +85,7 @@ export interface EngineGoal { 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; @@ -150,6 +151,58 @@ export interface AdmissionApproval { 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"; @@ -273,6 +326,26 @@ export function useAdmissionApprovals() { }); } +/** 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 ?? ""), From fafda4e45ac9999c4820bf0e519110632aaa77ea Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 11 Jul 2026 00:21:02 -0700 Subject: [PATCH 12/15] =?UTF-8?q?feat(shell):=20sync=20with=20web=20mobile?= =?UTF-8?q?=20updates=20#1971/#1973=20=E2=80=94=20Menu=20tab=20+=20sheet,?= =?UTF-8?q?=20GNB=20switcher,=20tab=20badges,=20lane=20reorder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bottom nav: Attention · Goals · AI hero · Feedback · Menu (Overview moves into the Menu sheet, matching web's Menu-tab layout) - Red count badges on Attention (approvals + admissions) and Goals (running/recovering fleet goals), #FF3B30 like web - Menu sheet (src/components/menu-sheet.tsx): profile group (Profile, Notifications, Sign out), native Overview, and web deep-links for Release/Sprint/Meeting/Comment/Decision/History/Setting on the pinned project - Header = web GNB below lg: centered two-line org/project switcher, fleet button with live status dot (red alert / green working / gray idle), bell; avatar removed (profile lives in the Menu sheet) - Fleet drawer: web-matched gestures (edge zone 32px, 50px thresholds, vertical-swipe rejection), width 85% max 360, plus an always-visible right-edge handle with status dot and working/total count - Goals lanes reordered to Queue | Ready | Blocked (default Blocked) --- app/(tabs)/_layout.tsx | 151 ++++++++++++++--- app/(tabs)/goal.tsx | 7 +- app/(tabs)/menu-tab.tsx | 12 ++ src/components/app-header.tsx | 112 ++++++------- src/components/fleet-sidebar.tsx | 10 +- src/components/menu-sheet.tsx | 271 +++++++++++++++++++++++++++++++ 6 files changed, 482 insertions(+), 81 deletions(-) create mode 100644 app/(tabs)/menu-tab.tsx create mode 100644 src/components/menu-sheet.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 33442cb..77c8406 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,14 +1,16 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later /** - * Tab shell mirroring the web app's final mobile bottom nav (phases 1-3 of - * the mobile/tablet UI, product-manager mobile-bottom-nav.tsx): + * Tab shell mirroring the web app's mobile bottom nav (final state after + * product-manager #1966-#1973): * - * Attention · Goals · [AI hero] · Feedback · Home + * Attention · Goals · [AI hero] · Feedback · Menu * - * The center AI button opens the "Ask the Senior PM" sheet. Notifications + - * Me live in the global top bar. The fleet sidebar slides in from the RIGHT — - * left-swipe from the right edge (or the header's fleet button) opens it. + * 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"; @@ -17,6 +19,13 @@ 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, +} from "@/src/lib/queries"; import { colors } from "@/src/lib/theme"; type IoniconName = React.ComponentProps["name"]; @@ -28,7 +37,7 @@ function tabIcon(focused: boolean, active: IoniconName, inactive: IoniconName) { ); } -/** Center AI hero — protruding green circle, like the web bottom nav. */ +/** Center AI hero — protruding ringed green circle, like the web bottom nav. */ function AiTabButton({ onPress }: { onPress: () => void }) { return ( @@ -45,21 +54,69 @@ function AiTabButton({ onPress }: { onPress: () => void }) { ); } +/** 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(); - // Left-swipe from the right screen edge opens the fleet sidebar. + // Red tab badges (web useNavCounts parity): Attention = approvals routed to + // me (todos + goal admissions); Goals = fleet goals actively running. + const todos = useTodos(); + const admissions = useAdmissionApprovals(); + const fleetQueue = useFleetQueue(true); + const machines = useFleetMachines(true); + + 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; + + // 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 < -10 && Math.abs(g.dy) < 40, + onMoveShouldSetPanResponder: (_e, g) => + g.dx < 0 && Math.abs(g.dx) > Math.abs(g.dy), onPanResponderRelease: (_e, g) => { - if (g.dx < -30) setFleetOpen(true); + if (g.dx <= -50) setFleetOpen(true); }, }) ).current; + const badgeStyle = { + backgroundColor: colors.danger, + color: "#FFFFFF", + fontSize: 11, + fontWeight: "700" as const, + }; + return ( setFleetOpen(true)} /> @@ -81,6 +138,8 @@ export default function TabLayout() { options={{ title: "Attention", tabBarLabel: "Attention", + tabBarBadge: attentionCount > 0 ? (attentionCount > 99 ? "99+" : attentionCount) : undefined, + tabBarBadgeStyle: badgeStyle, tabBarIcon: ({ focused, color }) => tabIcon(focused, "notifications", "notifications-outline")({ color }), }} @@ -90,6 +149,8 @@ export default function TabLayout() { options={{ title: "Goals", tabBarLabel: "Goals", + tabBarBadge: runningCount > 0 ? (runningCount > 99 ? "99+" : runningCount) : undefined, + tabBarBadgeStyle: badgeStyle, tabBarIcon: ({ focused, color }) => tabIcon(focused, "locate", "locate-outline")({ color }), }} @@ -111,23 +172,42 @@ export default function TabLayout() { }} /> - tabIcon(focused, "home", "home-outline")({ color }), + title: "Menu", + tabBarButton: () => setMenuOpen(true)} />, }} /> - {/* Reachable from the top bar (bell / avatar), hidden from the tab bar */} + {/* Hidden routes: Overview lives in the Menu sheet now; notifications + + me open from the top bar / Menu sheet. */} + - {/* Right-edge swipe zone (only while the panel is closed) */} - {!fleetOpen && } + {/* 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)} /> ; +} diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index 2f495d5..b203028 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -1,60 +1,68 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later /** - * Global top bar, mirroring the web app's global nav on mobile: - * brand on the left, notifications bell (unread badge) + user avatar on the - * right. On web, Notifications and the user menu live here rather than in - * the bottom tab bar — same on native. + * 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 } 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 { useNotifications } from "@/src/lib/queries"; +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 { email } = useAuthStore(); const { selected } = useProjectStore(); const { data } = useNotifications(1); + const machines = useFleetMachines(true); const unread = data?.meta.unreadCount ?? 0; const [switcherOpen, setSwitcherOpen] = useState(false); - const initials = (email ?? "?").slice(0, 2).toUpperCase(); + // 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 ( - {/* Brand + project switcher (like the web global nav) */} + {/* Left: brand chip */} + + B + + + {/* Center: two-line org/project switcher (web GNB parity) */} setSwitcherOpen(true)} activeOpacity={0.7} accessibilityLabel="Switch project" > - - B + + + {selected ? selected.orgName : "Interactor"} + + + + {selected ? selected.name : "All projects"} + + + - - {selected ? ( - {selected.name} - ) : ( - <> - Interactor BUILD - - )} - - - {/* Fleet sidebar toggle (also opens via right-edge left-swipe) */} + {/* Fleet button + live status dot */} {onOpenFleet && ( void }) { accessibilityLabel="Fleet activity" hitSlop={8} > - + + )} @@ -73,23 +82,13 @@ export function AppHeader({ onOpenFleet }: { onOpenFleet?: () => void }) { accessibilityLabel="Notifications" hitSlop={8} > - + {unread > 0 && ( {unread > 99 ? "99+" : unread} )} - - {/* User avatar → Me */} - router.push("/(tabs)/me" as never)} - accessibilityLabel="Account" - hitSlop={8} - > - {initials} - @@ -105,26 +104,38 @@ const styles = StyleSheet.create({ borderBottomColor: colors.border, }, bar: { - height: 48, + height: 52, flexDirection: "row", alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 16, + paddingHorizontal: 14, + gap: 10, }, - brand: { flexDirection: "row", alignItems: "center", gap: 8, flexShrink: 1, paddingRight: 8 }, brandIcon: { - width: 24, - height: 24, - borderRadius: 6, + width: 26, + height: 26, + borderRadius: 7, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center", }, - brandIconText: { color: "#FFFFFF", fontWeight: "800", fontSize: 13 }, - brandText: { fontSize: 14, color: colors.text }, - brandBold: { fontWeight: "800" }, - actions: { flexDirection: "row", alignItems: "center", gap: 14 }, + brandIconText: { color: "#FFFFFF", fontWeight: "800", fontSize: 14 }, + 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, @@ -138,13 +149,4 @@ const styles = StyleSheet.create({ paddingHorizontal: 3, }, badgeText: { color: "#FFFFFF", fontSize: 9, fontWeight: "700" }, - avatar: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: colors.surfaceSecondary, - alignItems: "center", - justifyContent: "center", - }, - avatarText: { fontSize: 11, fontWeight: "700", color: colors.text }, }); diff --git a/src/components/fleet-sidebar.tsx b/src/components/fleet-sidebar.tsx index 99896a8..7022475 100644 --- a/src/components/fleet-sidebar.tsx +++ b/src/components/fleet-sidebar.tsx @@ -34,7 +34,8 @@ import { } from "@/src/lib/queries"; import { colors, radii } from "@/src/lib/theme"; -const PANEL_WIDTH = Math.min(320, Dimensions.get("window").width * 0.85); +// 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 = { @@ -158,12 +159,13 @@ export function FleetSidebar({ visible, onClose }: Props) { }).start(); }, [visible, translateX]); - // Swipe right on the open panel to close it. + // 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 > 12 && Math.abs(g.dy) < 40, + onMoveShouldSetPanResponder: (_e, g) => g.dx > 0 && Math.abs(g.dx) > Math.abs(g.dy), onPanResponderRelease: (_e, g) => { - if (g.dx > 40) onClose(); + if (g.dx >= 50) onClose(); }, }) ).current; 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 }, +}); From 046006702ff646bf98f4fe09a9f5b4ea8cdf1c2d Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 11 Jul 2026 00:34:57 -0700 Subject: [PATCH 13/15] feat(alerts): native device-alert integration + Device alerts settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - push.ts: alert-permission helpers (get/request with iOS re-prompt handling), local test alert, app-icon badge sync - Root layout: app icon badge mirrors the unread notification count live (SSE-invalidated), cleared on sign-out - Me screen: new 'Device alerts' section — OS permission state with Enable / Open Settings action, 'Goal attention alerts' toggle wired to the backend engine_attention_push preference, send-test-alert row, and the account's registered devices with remove - queries.ts: useDevices/useDeleteDevice/usePreferences/useUpdatePreferences (needs product-manager PR #1974 for Bearer auth on /me/preferences) --- app/(tabs)/me.tsx | 267 ++++++++++++++++++++++++++++++++++++++------- app/_layout.tsx | 16 ++- src/lib/push.ts | 43 ++++++++ src/lib/queries.ts | 57 ++++++++++ 4 files changed, 340 insertions(+), 43 deletions(-) diff --git a/app/(tabs)/me.tsx b/app/(tabs)/me.tsx index b5cad78..019ab0f 100644 --- a/app/(tabs)/me.tsx +++ b/app/(tabs)/me.tsx @@ -1,77 +1,250 @@ // 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, 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(); + + // 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]; - function handleSignOut() { - Alert.alert("Sign out", "Sign out of Build on this device?", [ + 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 - - - {/* Profile card */} - - - {initial} + + + Me - - {username} - {email} + + {/* Profile card */} + + + {initial} + + + {username} + {email} + - - - {/* Settings section */} - - Account - - - - Notification preferences - - - - - - Privacy & security - + + {/* 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 - - - {/* Sign out */} - - - - Sign out - - + ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#F5F5F5" }, + scroll: { paddingBottom: 32 }, header: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }, heading: { fontSize: 28, fontWeight: "700", color: "#1C1C1E" }, profileCard: { @@ -126,7 +299,17 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 14, }, - menuLabel: { flex: 1, fontSize: 15, color: "#1C1C1E" }, + 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", diff --git a/app/_layout.tsx b/app/_layout.tsx index a277837..74a13ba 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,7 +5,8 @@ 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({ @@ -49,6 +50,19 @@ function AuthGuard({ children }: { children: React.ReactNode }) { // 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. 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 d2a0d53..9d96fc6 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -252,6 +252,31 @@ 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 = { @@ -363,6 +388,38 @@ export function useFeedback(projectId: string) { }); } +/** 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({ From 81d640f1abf8cf35c4afd87e2b769f32b11bf110 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 11 Jul 2026 02:28:47 -0700 Subject: [PATCH 14/15] feat(parity): exact web-mobile visual parity verified on iOS simulator Compared side-by-side against live web at 390px (Playwright captures with an authenticated session): - Real brand assets from product-manager/public: AI hero now renders icon_green.png in the ringed circle (label clipping fixed), Goals tab uses the target/disc icon - Header = web GNB: chat + service-launcher icons left, centered two-line org/project switcher, fleet button (red alert dot live), GREEN bell badge - Dashboard rebuilt as the web's My Todos card: FOCUS NOW hero band, UP NEXT numbered list, web todo priority palette (High orange, Medium yellow), priority sort to match web ordering - PageHeader component (32pt title + gray subtitle) applied to Approvals / Goals / Feedback with the exact page-meta subtitles - Goals: round green create-FAB + new-goal sheet (POST /me/engine/goals) - Auth: transient iOS network errors (NSURLErrorDomain -1005/-1017) retried with backoff; step-tagged auth errors; dev-only QA auto-login via gitignored .env.local; debug probes removed --- app/(auth)/login.tsx | 18 +- app/(tabs)/_layout.tsx | 30 +- app/(tabs)/approvals.tsx | 14 +- app/(tabs)/feedback.tsx | 14 +- app/(tabs)/goal.tsx | 143 +++++++++- app/(tabs)/index.tsx | 451 +++++++++++++------------------ assets/interactor-icon-green.png | Bin 0 -> 21749 bytes assets/interactor-logo-green.png | Bin 0 -> 7981 bytes src/components/app-header.tsx | 35 ++- src/components/page-header.tsx | 65 +++++ src/lib/queries.ts | 11 +- src/store/auth.ts | 61 +++-- 12 files changed, 514 insertions(+), 328 deletions(-) create mode 100644 assets/interactor-icon-green.png create mode 100644 assets/interactor-logo-green.png create mode 100644 src/components/page-header.tsx diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 46f0114..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 { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { View, Text, @@ -27,6 +27,22 @@ export default function LoginScreen() { 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.trim() || !password) return; setLoading(true); diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 77c8406..c7f076e 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -14,7 +14,7 @@ */ import { useRef, useState } from "react"; import { Tabs, useRouter } from "expo-router"; -import { View, Text, TouchableOpacity, PanResponder, Platform, StyleSheet } from "react-native"; +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"; @@ -37,7 +37,8 @@ function tabIcon(focused: boolean, active: IoniconName, inactive: IoniconName) { ); } -/** Center AI hero — protruding ringed green circle, like the web bottom nav. */ +/** 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 ( @@ -47,7 +48,10 @@ function AiTabButton({ onPress }: { onPress: () => void }) { activeOpacity={0.85} accessibilityLabel="Ask the Senior PM" > - + AI @@ -152,7 +156,7 @@ export default function TabLayout() { tabBarBadge: runningCount > 0 ? (runningCount > 99 ? "99+" : runningCount) : undefined, tabBarBadgeStyle: badgeStyle, tabBarIcon: ({ focused, color }) => - tabIcon(focused, "locate", "locate-outline")({ color }), + tabIcon(focused, "disc", "disc-outline")({ color }), }} /> {/* Header */} - - Approvals - {badgeCount > 0 && ( - - {badgeCount} - - )} - + {/* Tabs: general approval requests vs goal-admission approvals */} diff --git a/app/(tabs)/feedback.tsx b/app/(tabs)/feedback.tsx index d6d3e14..a9b2080 100644 --- a/app/(tabs)/feedback.tsx +++ b/app/(tabs)/feedback.tsx @@ -9,6 +9,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import Ionicons from "@expo/vector-icons/Ionicons"; import { useTodos, useFeedback, type FeedbackEntry } 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" }, @@ -100,14 +101,11 @@ export default function FeedbackScreen() { return ( {/* Header */} - - Feedback - {feedback.length > 0 && ( - - {feedback.length} - - )} - + {/* Project picker (hidden when the header switcher pins a project) */} {!globalProject && projects.length > 1 && ( diff --git a/app/(tabs)/goal.tsx b/app/(tabs)/goal.tsx index d7e55f2..bd99375 100644 --- a/app/(tabs)/goal.tsx +++ b/app/(tabs)/goal.tsx @@ -4,12 +4,14 @@ 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, type EngineGoal } from "@/src/lib/queries"; +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" }, @@ -103,6 +105,9 @@ function GoalCard({ goal, onPress }: { goal: EngineGoal; onPress: () => void }) 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(); @@ -122,15 +127,23 @@ export default function GoalScreen() { return ( - {/* Header */} - - Goals - {goals.length > 0 && ( - - {goals.length} - - )} - + {/* Header — web PageBar + the round green create FAB */} + setCreateOpen(true)} + activeOpacity={0.85} + accessibilityLabel="Create goal" + > + + + ) : undefined + } + /> {activeProject && ( {activeProject.name} @@ -187,6 +200,56 @@ export default function GoalScreen() { } /> )} + {/* 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 + )} + + + + + + ); } @@ -222,6 +285,66 @@ const styles = StyleSheet.create({ 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", diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e58517d..6a1e546 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,53 +1,35 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later -import { View, Text, ScrollView, RefreshControl, ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native"; +/** + * 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 { useCounts, useTodos, useNotifications, usePhases, type TodoItem, type Phase } from "@/src/lib/queries"; -import { useAuthStore } from "@/src/store/auth"; +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"; -const PRIORITY_COLOR: Record = { - urgent: "#FF3B30", - high: "#FF9500", - medium: "#FFCC00", - low: "#C7C7CC", +// 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 STATUS_LABEL: Record = { - todo: "To do", - in_progress: "In progress", - in_review: "In review", - blocked: "Blocked", - done: "Done", +const KIND_ICON: Record["name"]> = { + task: "checkbox-outline", + approval: "thumbs-up-outline", + decision_ack: "git-commit-outline", }; -function StatCard({ - icon, - label, - value, - accent, - bg, -}: { - icon: React.ComponentProps["name"]; - label: string; - value: number; - accent: string; - bg: string; -}) { - return ( - - - - - {value} - {label} - - ); -} - -// Phase status colors mirror the web dashboard's phase breakdown -// (completed green, active blue, planned gray, archived muted). const PHASE_STATUS: Record = { completed: { color: "#2F855A", bg: "#E8F8EB", label: "Completed" }, active: { color: "#007AFF", bg: "#EBF4FF", label: "Active" }, @@ -55,282 +37,237 @@ const PHASE_STATUS: Record archived: { color: "#C7C7CC", bg: "#F5F5F5", label: "Archived" }, }; -function PhaseRow({ phase }: { phase: Phase }) { - const cfg = PHASE_STATUS[phase.status] ?? PHASE_STATUS.planned; +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"; + return ( - - - - {phase.name} - - {phase._count.tasks} tasks · {phase._count.deliverables} deliverables - - - - {cfg.label} - + + #{index} + {priority && ( + + {priority.label} + + )} + + + {item.title} + + + {item.project.name} · {item.organization.name} + ); } -function TodoRow({ item }: { item: TodoItem }) { - if (item.kind === "approval") { - return ( - - - - {item.title} - {item.project.name} · Approval - - - Approval - - - ); - } - if (item.kind === "decision_ack") { - return ( - - - - {item.title} - {item.project.name} · Decision - - - Decision - - - ); - } +function PhaseRow({ phase }: { phase: Phase }) { + const cfg = PHASE_STATUS[phase.status] ?? PHASE_STATUS.planned; return ( - - - - {item.title} - {item.project.name} · {STATUS_LABEL[item.status] ?? item.status} - - {item.dueDate && ( - - {new Date(item.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + + + + {phase.name} + + {phase._count.tasks} tasks · {phase._count.deliverables} deliverables - )} + + + {cfg.label} + ); } -export default function OverviewScreen() { - const { email } = useAuthStore(); +export default function DashboardScreen() { const selectedProject = useProjectStore((s) => s.selected); - const counts = useCounts(); const todos = useTodos(); - const notifications = useNotifications(1); const phases = usePhases(selectedProject?.id); - const isLoading = counts.isLoading || todos.isLoading || notifications.isLoading; - const isRefetching = counts.isRefetching || todos.isRefetching || notifications.isRefetching; - - function refetchAll() { - counts.refetch(); - todos.refetch(); - notifications.refetch(); - if (selectedProject) phases.refetch(); - } - - const allTodos = todos.data?.data.items ?? []; - const unreadCount = notifications.data?.meta.unreadCount ?? 0; - const approvalCount = allTodos.filter((t) => t.kind === "approval").length; - const taskCount = allTodos.filter((t) => t.kind === "task").length; - const totalTodos = counts.data?.globalTodosCount ?? 0; - - const firstName = email?.split("@")[0]?.split(".")[0] ?? ""; - const greeting = firstName - ? `Hi, ${firstName.charAt(0).toUpperCase() + firstName.slice(1)}` - : "Overview"; + const items = todos.data?.data.items ?? []; + const focus = items[0]; + const upNext = items.slice(1); return ( + { + todos.refetch(); + if (selectedProject) phases.refetch(); + }} + tintColor={colors.primary} + /> } showsVerticalScrollIndicator={false} > - {/* Header */} - - - {greeting} - Here's what needs your attention - - {unreadCount > 0 && ( - - - - {unreadCount > 9 ? "9+" : unreadCount} - - - )} - + - {isLoading ? ( + {todos.isLoading ? ( - + ) : ( <> - {/* Stats grid */} - Summary - - - 0 ? "#FF9500" : "#C7C7CC"} bg={approvalCount > 0 ? "#FFF3E0" : "#F5F5F5"} /> - - 0 ? "#4CD964" : "#C7C7CC"} bg={unreadCount > 0 ? "#E8F8EB" : "#F5F5F5"} /> - - - {/* Phase breakdown — project-scoped, like the web dashboard */} - {selectedProject && (phases.data?.data.length ?? 0) > 0 && ( - <> - - Phases · {selectedProject.name} - {phases.data!.data.length} + {/* My Todos card */} + + + My Todos + + {items.length} - - {phases.data!.data.map((p) => ( - - ))} - - - )} + - {/* Recent todos */} - {allTodos.length > 0 && ( - <> - - Recent - {allTodos.length} items + {items.length === 0 ? ( + + + All caught up! + Nothing is waiting on you. - - {allTodos.slice(0, 8).map((item) => ( - - ))} - {allTodos.length > 8 && ( - - +{allTodos.length - 8} more items - + ) : ( + <> + {focus && ( + <> + + + FOCUS NOW + + + )} - - - )} + {upNext.length > 0 && ( + <> + + UP NEXT · {upNext.length} + + {upNext.map((item, i) => ( + + ))} + + )} + + )} + - {allTodos.length === 0 && !isLoading && ( - - - All caught up! - No pending todos across your projects. + {/* 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: { + 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", - justifyContent: "space-between", alignItems: "center", + gap: 8, paddingHorizontal: 16, paddingTop: 16, - paddingBottom: 20, + paddingBottom: 10, }, - greeting: { fontSize: 26, fontWeight: "700", color: "#1C1C1E" }, - subGreeting: { fontSize: 13, color: "#8E8E93", marginTop: 2 }, - bellWrap: { position: "relative", padding: 4 }, - bellBadge: { - position: "absolute", - top: 0, - right: 0, - backgroundColor: "#4CD964", + cardTitle: { fontSize: 22, fontWeight: "700", color: colors.text }, + countBadge: { + backgroundColor: "#F3F4F6", borderRadius: 9999, - minWidth: 16, - height: 16, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: 3, - }, - bellBadgeText: { fontSize: 10, fontWeight: "700", color: "#FFFFFF" }, - sectionLabel: { - fontSize: 11, - fontWeight: "600", - color: "#8E8E93", - textTransform: "uppercase", - letterSpacing: 0.8, - marginHorizontal: 16, - marginBottom: 8, + paddingHorizontal: 9, + paddingVertical: 2, }, - sectionRow: { + countBadgeText: { fontSize: 13, fontWeight: "600", color: colors.textSecondary }, + focusBand: { flexDirection: "row", - justifyContent: "space-between", alignItems: "center", - marginHorizontal: 16, - marginBottom: 8, - marginTop: 8, + gap: 6, + backgroundColor: colors.primaryLight, + paddingHorizontal: 16, + paddingVertical: 8, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderLight, }, - sectionCount: { fontSize: 12, color: "#8E8E93" }, - statGrid: { - flexDirection: "row", - flexWrap: "wrap", - paddingHorizontal: 12, - gap: 8, - marginBottom: 24, + focusLabel: { + fontSize: 12, + fontWeight: "700", + color: colors.primaryText, + letterSpacing: 0.8, }, - statCard: { - width: "47%", - backgroundColor: "#FFFFFF", - borderRadius: 16, - padding: 16, - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.04, - shadowRadius: 4, - elevation: 1, + upNextBand: { + paddingHorizontal: 16, + paddingVertical: 8, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderLight, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderLight, }, - statIconWrap: { - width: 32, - height: 32, - borderRadius: 8, - alignItems: "center", - justifyContent: "center", - marginBottom: 10, + upNextLabel: { + fontSize: 12, + fontWeight: "700", + color: colors.textSecondary, + letterSpacing: 0.8, }, - statValue: { fontSize: 32, fontWeight: "700", color: "#1C1C1E", marginBottom: 2 }, - statLabel: { fontSize: 12, color: "#8E8E93", fontWeight: "500" }, - todoList: { marginHorizontal: 16, gap: 1, borderRadius: 16, overflow: "hidden", marginBottom: 24 }, todoRow: { flexDirection: "row", alignItems: "center", - backgroundColor: "#FFFFFF", - paddingHorizontal: 14, - paddingVertical: 13, + 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, }, - kindDot: { width: 8, height: 8, borderRadius: 4, flexShrink: 0 }, - todoContent: { flex: 1 }, - todoTitle: { fontSize: 14, fontWeight: "500", color: "#1C1C1E", marginBottom: 2 }, - todoMeta: { fontSize: 12, color: "#8E8E93" }, - kindBadge: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 3 }, - kindBadgeText: { fontSize: 11, fontWeight: "600" }, - dueLabel: { fontSize: 11, color: "#8E8E93", fontWeight: "500" }, - dueLabelOverdue: { color: "#FF3B30", fontWeight: "600" }, - moreRow: { backgroundColor: "#FFFFFF", paddingVertical: 12, alignItems: "center" }, - moreText: { fontSize: 13, color: "#8E8E93" }, - center: { paddingTop: 60, alignItems: "center" }, - emptyState: { alignItems: "center", paddingTop: 60, paddingHorizontal: 32 }, - emptyTitle: { fontSize: 20, fontWeight: "700", color: "#1C1C1E", marginTop: 12 }, - emptySubtitle: { fontSize: 14, color: "#8E8E93", textAlign: "center", marginTop: 6 }, + 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/assets/interactor-icon-green.png b/assets/interactor-icon-green.png new file mode 100644 index 0000000000000000000000000000000000000000..8037e703bf1e086a7487f848a1ee795bb31076a0 GIT binary patch literal 21749 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 literal 0 HcmV?d00001 diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index b203028..1594585 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -7,7 +7,7 @@ * the right. The user menu lives in the Menu sheet (bottom nav), not here. */ import { useState } from "react"; -import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; +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"; @@ -36,9 +36,22 @@ export function AppHeader({ onOpenFleet }: { onOpenFleet?: () => void }) { return ( - {/* Left: brand chip */} - - B + {/* 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) */} @@ -110,15 +123,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 14, gap: 10, }, - brandIcon: { - width: 26, - height: 26, - borderRadius: 7, - backgroundColor: colors.primary, - alignItems: "center", - justifyContent: "center", - }, - brandIconText: { color: "#FFFFFF", fontWeight: "800", fontSize: 14 }, + leftIcons: { flexDirection: "row", alignItems: "center", gap: 12 }, switcher: { flex: 1, alignItems: "center" }, switcherText: { alignItems: "center", maxWidth: "90%" }, orgLine: { fontSize: 11, color: colors.textSecondary }, @@ -143,7 +148,9 @@ const styles = StyleSheet.create({ minWidth: 16, height: 16, borderRadius: 8, - backgroundColor: colors.danger, + // 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, 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/lib/queries.ts b/src/lib/queries.ts index 9d96fc6..d9186de 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -310,7 +310,7 @@ export function useTodos(scope = "global") { return useQuery({ queryKey: keys.todos(scope), queryFn: () => - api.get(`/api/v1/me/todos?scope=${scope}&sort=urgency&limit=100`), + api.get(`/api/v1/me/todos?scope=${scope}&sort=priority&limit=100`), }); } @@ -335,6 +335,15 @@ export function useGoals(projectId?: string, status?: string) { }); } +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 ?? ""), diff --git a/src/store/auth.ts b/src/store/auth.ts index 943fd7c..14803ec 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -62,21 +62,48 @@ interface AuthState { hydrate: () => Promise; } +/** 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 { - const res = await fetch(`${API_BASE_URL}/api/auth/csrf`); - const data = (await res.json()) as { csrfToken: string }; - return data.csrfToken; + 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> { - 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, - }; + 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( @@ -145,11 +172,13 @@ export const useAuthStore = create((set, get) => ({ csrfToken, redirect: "false", }); - await fetch(`${API_BASE_URL}/api/auth/callback/credentials`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); + 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(); From 747458eaf2cfd18b97425236abadeb7d8bd53056 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Sat, 11 Jul 2026 10:42:35 -0700 Subject: [PATCH 15/15] feat(parity): Feedback tab badges + Internal/Portal/Idea tab switcher - useCounts() now accepts orgSlug + projectSlug to fetch project-scoped counts including untriagedFeedbackCount from /api/v1/me/counts - Tab layout reads selected project and drives a red Feedback badge from untriagedFeedbackCount, matching web useNavCounts (PR #1975 parity) - Feedback page now has Internal / Portal queue / Idea source tabs (client-side filter on the source field), mirroring the web's three-tab layout from feedback-client.tsx; badge counts shown inline per tab --- app/(tabs)/_layout.tsx | 15 +++- app/(tabs)/feedback.tsx | 187 ++++++++++++++++++++++++---------------- src/lib/queries.ts | 17 +++- 3 files changed, 139 insertions(+), 80 deletions(-) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index c7f076e..7a838d9 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -25,7 +25,9 @@ import { useAdmissionApprovals, useFleetQueue, useFleetMachines, + useCounts, } from "@/src/lib/queries"; +import { useProjectStore } from "@/src/store/project"; import { colors } from "@/src/lib/theme"; type IoniconName = React.ComponentProps["name"]; @@ -79,12 +81,20 @@ export default function TabLayout() { 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. + // 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) + @@ -93,6 +103,7 @@ export default function TabLayout() { 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); @@ -171,6 +182,8 @@ export default function TabLayout() { options={{ title: "Feedback", tabBarLabel: "Feedback", + tabBarBadge: feedbackCount > 0 ? (feedbackCount > 99 ? "99+" : feedbackCount) : undefined, + tabBarBadgeStyle: badgeStyle, tabBarIcon: ({ focused, color }) => tabIcon(focused, "chatbubble-ellipses", "chatbubble-ellipses-outline")({ color }), }} diff --git a/app/(tabs)/feedback.tsx b/app/(tabs)/feedback.tsx index a9b2080..5b55bda 100644 --- a/app/(tabs)/feedback.tsx +++ b/app/(tabs)/feedback.tsx @@ -7,10 +7,30 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Ionicons from "@expo/vector-icons/Ionicons"; -import { useTodos, useFeedback, type FeedbackEntry } from "@/src/lib/queries"; +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" }, @@ -20,12 +40,16 @@ const STATUS_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" }, + 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 }) { @@ -38,7 +62,6 @@ function FeedbackCard({ item }: { item: FeedbackEntry }) { return ( - {/* Header row: source + time + vote count */} @@ -58,15 +81,12 @@ function FeedbackCard({ item }: { item: FeedbackEntry }) { - {/* Title */} {item.title} - {/* Body preview */} {item.body && ( {item.body} )} - {/* Footer: status badge + rating */} {status.label} @@ -86,64 +106,67 @@ function FeedbackCard({ item }: { item: FeedbackEntry }) { } export default function FeedbackScreen() { - const { data: todosData } = useTodos(); const globalProject = useProjectStore((s) => s.selected); - const projects = todosData?.data.facets.projects ?? []; + const [activeTab, setActiveTab] = useState("internal"); - const [selectedProjectId, setSelectedProjectId] = useState(() => projects[0]?.id ?? ""); - // Header switcher selection wins; local pills refine within global scope. - const activeProjectId = - globalProject?.id ?? (selectedProjectId || projects[0]?.id || ""); + const activeProjectId = globalProject?.id ?? ""; const { data, isLoading, isRefetching, refetch } = useFeedback(activeProjectId); - const feedback = data?.data ?? []; + 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 ( - {/* Header */} - {/* Project picker (hidden when the header switcher pins a project) */} - {!globalProject && projects.length > 1 && ( - - {projects.map((p) => ( - setSelectedProjectId(p.id)} - > - - {p.name} - - - ))} - - )} + {/* 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]} + + + )} + + ))} + - {/* No projects fallback */} {!activeProjectId && !isLoading && ( - No projects + No project selected - You are not assigned to any projects yet. + Select a project in the header to see feedback. )} - {/* Feedback list */} {!!activeProjectId && ( isLoading ? ( @@ -151,9 +174,9 @@ export default function FeedbackScreen() { ) : ( item.id} - contentContainerStyle={feedback.length === 0 ? styles.emptyFill : styles.listContent} + contentContainerStyle={tabFiltered.length === 0 ? styles.emptyFill : styles.listContent} refreshControl={ } @@ -161,9 +184,17 @@ export default function FeedbackScreen() { ListEmptyComponent={ - No feedback + + {activeTab === "internal" ? "No internal feedback" : + activeTab === "portal" ? "No portal submissions" : + "No ideas"} + - No feedback has been submitted for this project yet. + {activeTab === "internal" + ? "No feedback has been submitted for this project yet." + : activeTab === "portal" + ? "No feedback from the customer portal yet." + : "No ideas submitted yet."} } @@ -176,38 +207,42 @@ export default function FeedbackScreen() { 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" }, - countBadge: { - backgroundColor: "#E5E5EA", - borderRadius: 9999, - paddingHorizontal: 8, - paddingVertical: 2, - }, - countText: { fontSize: 13, fontWeight: "600", color: "#8E8E93" }, - projectScroll: { maxHeight: 44 }, - projectRow: { + tabScroll: { maxHeight: 52, flexShrink: 0 }, + tabRow: { paddingHorizontal: 16, + paddingVertical: 8, gap: 8, - paddingBottom: 12, + flexDirection: "row", + alignItems: "center", }, - projectPill: { + 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", - maxWidth: 200, + borderRadius: 9999, + paddingHorizontal: 6, + paddingVertical: 1, + minWidth: 20, + alignItems: "center", }, - projectPillActive: { backgroundColor: "#1C1C1E" }, - projectPillText: { fontSize: 13, color: "#1C1C1E", fontWeight: "500" }, - projectPillTextActive: { color: "#FFFFFF" }, + tabBadgeActive: { backgroundColor: "#3D3D3F" }, + tabBadgeText: { fontSize: 11, fontWeight: "700", color: "#8E8E93" }, + tabBadgeTextActive: { color: "#FFFFFF" }, listContent: { paddingHorizontal: 16, paddingBottom: 24, gap: 10 }, card: { backgroundColor: "#FFFFFF", diff --git a/src/lib/queries.ts b/src/lib/queries.ts index d9186de..99fb1d9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -59,6 +59,10 @@ export interface TodosResponse { export interface GlobalCounts { globalTodosCount: number; perOrg: Record; + /** Only present when the request included orgSlug + projectSlug. */ + untriagedFeedbackCount?: number; + myAttentionCount?: number; + runningTasksCount?: number; } // ── Goal types ──────────────────────────────────────────────────────────────── @@ -314,10 +318,17 @@ export function useTodos(scope = "global") { }); } -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, }); }