diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0c4f474ac..0d4bd83ec 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,9 +16,10 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`, and `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; and the form sheets `debates.tsx` (switcher), `new-debate.tsx` and `delete-account.tsx`, declared in the root `_layout.tsx`. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. -- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. +- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. +- One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e18402c45..6dc48e4bf 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -14,6 +14,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", "aes-js": "^3.1.2", + "date-fns": "^3.6.0", "expo": "~57.0.27", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index df7c13f22..7f6d5d9b9 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -7,12 +7,21 @@ * npm run -w @debatelab/mobile export:web * PORT= npx tsx apps/mobile/scripts/screenshots.ts * + * ONLY=iphone-light-en limits a run to the combinations whose name starts with it. + * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the * script signs in once through the real sign-in screen and also checks that a * cold start without a network keeps the user signed in. The deployed Edge * Functions only accept browser calls from a few origins, so for that run use * PORT=18080 (http://127.0.0.1:18080). Never print the code or put it in a * screenshot. + * + * Signed in, the app applies the account's saved language and theme, which + * would override the locale and colour scheme under test. Screenshot runs + * therefore answer the app's user_settings reads with the run's language and + * the System theme; nothing is written. The Debate tab shows the account's + * real Current Debate, and its empty state comes from answering the + * current_debates read with no row. The Debate tab is shot once per side view. */ import { chromium, type Browser, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; @@ -28,6 +37,7 @@ const DIST = join(__dirname, '../dist'); const OUT = join(__dirname, '../../../.verify/mobile-shots'); const PORT = Number(process.env.PORT); const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim(); +const ONLY = process.env.ONLY?.trim() ?? ''; const ORIGIN = `http://127.0.0.1:${PORT}`; const STAGING_HOST = new URL(SUPABASE_PROJECTS.staging.url).host; const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`; @@ -38,7 +48,8 @@ const DEVICES = { } as const; const SCHEMES = ['light', 'dark'] as const; const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const; -const SIGNED_IN_ROUTES = { debate: '/', prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' }; +const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' }; +const REST = `https://${STAGING_HOST}/rest/v1`; const TYPES: Record = { '.html': 'text/html; charset=utf-8', @@ -83,6 +94,64 @@ async function shoot(page: Page, name: string) { await page.screenshot({ path: join(OUT, `${name}.png`), mask: [page.getByTestId('account-email')] }); } +/** Answers user_settings reads with the run's language and the System theme (see the header). */ +async function pinSettings(page: Page, language: string) { + await page.route(`${REST}/user_settings*`, async (route) => { + if (route.request().method() !== 'GET') return route.continue(); + const response = await route.fetch(); + const pin = (row: Record) => ({ + ...row, + ...('language' in row ? { language } : {}), + ...('output_language' in row ? { output_language: language } : {}), + ...('theme' in row ? { theme: 'system' } : {}), + }); + const body: unknown = await response.json(); + const json = Array.isArray(body) + ? body.map((row) => pin(row as Record)) + : body && typeof body === 'object' ? pin(body as Record) : body; + await route.fulfill({ response, json }); + }); +} + +/** Waits until the screen's data has arrived, so no spinner is caught mid-load. */ +async function settle(page: Page) { + await page.waitForLoadState('networkidle'); + await page.waitForFunction(() => document.querySelectorAll('[role="progressbar"]').length === 0, undefined, { timeout: 15_000 }); +} + +async function shootDebateScreens(page: Page, prefix: string) { + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–')); + await settle(page); + await shoot(page, `${prefix}-debate`); + // Both sides' work is already loaded, so the Against view needs no new request. + await page.getByRole('tab', { name: /^(Against|Ellene|En contra)$/ }).click(); + await shoot(page, `${prefix}-debate-con`); + + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); + await settle(page); + // Let the sheet finish sliding in. + await page.waitForTimeout(700); + await shoot(page, `${prefix}-debates-sheet`); + + await page.getByRole('button', { name: /^(New debate|Új vita|Nuevo debate)$/ }).click(); + await page.getByTestId('new-debate-sheet').waitFor(); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-new-debate`); +} + +async function shootEmptyDebate(page: Page, prefix: string) { + await page.route(`${REST}/current_debates*`, (route) => + route.fulfill({ status: 200, contentType: 'application/json', json: [] }), + ); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-pill').waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-debate-empty`); +} + async function signIn(browser: Browser, problems: string[]): Promise { const context = await browser.newContext({ ...DEVICES.iphone, locale: LOCALES.en }); const page = await context.newPage(); @@ -171,6 +240,7 @@ async function main() { for (const scheme of SCHEMES) { for (const [language, locale] of Object.entries(LOCALES)) { const prefix = `${device}-${scheme}-${language}`; + if (!prefix.startsWith(ONLY)) continue; const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); const page = await signedOut.newPage(); watchErrors(page, problems, prefix); @@ -189,12 +259,25 @@ async function main() { const signedIn = await browser.newContext({ ...options, colorScheme: scheme, locale, storageState: session }); const tabPage = await signedIn.newPage(); watchErrors(tabPage, problems, `${prefix} signed in`); + await pinSettings(tabPage, language); + await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 4; for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { await tabPage.goto(`${ORIGIN}${route}`); - await tabPage.getByRole('tablist').waitFor(); + await tabPage.getByRole('tablist').first().waitFor(); + if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 }); + await settle(tabPage); await shoot(tabPage, `${prefix}-${name}`); shots += 1; } + const emptyPage = await signedIn.newPage(); + watchErrors(emptyPage, problems, `${prefix} empty`); + await pinSettings(emptyPage, language); + await shootEmptyDebate(emptyPage, prefix); + shots += 1; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 4996a3d12..ff1759905 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -1,31 +1,419 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { useFocusEffect, useRouter, type Href } from 'expo-router'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet } from 'react-native'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; -import { Card } from '@/components/card'; +import { MainActionButton, TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import type { IconName } from '@/components/icon-paths'; import { Screen } from '@/components/screen'; +import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { + fetchDebateCounts, + fetchRecentWork, + type DebateCounts, + type DebateItem, + type DebateItemKind, + type DebateSide, +} from '@/lib/debates'; +import { formatWhen } from '@/lib/dates'; +import { mainActionGradient, radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; -/** Home. Until debates reach the app (next PR), a signed-in user sees the empty state. */ +const RECENT_LIMIT = 3; + +// The Prepare tools on the website's Current Debate page. Each opens the Prepare tab until its screen lands. +const PREPARE_TOOLS: { key: string; icon: IconName }[] = [ + { key: 'generate', icon: 'sparkles' }, + { key: 'brainstorm', icon: 'bulb' }, + { key: 'factoids', icon: 'book' }, + { key: 'flowchart', icon: 'branch' }, +]; + +const KIND_ICONS: Record = { + case: 'sparkles', + attack: 'swords', + brainstorm: 'bulb', + factoids: 'book', + chat: 'chat', + flowchart: 'branch', + crossfire: 'target', + sparring: 'swords', +}; + +const PRACTICE_KINDS = new Set(['attack', 'crossfire', 'sparring']); + +/** Home: the Current Debate, as in mock 01. */ export default function DebateScreen() { const { t } = useTranslation(); + const router = useRouter(); + const { status, current, oneOff, setOneOff, refresh } = useDebates(); + + // Debates switched on the website show up when the tab comes back into view. + const focusedBefore = useRef(false); + useFocusEffect( + useCallback(() => { + if (focusedBefore.current) void refresh(); + focusedBefore.current = true; + }, [refresh]), + ); + + const openSwitcher = () => router.push('/debates'); + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'error') { + return ( + + + + {t('mobile.debate.loadFailed')} + + void refresh()} /> + + + ); + } + + if (!current) { + return ( + + + + + {t('debate.noCurrentTitle')} + + + {t('debate.noCurrentDesc')} + + + router.push('/new-debate')} /> + + ); + } + + return ( + + + {oneOff ? setOneOff(false)} /> : null} + + {current.motion} + + + + ); +} + +function SwitcherPill({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); return ( - - - {t('debate.noCurrentTitle')} + [styles.pill, { backgroundColor: colors.tile }, pressed && styles.pressed]}> + + {t('nav.currentDebate')} - - {t('debate.noCurrentDesc')} + + + ); +} + +function OneOffNotice({ onReturn }: { onReturn: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + + {t('debate.oneOffStrip')} - - - {t('mobile.debate.comingSoon')} + + + ); +} + +/** + * Counts, the main action, the Prepare tools and recent work for one debate. + * Students prepare both sides, so the toggle only chooses which side's work is + * shown. It starts on In favor for each debate and is never stored. + */ +function DebateDetails({ debateId }: { debateId: string }) { + const { t } = useTranslation(); + const router = useRouter(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [view, setView] = useState('pro'); + const [counts, setCounts] = useState(null); + const [recent, setRecent] = useState | null>(null); + const [failed, setFailed] = useState(false); + + // Both sides load together, so switching the view is instant. + const load = useCallback(async (isCurrent: () => boolean) => { + if (!userId) return; + try { + const [nextCounts, pro, con] = await Promise.all([ + fetchDebateCounts(userId, debateId), + fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT), + fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT), + ]); + if (!isCurrent()) return; + setCounts(nextCounts); + setRecent({ pro, con }); + setFailed(false); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); + setFailed(true); + } + }, [userId, debateId]); + + // Reload on focus, so work saved on the website shows up. + useFocusEffect( + useCallback(() => { + let active = true; + void load(() => active); + return () => { + active = false; + }; + }, [load]), + ); + + const open = (href: Href) => router.navigate(href); + const results = counts?.results[view]; + const items = recent?.[view] ?? null; + + return ( + <> + + + options={[ + { value: 'pro', label: t('debate.side.pro') }, + { value: 'con', label: t('debate.side.con') }, + ]} + value={view} + onChange={setView} + /> + + + + + + + + + open('/practice')} /> + + + {t('mobile.tabs.prepare')} + + + {PREPARE_TOOLS.map((tool) => ( + open('/prepare')} /> + ))} + + + + {t('mobile.debate.pickUp')} + + {failed ? ( + + + {t('mobile.debate.loadFailed')} + + void load(() => true)} /> + + ) : items === null ? ( + + ) : items.length === 0 ? ( + + + {t('debate.sideEmpty')} + + + ) : ( + + {items.map((item, index) => ( + open(PRACTICE_KINDS.has(item.kind) ? '/practice' : '/prepare')} + /> + ))} + + )} + + ); +} + +function Stat({ value, label }: { value: number | undefined; label: string }) { + return ( + + + {value ?? '–'} + + + {label} + + + ); +} + +function SparCard({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + return ( + [styles.spar, pressed && styles.pressed]}> + + + + + + + {t('mobile.debate.spar')} - - + {t('sparring.format.standard')} + {t('mobile.debate.sparSide')} + + {/* A View, so the web preview paints it above the gradient like the other children. */} + + + + + ); +} + +// Long one-word labels (Hungarian "Folyamatábra") shrink to fit on iOS; the web preview cannot shrink text, so it truncates them. +function ToolTile({ icon, label, onPress }: { icon: IconName; label: string; onPress: () => void }) { + const { colors } = useTheme(); + return ( + [ + styles.tile, + { backgroundColor: colors.card, borderColor: colors.border }, + pressed && styles.pressed, + ]}> + + + {label} + + + ); +} + +function RecentRow({ item, first, onPress }: { item: DebateItem; first: boolean; onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [ + styles.row, + !first && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + + + {item.title || t(`debate.kinds.${item.kind}`)} + + + {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} + + + + ); } const styles = StyleSheet.create({ - title: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6, marginTop: 8 }, + loading: { marginTop: 32 }, body: { fontSize: 16, lineHeight: 22 }, + pressed: { opacity: 0.7 }, + pill: { + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + gap: 5, + borderRadius: radius.pill, + paddingLeft: 14, + paddingRight: 11, + minHeight: 34, + marginTop: 4, + }, + pillLabel: { fontSize: 15 }, + notice: { + marginTop: 12, + borderWidth: 1, + borderStyle: 'dashed', + borderRadius: radius.field, + paddingHorizontal: 14, + paddingTop: 10, + alignItems: 'flex-start', + }, + noticeText: { fontSize: 14, lineHeight: 19 }, + emptyText: { gap: 8 }, + emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 }, + motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 }, + stats: { flexDirection: 'row', gap: 24, marginTop: 13, marginBottom: 15, marginHorizontal: 2 }, + statValue: { fontSize: 21, lineHeight: 26, letterSpacing: -0.2 }, + statLabel: { fontSize: 15 }, + spar: { + borderRadius: radius.card + 2, + overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + gap: 14, + paddingVertical: 17, + paddingHorizontal: 17, + }, + sparIcon: { + width: 46, + height: 46, + borderRadius: 13, + backgroundColor: 'rgba(255,255,255,0.2)', + alignItems: 'center', + justifyContent: 'center', + }, + sparText: { flex: 1, gap: 2 }, + sparTitle: { color: '#ffffff', fontSize: 18, lineHeight: 23 }, + sparLine: { color: 'rgba(255,255,255,0.88)', fontSize: 14.5, lineHeight: 20 }, + section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 20, marginBottom: 9, marginHorizontal: 2 }, + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + tile: { + flexGrow: 1, + flexBasis: '45%', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderWidth: 1, + borderRadius: radius.card, + paddingVertical: 12, + paddingHorizontal: 10, + }, + tileLabel: { flex: 1, fontSize: 15.5 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 14 }, }); diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index 286ba8de4..27d4238c4 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -1,60 +1,247 @@ +import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View } from 'react-native'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; import { ScreenTitle } from '@/components/screen-title'; import { Screen } from '@/components/screen'; +import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; +import { formatDate } from '@/lib/dates'; +import { useSettings, type ThemePreference } from '@/lib/settings'; +import { fetchSubscription, type Subscription, type SubscriptionTier } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; import { useTheme } from '@/theme/use-theme'; -/** Account and sign-out only. Settings and account deletion arrive with the You tab's own PR. */ +const TIER_LABELS: Record = { + trial: 'settings.tierTrial', + pro: 'settings.tierPro', + legacy_free: 'settings.tierLegacyFree', + expired: 'settings.tierExpired', +}; + +const LANGUAGE_OPTIONS = (Object.keys(APP_LANGUAGES) as AppLanguage[]).map((value) => ({ + value, + label: APP_LANGUAGES[value].label, +})); + +/** Plan, settings shared with the website, and the account. Download my data is not in the app yet. */ export default function YouScreen() { const { t } = useTranslation(); + const router = useRouter(); const { colors } = useTheme(); const { session, signOut } = useAuth(); + const settings = useSettings(); + const [saveFailed, setSaveFailed] = useState(false); + + const save = (change: () => Promise) => { + setSaveFailed(false); + change().catch((cause: unknown) => { + console.warn('Could not save settings:', cause instanceof Error ? cause.message : cause); + setSaveFailed(true); + }); + }; return ( + + + + + {t('settings.globalSettings')} + - + + save(() => settings.setLanguage(value))} + /> + + + save(() => settings.setOutputLanguage(value))} + /> + + + + options={[ + { value: 'light', label: t('settings.light') }, + { value: 'dark', label: t('settings.dark') }, + { value: 'system', label: t('settings.system') }, + ]} + value={settings.theme} + onChange={(value) => save(() => settings.setTheme(value))} + /> + + {saveFailed ? ( + + {t('mobile.you.saveFailed')} + + ) : null} + + + + {t('settings.account')} + + + - - + + {t('settings.signedInAs')} {session?.user.email ?? ''} + {supabaseEnv === 'staging' ? ( + + + {t('settings.stagingBanner')} + + + ) : null} - {supabaseEnv === 'staging' ? ( - - - {t('settings.stagingBanner')} + router.push('/delete-account')} + style={({ pressed }) => [styles.row, { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + {t('settings.deleteAccount')} + + + {t('settings.deleteAccountDesc')} - ) : null} - - - - {t('mobile.placeholder.body')} - + + + void signOut()} /> ); } +function Setting({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +function SubscriptionCard({ userId }: { userId: string | undefined }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [subscription, setSubscription] = useState(null); + const [failed, setFailed] = useState(false); + + const load = useCallback(async (isCurrent: () => boolean) => { + if (!userId) return; + try { + const next = await fetchSubscription(userId); + if (!isCurrent()) return; + setSubscription(next); + setFailed(false); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load the subscription:', cause instanceof Error ? cause.message : cause); + setFailed(true); + } + }, [userId]); + + // Usage changes as the student works, so read it again whenever You comes into view. + useFocusEffect( + useCallback(() => { + let active = true; + void load(() => active); + return () => { + active = false; + }; + }, [load]), + ); + + return ( + + + + + {t('settings.subscription')} + + {subscription ? ( + + + {t(TIER_LABELS[subscription.tier])} + + + ) : null} + + {failed && !subscription ? ( + <> + + {t('mobile.you.loadFailed')} + + void load(() => true)} /> + + ) : !subscription ? ( + + + + {t('settings.loading')} + + + ) : ( + + {subscription.tier === 'trial' && subscription.trialEndsAt ? ( + + {t('settings.ends')} {formatDate(subscription.trialEndsAt)} + + ) : null} + + {t('mobile.you.usage', { used: subscription.dailyUsed, limit: subscription.dailyLimit })} + + + {t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')} + + + )} + + ); +} + const styles = StyleSheet.create({ - row: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - account: { flex: 1, gap: 2 }, - caption: { fontSize: 13 }, - email: { fontSize: 17 }, + section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 6, marginBottom: -6, marginHorizontal: 2 }, + small: { fontSize: 14, lineHeight: 19 }, + planHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + planTitle: { flex: 1, fontSize: 17 }, + planBody: { gap: 4 }, + usage: { fontSize: 16 }, + loadingRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, badge: { alignSelf: 'flex-start', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 }, badgeText: { fontSize: 13 }, - body: { fontSize: 15, lineHeight: 21 }, + setting: { gap: 7 }, + settingLabel: { fontSize: 14, marginLeft: 2 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14, paddingHorizontal: 16 }, + account: { alignItems: 'flex-start' }, + rowText: { flex: 1, gap: 3 }, + rowTitle: { fontSize: 16 }, + email: { fontSize: 17 }, + pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index f692d7d6d..311548c0c 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -2,12 +2,15 @@ import '@/lib/i18n'; import { useFonts } from 'expo-font'; import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router'; +import type { NativeStackNavigationOptions } from 'expo-router/native-stack'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; import { StyleSheet, View } from 'react-native'; import { AuthProvider, useAuth } from '@/lib/auth'; +import { DebateProvider } from '@/lib/debate-context'; +import { SettingsProvider, useSettings } from '@/lib/settings'; import { interFonts } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -16,7 +19,11 @@ void SplashScreen.preventAutoHideAsync(); export default function RootLayout() { return ( - + + + + + ); } @@ -24,8 +31,10 @@ export default function RootLayout() { function Root() { const [fontsLoaded, fontError] = useFonts(interFonts); const { session, loading, settled } = useAuth(); + const { ready: settingsReady } = useSettings(); const { scheme, colors } = useTheme(); - const ready = (fontsLoaded || fontError !== null) && !loading; + // Settings come from the device here, so the app opens in the account's language and theme. + const ready = (fontsLoaded || fontError !== null) && !loading && settingsReady; useEffect(() => { if (ready) void SplashScreen.hideAsync(); @@ -33,6 +42,15 @@ function Root() { if (!ready) return null; + // Native form sheets with a grabber on iOS. The web preview shows them as full pages. + const sheet: NativeStackNavigationOptions = { + presentation: 'formSheet', + sheetGrabberVisible: true, + sheetAllowedDetents: [0.86, 1], + sheetCornerRadius: 22, + contentStyle: { backgroundColor: colors.background }, + }; + const base = scheme === 'dark' ? DarkTheme : DefaultTheme; const navigationTheme = { ...base, @@ -54,6 +72,9 @@ function Root() { + + + diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx new file mode 100644 index 000000000..fd2f11f17 --- /dev/null +++ b/apps/mobile/src/app/debates.tsx @@ -0,0 +1,227 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Switch, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Card } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { fetchSourceCounts, type Debate } from '@/lib/debates'; +import { formatRelativeDate, isToday } from '@/lib/dates'; +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +function normalize(text: string) { + return text.trim().replace(/\s+/g, ' ').toLocaleLowerCase(); +} + +/** "Your debates": switch the Current Debate, turn on one-off mode, or start a new one (mock 02). */ +export default function DebatesSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { status, debates, current, oneOff, setOneOff, switchTo, refresh } = useDebates(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [query, setQuery] = useState(''); + const [sourceCounts, setSourceCounts] = useState>({}); + const [switching, setSwitching] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const ids = debates.map((debate) => debate.id).join(','); + useEffect(() => { + if (!userId || !ids) return; + let active = true; + fetchSourceCounts(userId, ids.split(',')) + .then((counts) => { + if (active) setSourceCounts(counts); + }) + .catch((cause: unknown) => console.warn('Could not count sources:', cause)); + return () => { + active = false; + }; + }, [userId, ids]); + + const visible = useMemo(() => { + const needle = normalize(query); + return needle ? debates.filter((debate) => normalize(debate.motion).includes(needle)) : debates; + }, [debates, query]); + + const pick = async (debate: Debate) => { + if (switching) return; + if (debate.id === current?.id && !oneOff) { + router.back(); + return; + } + setSwitching(debate.id); + setError(''); + try { + await switchTo(debate.id); + router.back(); + } catch { + setError(t('debate.switchFailed')); + } finally { + setSwitching(null); + } + }; + + return ( + + + + {t('debate.yourDebates')} + + router.back()} /> + + + + + + + + {status === 'loading' ? : null} + {status === 'error' ? ( + + + {t('mobile.debates.loadFailed')} + + void refresh()} /> + + ) : null} + {status === 'ready' && debates.length === 0 ? ( + + {t('debate.noOtherDebates')} + + ) : null} + {status === 'ready' && debates.length > 0 && visible.length === 0 ? ( + + {t('mobile.debates.noMatches')} + + ) : null} + {visible.length > 0 ? ( + + {visible.map((debate, index) => { + const isCurrent = debate.id === current?.id; + const sources = sourceCounts[debate.id]; + const when = isToday(debate.lastActivityAt) + ? t('mobile.debates.activeToday') + : formatRelativeDate(debate.lastActivityAt); + return ( + void pick(debate)} + style={({ pressed }) => [ + styles.row, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + + {debate.motion} + + + {sources === undefined + ? when + : t('mobile.debates.meta', { sources: t('mobile.debates.sourceCount', { count: sources }), when })} + + + {switching === debate.id ? ( + + ) : isCurrent ? ( + + ) : null} + + ); + })} + + ) : null} + + + + + + + + + {t('mobile.debates.oneOff')} + + + {t('mobile.debates.oneOffDesc')} + + + + + + + {error ? ( + + {error} + + ) : null} + + router.push('/new-debate')} /> + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + search: { + flexDirection: 'row', + alignItems: 'center', + gap: 9, + borderRadius: radius.field, + paddingHorizontal: 13, + minHeight: 46, + }, + searchInput: { flex: 1, fontSize: 16.5, fontFamily: fontFamily.regular, paddingVertical: 10 }, + loading: { marginVertical: 24 }, + body: { fontSize: 15, lineHeight: 21 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, + rowText: { flex: 1, gap: 3 }, + motion: { fontSize: 16.5, lineHeight: 22 }, + meta: { fontSize: 14, lineHeight: 19 }, + pressed: { opacity: 0.6 }, + oneOffCard: { padding: 0, gap: 0 }, + oneOff: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 13, paddingHorizontal: 16 }, + oneOffIcon: { width: 42, height: 42, borderRadius: radius.tile, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/mobile/src/app/delete-account.tsx b/apps/mobile/src/app/delete-account.tsx new file mode 100644 index 000000000..39a1d691c --- /dev/null +++ b/apps/mobile/src/app/delete-account.tsx @@ -0,0 +1,111 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Field } from '@/components/field'; +import { Text } from '@/components/text'; +import { deleteAccount, deletionConfirmation } from '@/lib/account'; +import { useAuth } from '@/lib/auth'; +import { useTheme } from '@/theme/use-theme'; + +/** Removes what this device kept for the account: its settings. */ +async function forgetAccount(userId: string) { + const keys = await AsyncStorage.getAllKeys(); + await AsyncStorage.multiRemove(keys.filter((key) => key.includes(userId))); +} + +/** Deletes the account after the same typed confirmation as the website (rule 5.1.1(v)). */ +export default function DeleteAccountSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session, signOut } = useAuth(); + const email = session?.user.email; + const expected = deletionConfirmation(email); + const [confirmation, setConfirmation] = useState(''); + const [deleting, setDeleting] = useState(false); + const [failed, setFailed] = useState(false); + + const remove = async () => { + if (confirmation !== expected || deleting) return; + const userId = session?.user.id; + setDeleting(true); + setFailed(false); + try { + await deleteAccount(confirmation); + } catch (cause) { + // The server's message is English and not meant for students; the sheet shows translated text only. + console.warn('Could not delete the account:', cause instanceof Error ? cause.message : cause); + setDeleting(false); + setFailed(true); + return; + } + if (userId) await forgetAccount(userId).catch(() => undefined); + // The account is gone, so the server sign-out may fail; signOut still ends the session here. + await signOut(); + }; + + return ( + + + + + {t('settings.deleteAccountDialogTitle')} + + router.back()} disabled={deleting} /> + + + {t('settings.deleteAccountDialogDesc', { email: email || t('settings.unknownUser') })} + + void remove()} + /> + {failed ? ( + + + {t('settings.deleteAccountFailed')} + + + {t('mobile.deleteAccount.failedHint')} + + + ) : null} + void remove()} + /> + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + error: { gap: 2 }, +}); diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx new file mode 100644 index 000000000..3760d78f4 --- /dev/null +++ b/apps/mobile/src/app/new-debate.tsx @@ -0,0 +1,134 @@ +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, TextButton } from '@/components/buttons'; +import { Card } from '@/components/card'; +import { Field } from '@/components/field'; +import { Icon } from '@/components/icon'; +import { Text } from '@/components/text'; +import { useDebates } from '@/lib/debate-context'; +import { DEBATE_FORMATS, MAX_MOTION_LENGTH, type DebateFormat } from '@/lib/debates'; +import { useTheme } from '@/theme/use-theme'; + +/** Start a debate: motion and format, like the website's Start new debate dialog. It becomes current. */ +export default function NewDebateSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { create } = useDebates(); + const [motion, setMotion] = useState(''); + const [format, setFormat] = useState('custom'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const submit = async () => { + // The button stays disabled until there is a motion, as on the website. + if (!motion.trim()) return; + setSaving(true); + setError(''); + try { + // The website's form saves the role too; it has no role picker here yet, so it keeps the default. + await create({ motion, settings: { format, role: 'general' } }); + // Closes this sheet and the switcher under it, if it was opened from there. + router.dismissAll(); + } catch (cause) { + console.warn('Could not create the debate:', cause instanceof Error ? cause.message : cause); + setError(t('debate.saveFailed')); + setSaving(false); + } + }; + + return ( + + + + + {t('debate.startNew')} + + router.back()} disabled={saving} /> + + + {t('debate.startNewDesc')} + + + + + + + {t('debate.format')} + + + {DEBATE_FORMATS.map((value, index) => { + const selected = value === format; + return ( + setFormat(value)} + style={({ pressed }) => [ + styles.option, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + {t(`debate.formats.${value}`)} + + {selected ? : null} + + ); + })} + + + + {error ? ( + + {error} + + ) : null} + + void submit()} + /> + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + motion: { minHeight: 96, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + group: { gap: 7 }, + label: { fontSize: 14, marginLeft: 4 }, + list: { padding: 0, gap: 0 }, + option: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingHorizontal: 16, gap: 12 }, + optionLabel: { flex: 1, fontSize: 16 }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/buttons.tsx b/apps/mobile/src/components/buttons.tsx index 265a2667a..6dd81b607 100644 --- a/apps/mobile/src/components/buttons.tsx +++ b/apps/mobile/src/components/buttons.tsx @@ -1,6 +1,8 @@ import { LinearGradient } from 'expo-linear-gradient'; import { ActivityIndicator, Pressable, StyleSheet } from 'react-native'; +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; import { Text } from './text'; import { mainActionGradient, radius } from '@/theme/colors'; @@ -35,6 +37,44 @@ export function MainActionButton({ ); } +/** A solid button for a screen's action when the gradient is taken or would be too loud, as in the mocks' sheets. */ +export function PrimaryButton({ + label, + icon, + onPress, + busy, + disabled, + destructive, +}: { + label: string; + icon?: IconName; + onPress: () => void; + busy?: boolean; + disabled?: boolean; + destructive?: boolean; +}) { + const { colors } = useTheme(); + const inactive = disabled || busy; + const foreground = destructive ? '#ffffff' : colors.onPrimary; + return ( + [ + styles.primary, + { backgroundColor: destructive ? colors.destructive : colors.primary }, + (pressed || inactive) && styles.dimmed, + ]}> + {busy ? : icon ? : null} + + {label} + + + ); +} + /** A secondary action shown as tinted text, like an iOS plain button. */ export function TextButton({ label, @@ -75,6 +115,16 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, }, mainLabel: { color: '#ffffff', fontSize: 17 }, + primary: { + minHeight: 52, + borderRadius: radius.card - 2, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingHorizontal: 20, + }, + primaryLabel: { fontSize: 17 }, dimmed: { opacity: 0.6 }, text: { minHeight: 44, alignItems: 'center', justifyContent: 'center' }, textLabel: { fontSize: 16 }, diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 0b8bee6bf..b49766ec8 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -12,6 +12,16 @@ export const iconPaths = { library: '', user: '', globe: '', + bulb: '', + book: '', + branch: '', + chat: '', + chevronRight: '', + chevronDown: '', + plus: '', + search: '', + check: '', + x: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/icon.tsx b/apps/mobile/src/components/icon.tsx index bc9b1bac4..e8cd65dc5 100644 --- a/apps/mobile/src/components/icon.tsx +++ b/apps/mobile/src/components/icon.tsx @@ -3,5 +3,6 @@ import { SvgXml } from 'react-native-svg'; import { iconSvg, type IconName } from './icon-paths'; export function Icon({ name, color, size = 22 }: { name: IconName; color: string; size?: number }) { - return ; + // Icons keep their size next to text that wraps. + return ; } diff --git a/apps/mobile/src/components/screen.tsx b/apps/mobile/src/components/screen.tsx index 5870d1d24..e747b1e27 100644 --- a/apps/mobile/src/components/screen.tsx +++ b/apps/mobile/src/components/screen.tsx @@ -6,12 +6,12 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; * A tab's scrolling content. On iOS the native header and tab bar adjust the * insets automatically; `safeTop` is for screens without a header. */ -export function Screen({ children, safeTop }: { children: ReactNode; safeTop?: boolean }) { +export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTop?: boolean; gap?: number }) { const insets = useSafeAreaInsets(); return ( + contentContainerStyle={[styles.content, safeTop && { paddingTop: insets.top + 12 }, gap !== undefined && { gap }]}> {children} ); diff --git a/apps/mobile/src/lib/account.ts b/apps/mobile/src/lib/account.ts new file mode 100644 index 000000000..c1d582933 --- /dev/null +++ b/apps/mobile/src/lib/account.ts @@ -0,0 +1,24 @@ +import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; + +import { supabase } from './supabase'; + +/** The text a user types to confirm deletion. Must match deletionConfirmation in supabase/functions/account. */ +export function deletionConfirmation(email: string | undefined): string { + return email || 'DELETE'; +} + +/** + * Permanently deletes the signed-in account through the `account` Edge + * Function, with the same request as the website's src/lib/account.ts. Any + * Stripe subscription is canceled first. + */ +export async function deleteAccount(confirmation: string): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) throw new Error('Please sign in first'); + const { error } = await supabase.functions.invoke('account', { + headers: { Authorization: `Bearer ${accessToken}` }, + body: { action: 'delete', confirmation }, + }); + if (error) throw new Error((await getEdgeFunctionErrorMessage(error)) || 'Could not delete your account'); +} diff --git a/apps/mobile/src/lib/dates.ts b/apps/mobile/src/lib/dates.ts new file mode 100644 index 000000000..98ea19c9a --- /dev/null +++ b/apps/mobile/src/lib/dates.ts @@ -0,0 +1,30 @@ +import { isAppLanguage, toIntlLocale, type AppLanguage } from '@debatelab/core/languages'; +import { formatDistanceToNow, formatRelative, isToday } from 'date-fns'; +import { enGB, es, hu } from 'date-fns/locale'; + +import i18n from './i18n'; + +const LOCALES = { en: enGB, es, hu } as const; + +function language(): AppLanguage { + return isAppLanguage(i18n.resolvedLanguage) ? i18n.resolvedLanguage : 'en'; +} + +/** "3 weeks ago", as the website's formatRelativeDate. */ +export function formatRelativeDate(value: number): string { + return formatDistanceToNow(value, { addSuffix: true, locale: LOCALES[language()] }); +} + +/** "today at 16:43", "yesterday at 09:10", then a date. */ +export function formatWhen(value: number): string { + return formatRelative(value, Date.now(), { locale: LOCALES[language()] }); +} + +export { isToday }; + +/** A day like 31/12/2026, as the website's formatEuropeanDate. */ +export function formatDate(value: string): string { + const date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00` : value); + if (Number.isNaN(date.getTime())) return ''; + return new Intl.DateTimeFormat(toIntlLocale(language()), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date); +} diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx new file mode 100644 index 000000000..bda43d62f --- /dev/null +++ b/apps/mobile/src/lib/debate-context.tsx @@ -0,0 +1,131 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { AppState } from 'react-native'; + +import { useAuth } from './auth'; +import { + createDebate, + fetchDebates, + sortDebates, + switchDebate, + type Debate, + type DebateSettings, +} from './debates'; + +type Status = 'loading' | 'ready' | 'error'; + +type DebateContextValue = { + status: Status; + debates: Debate[]; + current: Debate | null; + /** Session-only, as on the website: tools run without reading from or saving to any debate. */ + oneOff: boolean; + /** The debate that new work attaches to right now, or null for one-off work. */ + activeDebateId: string | null; + refresh: () => Promise; + create: (input: { motion: string; settings: DebateSettings }) => Promise; + switchTo: (id: string) => Promise; + setOneOff: (oneOff: boolean) => void; +}; + +const DebateContext = createContext(null); + +export function DebateProvider({ children }: { children: ReactNode }) { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [status, setStatus] = useState('loading'); + const [debates, setDebates] = useState([]); + const [currentId, setCurrentId] = useState(null); + const [oneOff, setOneOff] = useState(false); + // A load that started before a create or switch must not overwrite it, only + // the latest of overlapping loads may land, and nothing from a previous + // owner may land after a sign-out or account switch. The generation is + // bumped on every owner change, so work started before signing out and back + // in as the same account does not land either. + const writes = useRef(0); + const refreshes = useRef(0); + const owner = useRef(userId); + const generation = useRef(0); + + const refresh = useCallback(async () => { + const forUser = owner.current; + if (!forUser) return; + const writesBefore = writes.current; + const request = ++refreshes.current; + try { + const next = await fetchDebates(forUser); + if (owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; + setDebates(next.debates); + setCurrentId(next.currentId); + setStatus('ready'); + } catch (cause) { + if (owner.current !== forUser || refreshes.current !== request) return; + console.warn('Could not load debates:', cause instanceof Error ? cause.message : cause); + // Keep what is on screen; only an empty first load becomes an error. + setStatus((previous) => (previous === 'ready' ? previous : 'error')); + } + }, []); + + useEffect(() => { + owner.current = userId; + generation.current += 1; + setDebates([]); + setCurrentId(null); + setOneOff(false); + setStatus('loading'); + if (!userId) return; + void refresh(); + // Pick up debates started or switched on the website while the app was in the background. + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') void refresh(); + }); + return () => subscription.remove(); + }, [userId, refresh]); + + const value = useMemo(() => { + const current = debates.find((debate) => debate.id === currentId) ?? null; + return { + status, + debates, + current, + oneOff, + activeDebateId: oneOff ? null : current?.id ?? null, + refresh, + // The account is read when the student acts. If the owner changed while + // the write ran, the write still happened for that account, so it is + // reported as done but not shown to whoever is signed in now. + create: async ({ motion, settings }) => { + const forUser = owner.current; + if (!forUser) throw new Error('Sign in to manage debates'); + const gen = generation.current; + const debate = await createDebate(forUser, { motion, settings }); + if (generation.current !== gen) return debate; + writes.current += 1; + setDebates((previous) => sortDebates([debate, ...previous.filter((item) => item.id !== debate.id)])); + setCurrentId(debate.id); + setOneOff(false); + setStatus('ready'); + return debate; + }, + switchTo: async (id) => { + const forUser = owner.current; + if (!forUser) throw new Error('Sign in to manage debates'); + const gen = generation.current; + const at = await switchDebate(forUser, id); + if (generation.current !== gen) return; + writes.current += 1; + setDebates((previous) => sortDebates(previous.map((item) => (item.id === id ? { ...item, lastActivityAt: at } : item)))); + setCurrentId(id); + setOneOff(false); + }, + setOneOff, + }; + }, [status, debates, currentId, oneOff, refresh]); + + return {children}; +} + +export function useDebates(): DebateContextValue { + const value = useContext(DebateContext); + if (!value) throw new Error('useDebates must be used inside DebateProvider'); + return value; +} diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts new file mode 100644 index 000000000..d3c63de72 --- /dev/null +++ b/apps/mobile/src/lib/debates.ts @@ -0,0 +1,240 @@ +import { supabase } from './supabase'; + +/** + * The website's debate tables, read and written the way its debate store + * (src/lib/stores/debate-store.ts) does, so the app and the website share one + * Current Debate. The store itself stays in the website: it persists to + * localStorage and uses the web Supabase client. + */ + +export interface DebateSettings { + format?: string; + role?: string; + judge?: string; +} + +export interface Debate { + id: string; + motion: string; + settings: DebateSettings; + instructions: string; + lastActivityAt: number; + createdAt: number; +} + +export const MAX_MOTION_LENGTH = 1200; + +/** The web DebateForm's formats, in its order. */ +export const DEBATE_FORMATS = ['custom', 'bp', 'policy', 'classroom'] as const; +export type DebateFormat = (typeof DEBATE_FORMATS)[number]; + +const DEBATE_COLUMNS = 'id,motion,settings,instructions,last_activity_at,created_at'; + +interface DebateRow { + id: string; + motion: string; + settings: unknown; + instructions: string | null; + last_activity_at: string; + created_at: string; +} + +function parseSettings(value: unknown): DebateSettings { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const record = value as Record; + const settings: DebateSettings = {}; + for (const key of ['format', 'role', 'judge'] as const) { + if (typeof record[key] === 'string' && record[key]) settings[key] = record[key] as string; + } + return settings; +} + +function fromRow(row: DebateRow): Debate { + return { + id: row.id, + motion: row.motion, + settings: parseSettings(row.settings), + instructions: row.instructions ?? '', + lastActivityAt: new Date(row.last_activity_at).getTime(), + createdAt: new Date(row.created_at).getTime(), + }; +} + +export function sortDebates(debates: Debate[]): Debate[] { + return [...debates].sort((a, b) => b.lastActivityAt - a.lastActivityAt); +} + +async function setCurrentPointer(userId: string, debateId: string) { + const { error } = await supabase + .from('current_debates') + .upsert({ user_id: userId, debate_id: debateId, updated_at: new Date().toISOString() }, { onConflict: 'user_id' }); + if (error) throw new Error(error.message); +} + +/** + * Every read and write below also filters on the user. Row level security is + * the real boundary; the filter keeps a wrong session from mixing accounts. + */ + +/** Every debate, newest activity first, and the current one when it is still in the list. */ +export async function fetchDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { + const [debatesResult, currentResult] = await Promise.all([ + supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), + supabase.from('current_debates').select('debate_id').eq('user_id', userId).maybeSingle(), + ]); + if (debatesResult.error) throw new Error(debatesResult.error.message); + if (currentResult.error) throw new Error(currentResult.error.message); + const debates = ((debatesResult.data ?? []) as DebateRow[]).map(fromRow); + const currentId = (currentResult.data as { debate_id?: string } | null)?.debate_id ?? null; + return { + debates: sortDebates(debates), + currentId: currentId && debates.some((debate) => debate.id === currentId) ? currentId : null, + }; +} + +/** Creates a debate and makes it current, as the website's create dialog does. */ +export async function createDebate( + userId: string, + input: { motion: string; settings: DebateSettings }, +): Promise { + const motion = input.motion.trim(); + if (!motion) throw new Error('A debate needs a motion'); + const { data, error } = await supabase + .from('debates') + .insert({ user_id: userId, motion: motion.slice(0, MAX_MOTION_LENGTH), settings: input.settings, instructions: '' }) + .select(DEBATE_COLUMNS) + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not create debate'); + const debate = fromRow(data as DebateRow); + await setCurrentPointer(userId, debate.id); + return debate; +} + +/** Makes a debate current. Choosing it also counts as activity, as on the website. */ +export async function switchDebate(userId: string, debateId: string): Promise { + await setCurrentPointer(userId, debateId); + const now = Date.now(); + // The website does not fail a switch when only this timestamp could not be saved. + await supabase.from('debates').update({ last_activity_at: new Date(now).toISOString() }) + .eq('id', debateId) + .eq('user_id', userId); + return now; +} + +/** Tables whose rows are a debate's saved results, as listed by the website's debate counts. */ +const RESULT_TABLES = ['cases', 'attacks', 'brainstorms', 'factoids', 'chats', 'flowcharts', 'crossfires'] as const; +type ResultTable = (typeof RESULT_TABLES)[number]; + +export type DebateItemKind = 'case' | 'attack' | 'brainstorm' | 'factoids' | 'chat' | 'flowchart' | 'crossfire' | 'sparring'; + +const KIND_BY_TABLE: Record = { + cases: 'case', + attacks: 'attack', + brainstorms: 'brainstorm', + factoids: 'factoids', + chats: 'chat', + flowcharts: 'flowchart', + crossfires: 'crossfire', +}; + +/** The two sides a debate is prepared for. */ +export type DebateSide = 'pro' | 'con'; + +/** + * The saved sides that belong to each side's preparation. As on the website's + * Current Debate page, 'both' counts for either side and neutral or side-less + * work counts for neither. + */ +const SIDES_FOR: Record = { pro: ['pro', 'both'], con: ['con', 'both'] }; + +export interface DebateCounts { + sources: number; + /** Saved results per side. */ + results: Record; + rounds: number; +} + +async function countRows(userId: string, table: string, debateId: string, sides?: string[]): Promise { + let query = supabase + .from(table) + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('debate_id', debateId); + if (sides) query = query.in('side', sides); + const { count, error } = await query; + if (error) throw new Error(error.message); + return count ?? 0; +} + +const sum = (counts: number[]) => counts.reduce((total, count) => total + count, 0); + +/** Sources, saved results per side and Sparring rounds in one debate. One-off work has no debate and never counts. */ +export async function fetchDebateCounts(userId: string, debateId: string): Promise { + const [sources, rounds, pro, con] = await Promise.all([ + countRows(userId, 'sources', debateId), + countRows(userId, 'sparring_sessions', debateId), + Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.pro))), + Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.con))), + ]); + return { sources, rounds, results: { pro: sum(pro), con: sum(con) } }; +} + +/** Library sources per debate, for the switcher's list. */ +export async function fetchSourceCounts(userId: string, debateIds: string[]): Promise> { + const counts = await Promise.all(debateIds.map((id) => countRows(userId, 'sources', id))); + return Object.fromEntries(debateIds.map((id, index) => [id, counts[index]])); +} + +export interface DebateItem { + id: string; + kind: DebateItemKind; + title: string; + createdAt: number; +} + +/** The newest saved results and rounds for one side of a debate, like the website's side lists. */ +export async function fetchRecentWork( + userId: string, + debateId: string, + side: DebateSide, + limit: number, +): Promise { + const lists = await Promise.all([ + ...RESULT_TABLES.map(async (table) => { + const { data, error } = await supabase + .from(table) + .select('id,title,created_at') + .eq('user_id', userId) + .eq('debate_id', debateId) + .in('side', SIDES_FOR[side]) + .order('created_at', { ascending: false }) + .limit(limit); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ + id: row.id, + kind: KIND_BY_TABLE[table], + title: row.title, + createdAt: new Date(row.created_at).getTime(), + })); + }), + (async () => { + // The website lists a round under its motion and the side the student spoke, ordered by its last update. + const { data, error } = await supabase + .from('sparring_sessions') + .select('id,motion,updated_at') + .eq('user_id', userId) + .eq('debate_id', debateId) + .eq('user_side', side) + .order('updated_at', { ascending: false }) + .limit(limit); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; motion: string; updated_at: string }[]).map((row): DebateItem => ({ + id: row.id, + kind: 'sparring', + title: row.motion, + createdAt: new Date(row.updated_at).getTime(), + })); + })(), + ]); + return lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); +} diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts index 8d93f192e..b41fa428b 100644 --- a/apps/mobile/src/lib/i18n.ts +++ b/apps/mobile/src/lib/i18n.ts @@ -8,8 +8,8 @@ import en from '../../../../src/locales/en.json'; import es from '../../../../src/locales/es.json'; import hu from '../../../../src/locales/hu.json'; -/** The device language when DebateLab has it, otherwise English. The settings choice arrives with the You tab. */ -function deviceLanguage(): AppLanguage { +/** The device language when DebateLab has it, otherwise English. Signed in, the account's choice wins (see settings.tsx). */ +export function deviceLanguage(): AppLanguage { const code = getLocales()[0]?.languageCode; return isAppLanguage(code) ? code : 'en'; } diff --git a/apps/mobile/src/lib/serial-queue.test.ts b/apps/mobile/src/lib/serial-queue.test.ts new file mode 100644 index 000000000..644e885c1 --- /dev/null +++ b/apps/mobile/src/lib/serial-queue.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { createSerialQueue } from './serial-queue'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('createSerialQueue', () => { + it('finishes a slow task before starting the next', async () => { + const queue = createSerialQueue(); + const writes: string[] = []; + await Promise.all([ + queue(async () => { + await delay(20); + writes.push('hu'); + }), + queue(async () => { + writes.push('es'); + }), + ]); + expect(writes).toEqual(['hu', 'es']); + }); + + it('runs the next task after one fails', async () => { + const queue = createSerialQueue(); + const failed = queue(async () => { + await delay(5); + throw new Error('offline'); + }); + const next = queue(async () => 'saved'); + await expect(failed).rejects.toThrow('offline'); + await expect(next).resolves.toBe('saved'); + }); +}); diff --git a/apps/mobile/src/lib/serial-queue.ts b/apps/mobile/src/lib/serial-queue.ts new file mode 100644 index 000000000..b24ac115a --- /dev/null +++ b/apps/mobile/src/lib/serial-queue.ts @@ -0,0 +1,14 @@ +export type SerialQueue = (task: () => Promise) => Promise; + +/** + * Runs tasks one at a time, in the order they were added: each starts after + * the one before it settles. A failed task does not stop the ones after it. + */ +export function createSerialQueue(): SerialQueue { + let tail: Promise = Promise.resolve(); + return (task) => { + const run = tail.then(task); + tail = run.catch(() => {}); + return run; + }; +} diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx new file mode 100644 index 000000000..eb91586b3 --- /dev/null +++ b/apps/mobile/src/lib/settings.tsx @@ -0,0 +1,253 @@ +import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; +import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; + +import { useAuth } from './auth'; +import i18n, { deviceLanguage } from './i18n'; +import { createSerialQueue } from './serial-queue'; +import { supabase } from './supabase'; + +import { applyColorScheme } from '@/theme/apply-color-scheme'; + +export type ThemePreference = 'light' | 'dark' | 'system'; + +type Settings = { language: AppLanguage; outputLanguage: AppLanguage; theme: ThemePreference }; +type SettingKey = keyof Settings; + +type SettingsContextValue = Settings & { + /** + * False until the settings to open with are known: the signed-in account's + * last known values from the device, or the device defaults. It never turns + * false again, because the root navigator must not unmount on sign-in. + */ + ready: boolean; + setLanguage: (language: AppLanguage) => Promise; + setOutputLanguage: (language: AppLanguage) => Promise; + setTheme: (theme: ThemePreference) => Promise; +}; + +// The columns each setting is stored in, in the website's user_settings row. +const COLUMNS = { language: 'language', outputLanguage: 'output_language', theme: 'theme' } as const; +const KEYS = Object.keys(COLUMNS) as SettingKey[]; + +const SIGNED_OUT: Settings = { language: deviceLanguage(), outputLanguage: 'en', theme: 'system' }; + +const SettingsContext = createContext({ + ...SIGNED_OUT, + ready: true, + setLanguage: async () => {}, + setOutputLanguage: async () => {}, + setTheme: async () => {}, +}); + +function isThemePreference(value: unknown): value is ThemePreference { + return value === 'light' || value === 'dark' || value === 'system'; +} + +function parseSettings(value: unknown): Settings | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + return { + language: isAppLanguage(record.language) ? record.language : 'en', + outputLanguage: isAppLanguage(record.outputLanguage ?? record.output_language) + ? (record.outputLanguage ?? record.output_language) as AppLanguage + : 'en', + theme: isThemePreference(record.theme) ? record.theme : 'system', + }; +} + +const cacheKey = (userId: string) => `debatelab.settings.${userId}`; + +function isEmptyObject(value: unknown): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0; +} + +/** + * The starting model the website would pick for this student, or null when + * their Pro access can't be read right now. + */ +async function defaultModelFor(userId: string): Promise { + const { data, error } = await supabase + .from('user_settings') + .select('subscription_tier,grace_period_ends_at') + .eq('user_id', userId) + .maybeSingle(); + if (error || !data) return null; + const pro = await canAccessProModels(data, async () => { + const { data: rows, error: billingError } = await supabase + .from('subscriptions') + .select('status, current_period_end') + .eq('user_id', userId); + return billingError ? null : rows ?? []; + }); + if (pro === null) return null; + return pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; +} + +/** + * The sign-up trigger provisions the row and clients may only update it, as in + * the website's settings store. Only the changed column is written, plus the + * website's default feature_configs while the row has none: the website reads + * an empty feature_configs as "never saved" and would replace this setting + * with the browser's. The defaults use the Pro model when the student has Pro + * access, as the website's would, because the website never upgrades configs + * once they are saved. The filter keeps configs saved in the meantime; when it + * matches nothing, configs were filled elsewhere and only the column is written. + * + * When configs may be empty and Pro access can't be read, nothing is written + * and the save fails: a column saved alone would be replaced by the website. + * Once this resolves, the row's feature_configs is filled in. + */ +async function writeSetting(userId: string, key: SettingKey, value: string, configsMayBeEmpty: boolean) { + const changes = { [COLUMNS[key]]: value, updated_at: new Date().toISOString() }; + if (configsMayBeEmpty) { + const model = await defaultModelFor(userId); + if (!model) throw new Error('Could not read Pro access to fill in the default feature configs'); + const { data, error } = await supabase + .from('user_settings') + .update({ ...changes, feature_configs: getDefaultFeatureConfigs(model) }) + .eq('user_id', userId) + .containedBy('feature_configs', {}) + .select('user_id'); + if (error) throw new Error(error.message); + if (data && data.length > 0) return; + } + const { error } = await supabase.from('user_settings').update(changes).eq('user_id', userId); + if (error) throw new Error(error.message); +} + +/** + * Interface language, AI output language and theme from the account's + * user_settings row, shared with the website. The last known values are kept + * on the device so the app opens in the right language and theme. + */ +export function SettingsProvider({ children }: { children: ReactNode }) { + const { session, loading } = useAuth(); + const userId = session?.user.id ?? null; + const [settings, setSettings] = useState(SIGNED_OUT); + const [ready, setReady] = useState(false); + const owner = useRef(userId); + // Bumped on every owner change, including signing out and back in as the + // same account. Async work from an earlier generation never lands, even when + // the same account is signed in again. + const generation = useRef(0); + // The settings last applied, so a save builds on every earlier save. + const latest = useRef(settings); + // Per key, the value the account's row last held as far as the app knows: + // from a load, or from a save that succeeded. A failed save rolls back to it. + const confirmed = useRef({ ...SIGNED_OUT }); + // Bumped by every save of a key. A load keeps the local value of any key + // saved after it started, and a failed save rolls its key back only while it + // is still the latest save of that key. + const versions = useRef>({ language: 0, outputLanguage: 0, theme: 0 }); + // Whether the row's feature_configs is filled in; null until a load or save tells. + const configsSaved = useRef(null); + // Saves write one at a time in the order they were made, so the last choice + // is the last write even when an earlier save's Pro-access check is slower. + const [writes] = useState(createSerialQueue); + + const apply = useCallback((next: Settings, forUser: string | null, gen: number) => { + if (generation.current !== gen) return; + latest.current = next; + setSettings(next); + if (i18n.language !== next.language) void i18n.changeLanguage(next.language); + applyColorScheme(next.theme); + if (forUser) { + AsyncStorage.setItem(cacheKey(forUser), JSON.stringify(next)).catch((cause: unknown) => + console.warn('Could not keep settings on this device:', cause), + ); + } + }, []); + + useEffect(() => { + if (loading) return; + owner.current = userId; + const gen = ++generation.current; + configsSaved.current = null; + if (!userId) { + apply({ ...SIGNED_OUT, language: deviceLanguage() }, null, gen); + confirmed.current = { ...latest.current }; + setReady(true); + return; + } + confirmed.current = { ...latest.current }; + let cancelled = false; + const started = { ...versions.current }; + // Loaded values, except for keys saved since the load started. The values + // taken also become the confirmed ones. + const merge = (loaded: Settings): Settings => { + const next = { ...latest.current }; + for (const key of KEYS) { + if (versions.current[key] !== started[key]) continue; + Object.assign(next, { [key]: loaded[key] }); + Object.assign(confirmed.current, { [key]: loaded[key] }); + } + return next; + }; + void (async () => { + try { + const cached = parseSettings(JSON.parse((await AsyncStorage.getItem(cacheKey(userId))) ?? 'null')); + if (!cancelled && cached) apply(merge(cached), userId, gen); + } catch (cause) { + console.warn('Could not read settings from this device:', cause); + } + if (!cancelled) setReady(true); + const { data, error } = await supabase + .from('user_settings') + .select('language,output_language,theme,feature_configs') + .eq('user_id', userId) + .maybeSingle(); + if (cancelled) return; + if (error) { + console.warn('Could not load settings:', error.message); + return; + } + if (data && configsSaved.current === null) configsSaved.current = !isEmptyObject(data.feature_configs); + const server = parseSettings(data); + if (server) apply(merge(server), userId, gen); + })(); + return () => { + cancelled = true; + }; + }, [loading, userId, apply]); + + const save = useCallback(async (key: K, value: Settings[K]) => { + const forUser = owner.current; + if (!forUser) return; + const gen = generation.current; + const version = ++versions.current[key]; + apply({ ...latest.current, [key]: value }, forUser, gen); + try { + await writes(async () => { + // A save made before an owner change is dropped, not written after it, + // even when the same account has signed in again since. + if (generation.current !== gen) return; + await writeSetting(forUser, key, value, configsSaved.current !== true); + if (generation.current !== gen) return; + configsSaved.current = true; + Object.assign(confirmed.current, { [key]: value }); + }); + } catch (cause) { + if (generation.current !== gen) return; + if (versions.current[key] === version) apply({ ...latest.current, [key]: confirmed.current[key] }, forUser, gen); + throw cause; + } + }, [apply, writes]); + + const value = useMemo(() => ({ + ...settings, + ready, + setLanguage: (language) => save('language', language), + setOutputLanguage: (language) => save('outputLanguage', language), + setTheme: (theme) => save('theme', theme), + }), [settings, ready, save]); + + return {children}; +} + +export function useSettings(): SettingsContextValue { + return useContext(SettingsContext); +} diff --git a/apps/mobile/src/lib/subscription.ts b/apps/mobile/src/lib/subscription.ts new file mode 100644 index 000000000..886d4b6ae --- /dev/null +++ b/apps/mobile/src/lib/subscription.ts @@ -0,0 +1,84 @@ +import { supabase } from './supabase'; + +/** + * The plan and today's AI usage, computed exactly as the website's + * useSubscription (src/hooks/use-subscription.ts) does. That hook is not + * shared yet because it changes with in-app purchase. + */ +export type SubscriptionTier = 'trial' | 'pro' | 'legacy_free' | 'expired'; + +export interface Subscription { + tier: SubscriptionTier; + dailyLimit: number; + dailyUsed: number; + trialEndsAt: string | null; +} + +interface BillingRow { + status?: string | null; + current_period_end?: string | null; +} + +export async function fetchSubscription(userId: string): Promise { + const snapshot: Subscription = { tier: 'expired', dailyLimit: 0, dailyUsed: 0, trialEndsAt: null }; + + const { data, error: settingsError } = await supabase + .from('user_settings') + .select('subscription_tier, daily_limit_override, trial_ends_at, grace_period_ends_at') + .eq('user_id', userId) + .maybeSingle(); + if (settingsError) throw new Error(settingsError.message); + + if (data) { + const rawTier = (data.subscription_tier || 'trial') as SubscriptionTier; + const limitOrDefault = (defaultLimit: number) => (data.daily_limit_override as number | null) ?? defaultLimit; + const now = new Date(); + + const { data: billingData, error: billingError } = await supabase + .from('subscriptions') + .select('status, current_period_end') + .eq('user_id', userId) + .order('current_period_end', { ascending: false, nullsFirst: false }) + .limit(5); + if (billingError) console.warn('Could not load billing state:', billingError.message); + const billingRows: BillingRow[] = billingError ? [] : billingData ?? []; + + const activeStripeSubscription = billingRows.some((row) => + (row.status === 'active' || row.status === 'trialing') && + row.current_period_end && + new Date(row.current_period_end) > now, + ); + const promoEnd = data.grace_period_ends_at ? new Date(data.grace_period_ends_at as string) : null; + const trialEnd = data.trial_ends_at ? new Date(data.trial_ends_at as string) : null; + const hasActiveTrialGift = Boolean(trialEnd && now < trialEnd); + snapshot.trialEndsAt = (data.trial_ends_at as string | null) ?? null; + + if (rawTier === 'legacy_free') { + snapshot.tier = promoEnd && now < promoEnd ? 'legacy_free' : hasActiveTrialGift ? 'trial' : 'expired'; + snapshot.dailyLimit = limitOrDefault(snapshot.tier === 'legacy_free' ? 50 : snapshot.tier === 'trial' ? 5 : 0); + } else if (rawTier === 'pro' && ((promoEnd && now < promoEnd) || activeStripeSubscription)) { + snapshot.tier = 'pro'; + snapshot.dailyLimit = limitOrDefault(50); + } else if (rawTier === 'trial' && trialEnd && now < trialEnd) { + snapshot.tier = 'trial'; + snapshot.dailyLimit = limitOrDefault(5); + } else if (hasActiveTrialGift) { + snapshot.tier = 'trial'; + snapshot.dailyLimit = limitOrDefault(5); + } else { + snapshot.dailyLimit = limitOrDefault(0); + } + } + + // Usage resets at midnight UTC. + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const { count, error: usageError } = await supabase + .from('ai_requests') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .gte('created_at', todayStart.toISOString()); + if (usageError) console.warn('Could not load daily usage:', usageError.message); + + return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 }; +} diff --git a/apps/mobile/src/theme/apply-color-scheme.ts b/apps/mobile/src/theme/apply-color-scheme.ts new file mode 100644 index 000000000..927f334bf --- /dev/null +++ b/apps/mobile/src/theme/apply-color-scheme.ts @@ -0,0 +1,6 @@ +import { Appearance } from 'react-native'; + +/** Native controls, sheets and the tab bar follow the theme chosen in You, not only the system's. */ +export function applyColorScheme(theme: 'light' | 'dark' | 'system') { + Appearance.setColorScheme(theme === 'system' ? 'unspecified' : theme); +} diff --git a/apps/mobile/src/theme/apply-color-scheme.web.ts b/apps/mobile/src/theme/apply-color-scheme.web.ts new file mode 100644 index 000000000..de2643432 --- /dev/null +++ b/apps/mobile/src/theme/apply-color-scheme.web.ts @@ -0,0 +1,2 @@ +/** The web preview draws everything from useTheme, so there is no native appearance to set. */ +export function applyColorScheme(_theme: 'light' | 'dark' | 'system') {} diff --git a/apps/mobile/src/theme/use-theme.ts b/apps/mobile/src/theme/use-theme.ts index cd52fd01b..4764e06ab 100644 --- a/apps/mobile/src/theme/use-theme.ts +++ b/apps/mobile/src/theme/use-theme.ts @@ -1,7 +1,12 @@ import { palette } from './colors'; import { useColorScheme } from './use-color-scheme'; +import { useSettings } from '@/lib/settings'; + +/** The theme chosen in You, or the system's when it is System. */ export function useTheme() { - const scheme = useColorScheme() === 'dark' ? 'dark' : 'light'; + const { theme } = useSettings(); + const system = useColorScheme() === 'dark' ? 'dark' : 'light'; + const scheme = theme === 'system' ? system : theme; return { scheme, colors: palette[scheme] } as const; } diff --git a/docs/development.md b/docs/development.md index 862f6bff3..32c7fd3d0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -101,14 +101,14 @@ The iOS app lives in [`apps/mobile/`](../apps/mobile/AGENTS.md). The root `npm c ### Screenshots on the VPS -The web export renders the app's screens in Chromium. It has no native tab bar, large titles, sheets or gestures, so it shows layout, copy and colour, not iOS behaviour. +The web export renders the app's screens in Chromium. It has no native tab bar, large titles or gestures, and it draws the form sheets as plain full-screen pages, so it shows layout, copy and colour, not iOS behaviour. ```bash npm run -w @debatelab/mobile export:web PORT= npx tsx apps/mobile/scripts/screenshots.ts ``` -Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. +Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form and the Debate tab's empty state, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. ### Expo Go on Adam's iPhone diff --git a/package-lock.json b/package-lock.json index b720a43b4..ca545ac95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,6 +123,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", "aes-js": "^3.1.2", + "date-fns": "^3.6.0", "expo": "~57.0.27", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", diff --git a/packages/core/src/ai/feature-configs.ts b/packages/core/src/ai/feature-configs.ts new file mode 100644 index 000000000..d5ba62f41 --- /dev/null +++ b/packages/core/src/ai/feature-configs.ts @@ -0,0 +1,27 @@ +import { DEFAULT_MODEL } from '../contract/model-catalog'; +import { PROVIDER_ID, type ProviderConfig } from './providers/types'; + +export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; + +const defaultConfig: ProviderConfig = { + provider: PROVIDER_ID, + model: DEFAULT_MODEL, + temperature: 0.7, + reasoningEffort: 'low', +}; + +/** + * The model settings each tool starts with. The website saves these in + * user_settings.feature_configs, and the app writes them too when it saves the + * first setting, because the website reads an empty feature_configs as "never + * saved". Both pass DEFAULT_PRO_MODEL when canAccessProModels (pro-access.ts) + * says the student has Pro access. + */ +export const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record => ({ + generate: { ...defaultConfig, model }, + attack: { ...defaultConfig, model }, + brainstorm: { ...defaultConfig, model, temperature: 0.9 }, + factoids: { ...defaultConfig, model, temperature: 0.3 }, + ask: { ...defaultConfig, model }, + sparring: { ...defaultConfig, model }, +}); diff --git a/packages/core/src/ai/pro-access.ts b/packages/core/src/ai/pro-access.ts new file mode 100644 index 000000000..0b954865c --- /dev/null +++ b/packages/core/src/ai/pro-access.ts @@ -0,0 +1,32 @@ +export interface BillingPeriod { + status?: string | null; + current_period_end?: string | null; +} + +/** + * Whether the student may use Pro models, from their user_settings row: a Pro + * or legacy tier in its grace period, or a Pro tier with an active or trialing + * subscription that has not ended. The website's settings store and the app + * both pick the starting model with it. loadSubscriptions runs only for a Pro + * tier outside its grace period; when it returns null, so does this. + */ +export async function canAccessProModels( + settings: { subscription_tier?: unknown; grace_period_ends_at?: unknown }, + loadSubscriptions: () => Promise, + now: Date = new Date(), +): Promise { + const tier = settings.subscription_tier; + const graceEnd = typeof settings.grace_period_ends_at === 'string' + ? new Date(settings.grace_period_ends_at) + : null; + if ((tier === 'pro' || tier === 'legacy_free') && graceEnd && graceEnd > now) return true; + if (tier !== 'pro') return false; + + const subscriptions = await loadSubscriptions(); + if (!subscriptions) return null; + return subscriptions.some((subscription) => + (subscription.status === 'active' || subscription.status === 'trialing') + && !!subscription.current_period_end + && new Date(subscription.current_period_end) > now + ); +} diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 6c4c56610..2c2a6cd39 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { applyAppLanguage } from '@/i18n'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; +import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels as canAccessProModelsFor } from '@debatelab/core/ai/pro-access'; import { AUTO_FAST, AUTO_PRO, @@ -14,7 +16,7 @@ import { import type { ProviderConfig } from '../ai/providers/types'; import { supabase } from '@/lib/supabase/client'; -export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; +export type { FeatureTab }; type CoreFeatureTab = Exclude; type FeatureConfigs = Record & Partial>; @@ -54,22 +56,6 @@ const LEGACY_MODEL_SUCCESSORS: Record = { 'gemini-3.1-flash-lite-preview': 'google/gemini-3.8-flash', }; -const defaultConfig: ProviderConfig = { - provider: DEFAULT_PROVIDER, - model: DEFAULT_MODEL, - temperature: 0.7, - reasoningEffort: 'low', -}; - -const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record => ({ - generate: { ...defaultConfig, model }, - attack: { ...defaultConfig, model }, - brainstorm: { ...defaultConfig, model, temperature: 0.9 }, - factoids: { ...defaultConfig, model, temperature: 0.3 }, - ask: { ...defaultConfig, model }, - sparring: { ...defaultConfig, model }, -}); - function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -143,25 +129,15 @@ function featureConfigsAreCanonical(value: unknown, expected: Record): Promise { - const tier = settings.subscription_tier; - const graceEnd = typeof settings.grace_period_ends_at === 'string' - ? new Date(settings.grace_period_ends_at) - : null; - if ((tier === 'pro' || tier === 'legacy_free') && graceEnd && graceEnd > new Date()) return true; - if (tier !== 'pro') return false; - - const userId = await getUserId(); - if (!userId) return null; - const { data, error } = await supabase - .from('subscriptions') - .select('status, current_period_end') - .eq('user_id', userId); - if (error) return null; - return Boolean(data?.some((subscription) => - (subscription.status === 'active' || subscription.status === 'trialing') - && subscription.current_period_end - && new Date(subscription.current_period_end) > new Date() - )); + return canAccessProModelsFor(settings, async () => { + const userId = await getUserId(); + if (!userId) return null; + const { data, error } = await supabase + .from('subscriptions') + .select('status, current_period_end') + .eq('user_id', userId); + return error ? null : data ?? []; + }); } async function getUserId() { diff --git a/src/locales/en.json b/src/locales/en.json index 678cac756..2ce586d23 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2411,7 +2411,41 @@ "body": "This part of DebateLab is on its way to the app. Until then, use it on the website." }, "debate": { - "comingSoon": "Starting and switching debates comes to the app in the next update." + "statSources_one": "source", + "statSources_other": "sources", + "statResults_one": "result", + "statResults_other": "results", + "statRounds_one": "round", + "statRounds_other": "rounds", + "spar": "Spar against the AI", + "sparSide": "You pick your side when the round starts", + "pickUp": "Pick up where you left off", + "itemMeta": "{{kind}}, {{when}}", + "loadFailed": "Could not load your debate.", + "viewAria": "Show preparation for" + }, + "common": { + "done": "Done" + }, + "debates": { + "search": "Search motions", + "noMatches": "No debates match your search.", + "sourceCount_one": "{{count}} source", + "sourceCount_other": "{{count}} sources", + "activeToday": "active today", + "meta": "{{sources}}, {{when}}", + "oneOff": "One-off mode", + "oneOffDesc": "Use a tool without saving to a debate", + "newDebate": "New debate", + "loadFailed": "Could not load your debates." + }, + "you": { + "usage": "{{used}} of {{limit}} AI uses today", + "loadFailed": "Could not load your subscription.", + "saveFailed": "Could not save your settings. Try again." + }, + "deleteAccount": { + "failedHint": "Your account is still here. Check your connection and try again." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 4bad9f8e4..a8d2c96d6 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2411,7 +2411,41 @@ "body": "Esta parte de DebateLab llegará pronto a la app. Mientras tanto, úsala en la web." }, "debate": { - "comingSoon": "Podrás iniciar y cambiar de debate en la app con la próxima actualización." + "statSources_one": "fuente", + "statSources_other": "fuentes", + "statResults_one": "resultado", + "statResults_other": "resultados", + "statRounds_one": "ronda", + "statRounds_other": "rondas", + "spar": "Entrena contra la IA", + "sparSide": "Eliges tu postura al empezar la ronda", + "pickUp": "Continúa donde lo dejaste", + "itemMeta": "{{kind}}, {{when}}", + "loadFailed": "No se pudo cargar tu debate.", + "viewAria": "Mostrar la preparación de" + }, + "common": { + "done": "Listo" + }, + "debates": { + "search": "Buscar mociones", + "noMatches": "Ningún debate coincide con tu búsqueda.", + "sourceCount_one": "{{count}} fuente", + "sourceCount_other": "{{count}} fuentes", + "activeToday": "activo hoy", + "meta": "{{sources}}, {{when}}", + "oneOff": "Modo puntual", + "oneOffDesc": "Usa una herramienta sin guardar en un debate", + "newDebate": "Nuevo debate", + "loadFailed": "No se pudieron cargar tus debates." + }, + "you": { + "usage": "{{used}} de {{limit}} usos de IA hoy", + "loadFailed": "No se pudo cargar tu suscripción.", + "saveFailed": "No se pudieron guardar tus ajustes. Inténtalo de nuevo." + }, + "deleteAccount": { + "failedHint": "Tu cuenta sigue activa. Comprueba tu conexión e inténtalo de nuevo." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index a5a9b0c03..d17391164 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2411,7 +2411,41 @@ "body": "A DebateLab ezen része hamarosan az appba is megérkezik. Addig a weboldalon használhatod." }, "debate": { - "comingSoon": "Vitát indítani és váltani a következő frissítéssel tudsz majd az appban." + "statSources_one": "forrás", + "statSources_other": "forrás", + "statResults_one": "eredmény", + "statResults_other": "eredmény", + "statRounds_one": "forduló", + "statRounds_other": "forduló", + "spar": "Gyakorolj az MI ellen", + "sparSide": "Az oldaladat a forduló elején választod", + "pickUp": "Folytasd, ahol abbahagytad", + "itemMeta": "{{kind}}, {{when}}", + "loadFailed": "Nem sikerült betölteni a vitát.", + "viewAria": "Felkészülés megjelenítése" + }, + "common": { + "done": "Kész" + }, + "debates": { + "search": "Tételek keresése", + "noMatches": "Nincs a keresésnek megfelelő vita.", + "sourceCount_one": "{{count}} forrás", + "sourceCount_other": "{{count}} forrás", + "activeToday": "ma aktív", + "meta": "{{sources}}, {{when}}", + "oneOff": "Egyszeri mód", + "oneOffDesc": "Eszköz használata vitába mentés nélkül", + "newDebate": "Új vita", + "loadFailed": "Nem sikerült betölteni a vitáidat." + }, + "you": { + "usage": "Ma {{used}} / {{limit}} MI-használat", + "loadFailed": "Nem sikerült betölteni az előfizetésedet.", + "saveFailed": "Nem sikerült menteni a beállításokat. Próbáld újra." + }, + "deleteAccount": { + "failedHint": "A fiókod megmaradt. Ellenőrizd a kapcsolatot, és próbáld újra." } } } diff --git a/src/test/settings/mobile-saved-settings.test.ts b/src/test/settings/mobile-saved-settings.test.ts new file mode 100644 index 000000000..2b23812d1 --- /dev/null +++ b/src/test/settings/mobile-saved-settings.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; +import { resetMockState, setMockTableResponse, supabaseMock } from '@/test/mocks/supabase'; + +vi.mock('@/lib/supabase/client', async () => ({ supabase: (await import('@/test/mocks/supabase')).supabaseMock })); + +const { useSettingsStore } = await import('@/lib/stores/settings-store'); + +/** Every update the store sent to Supabase. */ +const updates = () => supabaseMock.from.mock.results.flatMap((result) => result.value.update.mock.calls); + +// The iOS app saves a setting with the website's default feature_configs when +// the row has none, because the website treats an empty feature_configs as a +// row that was never saved and replaces its language and theme. +describe('settings saved by the iOS app', () => { + beforeEach(() => { + resetMockState(); + useSettingsStore.setState({ language: 'en', outputLanguage: 'en', theme: 'light' }); + }); + + it('keeps the language and theme the app saved, without rewriting the row', async () => { + setMockTableResponse('user_settings', { + single: { + language: 'hu', + output_language: 'es', + theme: 'dark', + subscription_tier: 'trial', + feature_configs: getDefaultFeatureConfigs(), + }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + const state = useSettingsStore.getState(); + expect([state.language, state.outputLanguage, state.theme]).toEqual(['hu', 'es', 'dark']); + expect(updates()).toEqual([]); + }); + + it('keeps the Pro model the app saved for a Pro student, without rewriting the row', async () => { + const subscriptions = [{ status: 'active', current_period_end: new Date(Date.now() + 86_400_000).toISOString() }]; + const row = { language: 'hu', output_language: 'es', theme: 'dark', subscription_tier: 'pro' }; + // The app picks its defaults with the same check the website hydrates with. + const pro = await canAccessProModels(row, async () => subscriptions); + setMockTableResponse('subscriptions', { select: subscriptions }); + setMockTableResponse('user_settings', { + single: { ...row, feature_configs: getDefaultFeatureConfigs(pro ? DEFAULT_PRO_MODEL : undefined) }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + expect(useSettingsStore.getState().featureConfigs.generate.model).toBe(DEFAULT_PRO_MODEL); + expect(updates()).toEqual([]); + }); + + it('replaces them when feature_configs was left empty', async () => { + setMockTableResponse('user_settings', { + single: { language: 'hu', output_language: 'es', theme: 'dark', subscription_tier: 'trial', feature_configs: {} }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + const state = useSettingsStore.getState(); + expect([state.language, state.outputLanguage, state.theme]).toEqual(['en', 'en', 'light']); + }); +});