diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index c34abfcb3..5c2f84e46 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -17,15 +17,17 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), and `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), and `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. - `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. `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. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. +- Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). +- Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). -- One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm and Factoids forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. 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. +- One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids and Crossfire forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. 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/assets/sounds/README.md b/apps/mobile/assets/sounds/README.md new file mode 100644 index 000000000..b7b815e82 --- /dev/null +++ b/apps/mobile/assets/sounds/README.md @@ -0,0 +1,7 @@ +# Timer sounds + +`beep.wav` (one minute left) and `double-beep.wav` (time up) are generated by +[scripts/render-timer-sounds.ts](../../scripts/render-timer-sounds.ts), which +copies the website's Web Audio synth in `src/pages/TimerPage.tsx`: an 880 Hz +sine at 22.05 kHz, 16-bit mono. Rerun the script to change them; don't edit +the files by hand. diff --git a/apps/mobile/assets/sounds/beep.wav b/apps/mobile/assets/sounds/beep.wav new file mode 100644 index 000000000..eaa18f98a Binary files /dev/null and b/apps/mobile/assets/sounds/beep.wav differ diff --git a/apps/mobile/assets/sounds/double-beep.wav b/apps/mobile/assets/sounds/double-beep.wav new file mode 100644 index 000000000..a037431ac Binary files /dev/null and b/apps/mobile/assets/sounds/double-beep.wav differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 65d926152..db9201929 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,11 +17,14 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-keep-awake": "~57.0.2", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", diff --git a/apps/mobile/scripts/render-timer-sounds.ts b/apps/mobile/scripts/render-timer-sounds.ts new file mode 100644 index 000000000..691727656 --- /dev/null +++ b/apps/mobile/scripts/render-timer-sounds.ts @@ -0,0 +1,68 @@ +/** + * Writes the Timer's signal sounds to assets/sounds as 16-bit mono WAV files. + * They copy the website's Web Audio synth (playSignal in src/pages/TimerPage.tsx): + * an 880 Hz sine, one beep fading out over 0.5 s, and two beeps for time up + * (0.1 s on, a 50 ms gap, then a fade to 0.6 s). The website plays them at a + * tenth of full scale; a phone speaker needs more, so these peak at half. + * Run from the repository root after changing a sound: + * + * npx tsx apps/mobile/scripts/render-timer-sounds.ts + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const RATE = 22_050; +const FREQUENCY = 880; +const PEAK = 0.5; +// A few milliseconds of ramp at each edge, so the tone starts and stops without a click. +const EDGE = 0.004; +const outDir = join(__dirname, '../assets/sounds'); + +/** Gain at time t for a tone that starts at `from`, holds, then fades exponentially to 0.001 by `to`. */ +function envelope(t: number, from: number, to: number, holdUntil = from): number { + if (t < from || t > to) return 0; + const attack = Math.min(1, (t - from) / EDGE); + const release = Math.min(1, (to - t) / EDGE); + const fade = t <= holdUntil ? 1 : Math.pow(0.001 / PEAK, (t - holdUntil) / (to - holdUntil)); + return attack * release * fade; +} + +function render(seconds: number, gain: (t: number) => number): Buffer { + const samples = Math.round(seconds * RATE); + const data = Buffer.alloc(samples * 2); + for (let i = 0; i < samples; i += 1) { + const t = i / RATE; + const value = Math.sin(2 * Math.PI * FREQUENCY * t) * PEAK * gain(t); + data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 32_767), i * 2); + } + const header = Buffer.alloc(44); + header.write('RIFF', 0); + header.writeUInt32LE(36 + data.length, 4); + header.write('WAVE', 8); + header.write('fmt ', 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(RATE, 24); + header.writeUInt32LE(RATE * 2, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36); + header.writeUInt32LE(data.length, 40); + return Buffer.concat([header, data]); +} + +async function main() { + await mkdir(outDir, { recursive: true }); + await writeFile(join(outDir, 'beep.wav'), render(0.5, (t) => envelope(t, 0, 0.5))); + await writeFile( + join(outDir, 'double-beep.wav'), + render(0.6, (t) => (t < 0.1 ? envelope(t, 0, 0.1, 0.1) : envelope(t, 0.15, 0.6))), + ); + console.log(`Wrote beep.wav and double-beep.wav to ${outDir}`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index cf7b5458f..d4c27ca49 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -40,6 +40,13 @@ * next to the screenshots. Brainstorm and Factoids are shot the same way, * from a fixed brainstorm and fixed factoids, and save nothing. * + * Crossfire screenshots never call a model and never save: the page answers + * each turn, signal and debrief with fixed text in the run's language, and + * answers the crossfire write locally. + * + * The Timer runs on Playwright's clock, so its minutes pass at once, and the + * run checks that it signalled one beep at the last minute and two at time up. + * * Flowchart screenshots read fixed saved maps: the page answers every * flowcharts read with two made-up maps in the website's React Flow shape (or * none for one-off work, which shows the starter map) and answers writes @@ -703,6 +710,150 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language await streaming.close(); } +// A made-up Crossfire exchange and debrief, in English and Hungarian, so the Hungarian shots carry no English model text. +const CROSSFIRE_TEXT = { + en: { + argument: 'Banning private cars from city centres cuts roadside pollution and gives streets back to people who walk, cycle and take the bus.', + questions: ['Who checks that the exemptions for carers actually work?', 'What happens to shops that depend on drivers?'], + answers: [ + 'Councils already issue permits for carers and shift workers, so the checks exist before the ban starts.', + 'Footfall studies show shops gain visitors when streets close to traffic, though a few car-dependent stores may lose trade.', + ], + debrief: { + concession: 'The opponent admitted that some car-dependent stores may lose trade (T4).', + controlled: 'Asking who checks the exemptions forced a concrete answer about permits.', + missed: 'You could have pressed on how many permits a typical city issues.', + better: 'How many permits would a city of a million people need to issue?', + reason: 'A number tests whether the exemptions undercut the health benefit.', + strongest: 'The question about shops drew out a concession you can use in rebuttal.', + }, + }, + hu: { + argument: 'Ha kitiltjuk a magánautókat a belvárosból, csökken az út menti szennyezés, és az utcák visszakerülnek a gyalogosokhoz, a biciklisekhez és a buszokhoz.', + questions: ['Ki ellenőrzi, hogy a gondozóknak adott kivételek valóban működnek?', 'Mi lesz azokkal a boltokkal, amelyek az autósokból élnek?'], + answers: [ + 'Az önkormányzatok már most is adnak engedélyt a gondozóknak és a műszakban dolgozóknak, így az ellenőrzés a tilalom előtt megvan.', + 'A forgalmi felmérések szerint a boltok több látogatót kapnak, ha az utcát lezárják, bár néhány autós vásárlókra épülő üzlet veszíthet.', + ], + debrief: { + concession: 'Az ellenfél elismerte, hogy néhány autós vásárlókra épülő üzlet veszíthet (T4).', + controlled: 'A kivételek ellenőrzéséről szóló kérdés konkrét választ kényszerített ki az engedélyekről.', + missed: 'Rákérdezhettél volna, hány engedélyt ad ki egy átlagos város.', + better: 'Hány engedélyt kellene kiadnia egy egymilliós városnak?', + reason: 'Egy szám megmutatja, hogy a kivételek nem rontják-e le az egészségügyi hasznot.', + strongest: 'A boltokról szóló kérdés olyan engedményt hozott, amelyet a cáfolatban felhasználhatsz.', + }, + }, +} as const; + +/** Fakes Crossfire's AI calls and its save for this page (see the header). Replies follow the request, as the client checks them. */ +async function fakeCrossfire(page: Page, language: keyof typeof CROSSFIRE_TEXT) { + const text = CROSSFIRE_TEXT[language]; + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { + const body = route.request().postDataJSON() as { feature?: string; input?: { operation?: string; expectedReply?: 'question' | 'answer'; transcript?: { id: string }[] } } | null; + if (body?.feature !== 'crossfire') return route.abort(); + const input = body.input ?? {}; + const turns = input.transcript ?? []; + const exchange = Math.floor(turns.length / 2) % text.answers.length; + let output: unknown; + let extra: Record = {}; + if (input.operation === 'next-turn') { + const type = input.expectedReply ?? 'answer'; + output = { type, text: type === 'answer' ? text.answers[exchange] : text.questions[exchange] }; + extra = { signalToken: 'screenshot-signal' }; + } else if (input.operation === 'signal') { + output = { signal: { answerTurnId: `T${turns.length + 1}`, answerQuality: exchange === 0 ? 'direct' : 'partial', answerQualityConfidence: 0.82, concession: exchange === 0 ? 'none' : 'implicit', concessionConfidence: 0.74, followUpOpportunity: 0.81 } }; + } else { + const d = text.debrief; + output = { + concessionsSecured: [{ turnIds: ['T3', 'T4'], claim: d.concession }], + controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: d.controlled }], + weakOrRepetitiveQuestions: [], + evasiveOrIncompleteAnswers: [], + missedFollowUps: [{ turnIds: ['T2'], claim: d.missed }], + betterFollowUpQuestions: [{ turnIds: ['T2'], question: d.better, reason: d.reason }], + strongestExchange: { turnIds: ['T3', 'T4'], claim: d.strongest }, + }; + } + return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output, ...extra } }); + }); + await page.route(`${REST}/crossfires*`, (route) => + route.request().method() === 'POST' ? route.fulfill({ status: 201, json: { id: '00000000-0000-4000-8000-00000000cf01' } }) : route.continue(), + ); +} + +/** Crossfire from the Practice tab: the setup, a typed exchange with its live signals, and the debrief, all from fixed outputs. */ +async function shootCrossfireScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const text = CROSSFIRE_TEXT[language === 'hu' ? 'hu' : 'en']; + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} crossfire`); + await pinSettings(page, language); + await fakeCrossfire(page, language === 'hu' ? 'hu' : 'en'); + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-crossfire').click({ timeout: 30_000 }); + await page.getByTestId('crossfire-setup').waitFor({ timeout: 15_000 }); + // The resolution comes from the Current Debate, so the round attaches to it without the motion question. + await page.waitForFunction(() => (document.querySelector('[data-testid="crossfire-resolution"]') as HTMLTextAreaElement | null)?.value.trim()); + await page.getByTestId('crossfire-argument').fill(text.argument); + await settle(page); + await shoot(page, `${prefix}-crossfire-setup`); + + await page.getByRole('button', { name: /^(Start crossfire|Keresztkérdezés indítása|Iniciar interrogatorio)$/ }).click(); + await page.getByTestId('crossfire-live').waitFor({ timeout: 15_000 }); + for (const [index, question] of text.questions.entries()) { + await page.getByTestId('crossfire-input').fill(question); + // The count is a plural string; without plural rules it shows its key instead. + const count = (await page.getByTestId('crossfire-count').textContent()) ?? ''; + if (!/\d/.test(count) || count.includes('charactersRemaining')) throw new Error(`Crossfire character count read "${count}"`); + await page.getByTestId('crossfire-send').click(); + await page.getByTestId('crossfire-turn').nth(index * 2 + 1).waitFor({ timeout: 15_000 }); + await page.getByTestId('crossfire-signal').nth(index).waitFor({ timeout: 15_000 }); + } + await page.waitForTimeout(400); + await shoot(page, `${prefix}-crossfire-exchange`); + + await page.getByTestId('crossfire-end').click(); + await page.getByTestId('crossfire-debrief').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-crossfire-debrief`); + await page.close(); +} + +/** + * The Timer from the Practice tab: idle, running, and after time up. The page's + * clock is Playwright's, so minutes pass at once; the web preview records the + * signals it would sound (timer-signals.web.ts) and the run checks them. + */ +async function shootTimerScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} timer`); + await pinSettings(page, language); + await page.clock.install(); + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-timer').click({ timeout: 30_000 }); + await page.getByTestId('timer-screen').waitFor({ timeout: 15_000 }); + await settle(page); + await shoot(page, `${prefix}-timer`); + + // The first Standard speech is 4:00. After 1:14 it shows 2:46 and keeps running. + await page.getByTestId('timer-toggle').click(); + await page.clock.fastForward('01:14'); + await page.getByTestId('timer-time').filter({ hasText: '2:46' }).waitFor(); + await shoot(page, `${prefix}-timer-running`); + + // Past the last minute and on to time up: one beep, then two. + await page.clock.fastForward('01:50'); + await page.getByTestId('timer-time').filter({ hasText: '0:56' }).waitFor(); + await page.clock.fastForward('01:00'); + await page.getByTestId('timer-time').filter({ hasText: '0:00' }).waitFor(); + await page.waitForTimeout(100); + const signals = await page.evaluate(() => (window as unknown as { __debatelabTimerSignals?: { signal: string; sound: boolean }[] }).__debatelabTimerSignals ?? []); + const heard = signals.map((item) => item.signal).join(','); + if (heard !== 'beep,double-beep') throw new Error(`Timer signals were ${heard || 'none'}, expected beep,double-beep`); + await shoot(page, `${prefix}-timer-time-up`); + await page.close(); +} + /** Prepare and Generate in one-off mode, reached in the app so the in-memory mode survives. Nothing is sent or saved. */ async function shootOneOffTools(page: Page, prefix: string) { await page.goto(`${ORIGIN}/`); @@ -899,6 +1050,10 @@ async function main() { await shootTool(signedIn, prefix, language, problems, tool); shots += 6; } + await shootTimerScreens(signedIn, prefix, language, problems); + shots += 3; + await shootCrossfireScreens(signedIn, prefix, language, problems); + shots += 3; await shootFlowchart(signedIn, prefix, language, problems); shots += 10; const oneOffPage = await signedIn.newPage(); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 0cd759c39..7d5f944f1 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -10,7 +10,7 @@ import { Screen } from '@/components/screen'; import { Text } from '@/components/text'; import { useTheme } from '@/theme/use-theme'; -/** The Practice tools. Attack and Ask open full screen; the others say they are on their way. */ +/** The Practice tools. Attack, Ask, Crossfire and Timer open full screen; the others say they are on their way. */ export default function PracticeScreen() { const { t } = useTranslation(); const router = useRouter(); @@ -28,7 +28,11 @@ export default function PracticeScreen() { ? router.push('/ask') : tool.key === 'attack' ? router.push({ pathname: '/attack', params: { from: 'practice' } }) - : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} + : tool.key === 'timer' + ? router.push('/timer') + : tool.key === 'crossfire' + ? router.push({ pathname: '/crossfire', params: { from: 'practice' } }) + : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} testID={`practice-tool-${tool.key}`} style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2a322830a..af126945e 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -95,6 +95,9 @@ function Root() { + {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} + + diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx new file mode 100644 index 000000000..ccb38b211 --- /dev/null +++ b/apps/mobile/src/app/crossfire.tsx @@ -0,0 +1,706 @@ +import { createCrossfireSetupState, crossfireReducer, expectedUserTurnKind } from '@debatelab/core/crossfire/reducer'; +import { + CrossfireConfigSchema, + MAX_CROSSFIRE_ARGUMENT_CHARS, + MAX_CROSSFIRE_RESOLUTION_CHARS, + MAX_CROSSFIRE_TURN_CHARS, + type CrossfireState, + type CrossfireTurn, +} from '@debatelab/core/crossfire/schemas'; +import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, AppState, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, TextButton } from '@/components/buttons'; +import { ChoiceCards, CrossfireDebriefDocument, CrossfireTranscript } from '@/components/crossfire-views'; +import { DocumentScreen, DocumentTitle, SectionHeading } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SwitchRow } from '@/components/form-rows'; +import { Icon } from '@/components/icon'; +import { LiveTopBar, RoundIconButton } from '@/components/round-controls'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { TimerRing } from '@/components/timer-ring'; +import { ToolTitle, WorkingIn } from '@/components/tool-form'; +import { abortError, AIError, isAbortError, type ResponseExtras, untilAborted } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { + countActiveSources, + crossfireContext, + crossfireDebrief, + crossfireDraft, + CrossfireResponseError, + crossfireSignal, + crossfireTurn, + recordCrossfire, + withTurnDeadline, +} from '@/lib/crossfire'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; +import { chooseMotionTarget } from '@/lib/motion-gate'; +import { keepSourceAssessments } from '@/lib/results'; +import { useSettings } from '@/lib/settings'; +import { formatClock } from '@/lib/timer'; +import { fontFamily } from '@/theme/fonts'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +type Active = Extract; +type Debriefing = Extract; +type ErrorKind = 'timeout' | 'response' | 'request'; + +const PLAN_MESSAGES = new Set(['mobile.results.errors.trialExpired', 'mobile.results.errors.limitReached', 'mobile.results.errors.modelRestricted']); + +/** The round left behind, as the website's restoreState: a request that was cut off becomes one to retry. */ +function restoredState(): CrossfireState { + const draft = crossfireDraft.get()?.state; + if (draft?.phase === 'active' && draft.request.status === 'pending') { + return { ...draft, request: { status: 'failed', expectedReply: draft.request.expectedReply, errorKind: 'request', message: 'crossfire.interruptedRequest' } }; + } + if (draft?.phase === 'debriefing' && draft.requestStatus === 'pending') { + return { ...draft, requestStatus: 'failed', errorKind: 'request', errorMessage: 'crossfire.interruptedDebrief' }; + } + if (draft?.phase === 'active' || draft?.phase === 'debriefing') return draft; + // A form keeps its texts and goes back to the website's defaults for the rest. + return createCrossfireSetupState(draft?.phase === 'setup' + ? { resolution: draft.config.resolution, argument: draft.config.argument, seedQuestions: draft.config.seedQuestions } + : undefined); +} + +/** Errors are stored as translation keys, so a restored round still reads in the current language. */ +function classify(cause: unknown, debrief: boolean): { kind: ErrorKind; message: string } { + if (cause instanceof CrossfireResponseError) return { kind: 'response', message: debrief ? 'crossfire.invalidDebriefResponseDesc' : 'crossfire.invalidResponseDesc' }; + if (cause instanceof AIError) return { kind: cause.kind === 'timeout' ? 'timeout' : 'request', message: `mobile.results.errors.${cause.kind}` }; + console.warn('Crossfire request failed:', cause instanceof Error ? cause.message : cause); + return { kind: 'request', message: 'mobile.results.errors.failed' }; +} + +function nextTurn(sequence: number, speaker: 'user' | 'ai', kind: 'question' | 'answer', text: string): CrossfireTurn { + return { id: `T${sequence}`, sequence, speaker, kind, text: text.trim(), createdAt: Date.now() }; +} + +/** + * Crossfire in its typed mode, as the website's CrossfirePage: set up a + * resolution and a case, question the AI or be questioned for a few timed + * minutes, then read the debrief. The live round is full screen; the round + * stays for the session if the student leaves it. Voice rounds need the + * development build (speech recognition and the opponent's voice). + */ +export default function CrossfireScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [state, dispatch] = useReducer(crossfireReducer, undefined, restoredState); + // The debate chosen when the round started; null for one-off rounds. + const debateTarget = useRef(crossfireDraft.get()?.debateId ?? null); + const [draftTurn, setDraftTurn] = useState(''); + const [now, setNow] = useState(() => Date.now()); + const [formError, setFormError] = useState(''); + const [notice, setNotice] = useState(''); + const [activeSources, setActiveSources] = useState(null); + const requestRef = useRef(null); + // The finished round still to be recorded in its debate. + const pendingRecord = useRef<{ sessionId: string; forUser: string; debateId: string } | null>(null); + const signalRequests = useRef(new Set()); + const live = useRef(true); + const owner = useRef(userId); + const scroll = useRef(null); + + useEffect(() => { + owner.current = userId; + }, [userId]); + + const abortSignals = useCallback(() => { + signalRequests.current.forEach((controller) => controller.abort()); + signalRequests.current.clear(); + }, []); + + // Leaving cancels what is on its way; the round itself stays for the session. + useEffect(() => { + live.current = true; + return () => { + live.current = false; + requestRef.current?.abort(); + abortSignals(); + }; + }, [abortSignals]); + + // The round is kept as it changes, as the website saves its draft; a finished one is not. + useEffect(() => { + crossfireDraft.set(state.phase === 'complete' ? null : { state, debateId: debateTarget.current }); + }, [state]); + + // The resolution follows the Current Debate unless the student typed their own. + const resolution = state.phase === 'setup' ? state.config.resolution : null; + useEffect(() => { + if (resolution === null || oneOff || !current) return; + if (shouldPrefillMotion(resolution, current.motion, debates.map((debate) => debate.motion))) { + dispatch({ type: 'config-patched', patch: { resolution: current.motion } }); + } + }, [current, debates, oneOff, resolution]); + + // The grounding switch says when the Current Debate's Library has no active sources. + const sourceDebate = oneOff ? null : current?.id ?? null; + useFocusEffect( + useCallback(() => { + if (!userId || !sourceDebate) { + setActiveSources(0); + return; + } + let active = true; + countActiveSources(userId, sourceDebate) + .then((count) => { + if (active) setActiveSources(count); + }) + .catch((cause: unknown) => console.warn('Could not count active sources:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [userId, sourceDebate]), + ); + + // The round's clock reads its end timestamp; the tick only redraws it, and coming back from the background redraws at once. + useEffect(() => { + if (state.phase !== 'active') return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 500); + const subscription = AppState.addEventListener('change', (next) => { + if (next === 'active') setNow(Date.now()); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [state.phase]); + + /** What the website does with every whole answer: save the source rankings, and say when the input was shortened. */ + const afterResponse = useCallback((forUser: string, debateId: string | null, extras: ResponseExtras) => { + if (extras.truncated) setNotice(`${t('aiClient.inputShortened')}. ${t('aiClient.inputShortenedDesc')}`); + void keepSourceAssessments(forUser, debateId, extras.assessments).then((saved) => { + if (!saved && live.current) setNotice(`${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`); + }); + }, [t]); + + // Read inside the request's deadline and abort, so a stalled read neither outlasts 75 s nor survives leaving. + const requestContext = useCallback(async (forUser: string, debateId: string | null, signal: AbortSignal) => { + const [config, context] = await untilAborted( + Promise.all([ + // The website sends Crossfire with the Ask tool's model settings. + fetchFeatureConfig(forUser, 'ask'), + debateId ? buildDebateContext(forUser, debateId, 'crossfire', 'both') : Promise.resolve(undefined), + ]), + signal, + ); + return { config, context }; + }, []); + + const runNextTurn = useCallback(async (snapshot: Active, expectedReply: 'question' | 'answer', forUser: string) => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + const debateId = debateTarget.current; + try { + const { config, reply, ...extras } = await withTurnDeadline(controller.signal, async (signal) => { + const { config, context } = await requestContext(forUser, debateId, signal); + if (signal.aborted) throw abortError(); + const turn = await crossfireTurn( + { operation: 'next-turn', config: snapshot.config, transcript: snapshot.turns, expectedReply }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal }, + ); + return { config, ...turn }; + }); + if (controller.signal.aborted) return; + dispatch({ type: 'ai-turn-received', turn: nextTurn(snapshot.turns.length + 1, 'ai', reply.type, reply.text) }); + afterResponse(forUser, debateId, extras); + if (reply.signalToken) { + const signalController = new AbortController(); + signalRequests.current.add(signalController); + crossfireSignal( + { config: snapshot.config, transcript: snapshot.turns, reply: { type: reply.type, text: reply.text }, signalToken: reply.signalToken }, + { config, language: outputLanguage, signal: signalController.signal }, + ) + .then((signal) => { + if (signal && !signalController.signal.aborted) dispatch({ type: 'signal-received', signal }); + }) + .catch((): void => {}) + .finally(() => signalRequests.current.delete(signalController)); + } + } catch (cause) { + if (controller.signal.aborted || isAbortError(cause)) return; + if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); + const { kind, message } = classify(cause, false); + dispatch({ type: 'request-failed', expectedReply, errorKind: kind, message }); + } + }, [afterResponse, outputLanguage, refresh, requestContext]); + + const runDebrief = useCallback(async (snapshot: Debriefing, forUser: string) => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + const debateId = debateTarget.current; + try { + const { debrief, ...extras } = await withTurnDeadline(controller.signal, async (signal) => { + const { config, context } = await requestContext(forUser, debateId, signal); + if (signal.aborted) throw abortError(); + return crossfireDebrief( + { operation: 'debrief', config: snapshot.config, transcript: snapshot.turns }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal }, + ); + }); + if (controller.signal.aborted) return; + // The effect below saves the finished round from the state it reaches, with any signal that came in meanwhile. + if (debateId) pendingRecord.current = { sessionId: snapshot.sessionId, forUser, debateId }; + dispatch({ type: 'debrief-received', debrief }); + afterResponse(forUser, debateId, extras); + } catch (cause) { + if (controller.signal.aborted || isAbortError(cause)) return; + if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); + const { kind, message } = classify(cause, true); + dispatch({ type: 'debrief-failed', errorKind: kind, message }); + } + }, [afterResponse, outputLanguage, refresh, requestContext]); + + // A round that finishes here is recorded in its debate once, from its latest state, as CrossfirePage does; one-off rounds are not saved. + useEffect(() => { + const pending = pendingRecord.current; + if (!pending || state.phase !== 'complete' || state.sessionId !== pending.sessionId) return; + pendingRecord.current = null; + recordCrossfire(pending.forUser, pending.debateId, state).catch((cause: unknown) => { + console.warn('Could not save the crossfire to the debate:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === pending.forUser) setNotice(t('debate.recordFailed')); + }); + }, [state, t]); + + const endSession = useCallback((reason: 'manual' | 'timer') => { + if (state.phase !== 'active' || !userId) return; + requestRef.current?.abort(); + if (state.turns.length === 0) { + dispatch({ type: 'session-reset', prefill: { resolution: state.config.resolution, argument: state.config.argument, seedQuestions: state.config.seedQuestions } }); + setFormError(t('crossfire.debriefEmptyDesc')); + return; + } + const action = { type: 'session-ended' as const, now: Date.now(), reason }; + const next = crossfireReducer(state, action); + dispatch(action); + if (next.phase === 'debriefing') void runDebrief(next, userId); + }, [runDebrief, state, t, userId]); + + useEffect(() => { + if (state.phase === 'active' && now >= state.endsAt) endSession('timer'); + }, [endSession, now, state]); + + const beginSession = (debateId: string | null, forUser: string) => { + if (state.phase !== 'setup') return; + debateTarget.current = debateId; + setNotice(''); + const action = { type: 'session-started' as const, sessionId: `crossfire-${Date.now()}`, now: Date.now() }; + const next = crossfireReducer(state, action); + dispatch(action); + if (next.phase === 'active' && next.request.status === 'pending') void runNextTurn(next, next.request.expectedReply, forUser); + }; + + const startSession = async () => { + if (state.phase !== 'setup' || !userId) return; + const parsed = CrossfireConfigSchema.safeParse(state.config); + if (!parsed.success) { + setFormError(t('crossfire.validationError')); + return; + } + setFormError(''); + const forUser = userId; + const motion = parsed.data.resolution; + // Where the round belongs is settled before it starts, as the website's motion gate does. + const decision = decideMotionTarget(motion, { debates, current, oneOff }); + if (decision.kind !== 'ask') { + beginSession(decision.kind === 'attach' ? decision.debateId : null, forUser); + return; + } + const choice = await chooseMotionTarget(decision.matches, { + title: current ? t('debate.mismatchTitle') : t('debate.offerTitle'), + message: `“${motion}”\n\n${current ? t('debate.mismatchDesc', { motion: current.motion }) : t('debate.offerDesc')}`, + switchLabel: t('debate.switchToExisting'), + createLabel: current ? t('debate.createFromMotion') : t('debate.startDebate'), + onceLabel: current ? t('debate.useOnce') : t('debate.offerNotNow'), + cancel: t('common.cancel'), + }); + if (!choice || !live.current) return; + let debateId: string | null = null; + try { + if (choice.kind === 'switch') { + await switchTo(choice.debateId); + debateId = choice.debateId; + } else if (choice.kind === 'create') { + debateId = (await create({ motion, settings: {} })).id; + } + } catch (cause) { + console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); + if (live.current) setFormError(t('debate.saveFailed')); + return; + } + if (live.current) beginSession(debateId, forUser); + }; + + const submitTurn = () => { + if (state.phase !== 'active' || !userId) return; + const kind = expectedUserTurnKind(state); + const text = draftTurn.trim(); + if (!kind || !text || text.length > MAX_CROSSFIRE_TURN_CHARS) return; + const expectedReply = kind === 'question' ? 'answer' : 'question'; + const action = { type: 'user-turn-submitted' as const, turn: nextTurn(state.turns.length + 1, 'user', kind, text), expectedReply } as const; + const next = crossfireReducer(state, action); + if (next === state) return; + dispatch(action); + setDraftTurn(''); + if (next.phase === 'active') void runNextTurn(next, expectedReply, userId); + }; + + const retryTurn = () => { + if (state.phase !== 'active' || state.request.status !== 'failed' || !userId) return; + const expectedReply = state.request.expectedReply; + const next = crossfireReducer(state, { type: 'request-retried' }); + dispatch({ type: 'request-retried' }); + if (next.phase === 'active') void runNextTurn(next, expectedReply, userId); + }; + + const retryDebrief = () => { + if (state.phase !== 'debriefing' || state.requestStatus !== 'failed' || !userId) return; + const next = crossfireReducer(state, { type: 'debrief-retried' }); + dispatch({ type: 'debrief-retried' }); + if (next.phase === 'debriefing') void runDebrief(next, userId); + }; + + const restart = () => { + requestRef.current?.abort(); + abortSignals(); + debateTarget.current = null; + dispatch({ type: 'session-reset' }); + setDraftTurn(''); + setFormError(''); + setNotice(''); + }; + + if (state.phase === 'setup') { + const config = state.config; + return ( + } + actions={ void startSession()} />}> + + + dispatch({ type: 'config-patched', patch: { resolution: value } })} + placeholder={t('crossfire.resolutionPlaceholder')} + multiline + style={styles.resolution} + testID="crossfire-resolution" + /> + dispatch({ type: 'config-patched', patch: { argument: value } })} + placeholder={t('crossfire.argumentPlaceholder')} + multiline + style={styles.argument} + testID="crossfire-argument" + /> + dispatch({ type: 'config-patched', patch: { mode } })} + testIDPrefix="crossfire-mode" + options={[ + { value: 'question-ai', label: t('crossfire.modeQuestionAi'), description: t('crossfire.modeQuestionAiDesc') }, + { value: 'ai-questions-user', label: t('crossfire.modeAiQuestionsUser'), description: t('crossfire.modeAiQuestionsUserDesc') }, + ]} + /> + dispatch({ type: 'config-patched', patch: { style } })} + testIDPrefix="crossfire-style" + options={[ + { value: 'cooperative', label: t('crossfire.styleCooperative'), description: t('crossfire.styleCooperativeDesc') }, + { value: 'evasive', label: t('crossfire.styleEvasive'), description: t('crossfire.styleEvasiveDesc') }, + { value: 'aggressive', label: t('crossfire.styleAggressive'), description: t('crossfire.styleAggressiveDesc') }, + ]} + /> + + + {t('crossfire.durationLabel')} + + + value={String(config.durationSeconds) as '180' | '300' | '480'} + onChange={(value) => dispatch({ type: 'config-patched', patch: { durationSeconds: Number(value) as 180 | 300 | 480 } })} + options={[ + { value: '180', label: t('crossfire.duration3m') }, + { value: '300', label: t('crossfire.duration5m') }, + { value: '480', label: t('crossfire.duration8m') }, + ]} + /> + + {config.seedQuestions.length > 0 ? ( + + {config.seedQuestions.map((question, index) => ( + + {question} + + ))} + + ) : null} + + dispatch({ type: 'config-patched', patch: { useSourceLibrary } })} + /> + {/* Voice rounds need speech recognition and the opponent's voice, which come with the development build. */} + {}} + disabled + /> + + {formError ? ( + + {formError} + + ) : null} + + ); + } + + if (state.phase === 'complete') { + return ( + } + notice={notice} + actions={}> + + + {t('crossfire.debriefSubtitle')} + + + + + + ); + } + + // The live round and its debrief wait: full screen, following the system theme like mock 05. + const inputKind = expectedUserTurnKind(state); + const total = state.config.durationSeconds; + const remaining = state.phase === 'active' ? Math.max(0, Math.ceil((state.endsAt - now) / 1000)) : 0; + const modeLabel = state.config.mode === 'question-ai' ? t('crossfire.modeQuestionAi') : t('crossfire.modeAiQuestionsUser'); + const styleKey = `crossfire.style${state.config.style[0].toUpperCase()}${state.config.style.slice(1)}`; + const canSend = Boolean(draftTurn.trim()) && inputKind !== null; + + return ( + + {/* The setup's document header is replaced by the live bar. */} + + router.back()} testID="crossfire-close" />} + title={t('crossfire.title')} + subtitle={`${modeLabel} · ${t(styleKey)}`} + /> + + + 0 ? remaining / total : 0} warning={state.phase === 'active' && remaining < 30}> + + {formatClock(remaining)} + + + + + + {t('crossfire.timerLabel')} + + + {state.config.resolution} + + + {state.phase === 'active' ? ( + endSession('manual')} + testID="crossfire-end" + style={({ pressed }) => [styles.end, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + + {t('crossfire.endSession')} + + + ) : null} + + + scroll.current?.scrollToEnd({ animated: true })}> + + + {notice ? ( + + {notice} + + ) : null} + + {state.phase === 'active' && state.request.status === 'pending' ? ( + + + + {t('crossfire.thinking')} + + + ) : null} + + {state.phase === 'active' && state.request.status === 'failed' ? ( + + + {state.request.errorKind === 'timeout' ? t('crossfire.timeout') : state.request.errorKind === 'response' ? t('crossfire.invalidResponse') : t('crossfire.error')} + + + {state.request.errorKind === 'timeout' ? t('crossfire.timeoutDesc') : t(state.request.message)} + + + + {PLAN_MESSAGES.has(state.request.message) ? router.navigate('/you')} /> : null} + + + ) : null} + + {state.phase === 'debriefing' ? ( + + {state.requestStatus === 'pending' ? ( + <> + + + {t('crossfire.debriefLoading')} + + + {t('crossfire.debriefSubtitle')} + + + ) : ( + <> + + + {state.errorKind === 'timeout' ? t('crossfire.debriefTimeout') : t('crossfire.debriefError')} + + + {t(state.errorMessage ?? 'crossfire.errorDesc')} + + + + )} + + ) : null} + + + {inputKind ? ( + + + + + {inputKind === 'question' ? t('crossfire.questionLabel') : t('crossfire.answerLabel')} + + {/* The website's count; plural forms need the Intl.PluralRules polyfill on Hermes. */} + + {t('crossfire.charactersRemaining', { count: MAX_CROSSFIRE_TURN_CHARS - draftTurn.length })} + + + + + + + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + flex: { flex: 1 }, + resolution: { minHeight: 76, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + argument: { minHeight: 150, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + duration: { gap: 7 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + seed: { fontSize: 15.5, lineHeight: 22, paddingHorizontal: 15, paddingVertical: 10 }, + formError: { fontSize: 15, lineHeight: 21, marginLeft: 4 }, + subtitle: { fontSize: 15.5, lineHeight: 22, marginTop: -6 }, + clockRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 18, paddingTop: 12, paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, width: '100%', maxWidth: 720, alignSelf: 'center' }, + clockText: { fontSize: 14.5, fontVariant: ['tabular-nums'] }, + clockInfo: { flex: 1, minWidth: 0, gap: 1 }, + clockLabel: { fontSize: 13 }, + resolutionLine: { fontSize: 15, lineHeight: 20 }, + // Long labels ("Munkamenet befejezése") wrap instead of squeezing the resolution. + end: { borderWidth: 1, borderRadius: 19, paddingHorizontal: 14, paddingVertical: 6, minHeight: 38, justifyContent: 'center', maxWidth: '38%' }, + endLabel: { fontSize: 14.5, textAlign: 'center' }, + pressed: { opacity: 0.7 }, + liveContent: { paddingHorizontal: 18, paddingTop: 16, paddingBottom: 24, gap: 14, width: '100%', maxWidth: 720, alignSelf: 'center' }, + notice: { fontSize: 14, lineHeight: 19 }, + thinking: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + thinkingText: { fontSize: 15 }, + error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + errorTitle: { fontSize: 16, textAlign: 'center' }, + errorBody: { fontSize: 15, lineHeight: 21 }, + errorActions: { flexDirection: 'row', gap: 20 }, + debriefWait: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 30, paddingHorizontal: 20, alignItems: 'center', gap: 10 }, + waitBody: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, + composer: { borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10 }, + composerColumn: { width: '100%', maxWidth: 720, alignSelf: 'center', paddingHorizontal: 16, gap: 6 }, + composerLabelRow: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginHorizontal: 6 }, + composerLabel: { fontSize: 13.5 }, + composerCount: { fontSize: 12.5 }, + inputRow: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 24, paddingLeft: 16, paddingRight: 6, paddingVertical: 6, minHeight: 48 }, + input: { flex: 1, fontSize: 17, fontFamily: fontFamily.regular, paddingTop: 7, paddingBottom: 7, maxHeight: 140 }, + send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + sendDisabled: { opacity: 0.45 }, +}); diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx new file mode 100644 index 000000000..99e990b27 --- /dev/null +++ b/apps/mobile/src/app/timer.tsx @@ -0,0 +1,229 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppState, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ActionMenu } from '@/components/action-menu'; +import { Chip } from '@/components/chip'; +import { Icon } from '@/components/icon'; +import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; +import { Text } from '@/components/text'; +import { TimerRing } from '@/components/timer-ring'; +import { + formatClock, + idleClock, + pauseClock, + remainingSeconds, + signalFor, + startClock, + TIMER_FORMATS, + type TimerClock, +} from '@/lib/timer'; +import { useKeepAwakeWhile, useTimerSignals } from '@/lib/timer-signals'; +import { useTheme } from '@/theme/use-theme'; + +const TICK_MS = 250; + +/** + * The debate timer, as the website's TimerPage: pick a format and a speech, + * then run its clock with a beep at one minute left and two at time up. Full + * screen with no tab bar, like the live Sparring round in mock 05. + */ +export default function TimerScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); + const { colors } = useTheme(); + const [formatId, setFormatId] = useState(TIMER_FORMATS[0].id); + const [speechIndex, setSpeechIndex] = useState(0); + const format = TIMER_FORMATS.find((item) => item.id === formatId) ?? TIMER_FORMATS[0]; + const speech = format.speeches[speechIndex] ?? format.speeches[0]; + const [clock, setClock] = useState(() => idleClock(speech.minutes)); + const [now, setNow] = useState(() => Date.now()); + const [sound, setSound] = useState(true); + const [formatMenu, setFormatMenu] = useState(false); + const signal = useTimerSignals(); + + const remaining = remainingSeconds(clock, now); + const running = clock.startedAt !== null && remaining > 0; + const finished = remaining === 0; + const total = speech.minutes * 60; + useKeepAwakeWhile(running); + + // The clock reads the time from its start timestamp; the tick only redraws it. + useEffect(() => { + if (clock.startedAt === null) return; + const timer = setInterval(() => setNow(Date.now()), TICK_MS); + // Coming back from the background redraws at once, with any signal that came due. + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') setNow(Date.now()); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [clock.startedAt]); + + // Signals fire as the time left crosses a threshold, also when a pause lands on it, and the clock stops at zero. + const lastRemaining = useRef(remaining); + useEffect(() => { + const previous = lastRemaining.current; + lastRemaining.current = remaining; + const due = signalFor(previous, remaining, clock); + if (due) signal(due, sound); + if (remaining === 0 && clock.startedAt !== null) setClock((current) => pauseClock(current, Date.now())); + }, [clock, remaining, signal, sound]); + + const selectSpeech = (index: number, formatSpeeches = format.speeches) => { + const next = formatSpeeches[index]; + if (!next) return; + setSpeechIndex(index); + setClock(idleClock(next.minutes)); + setNow(Date.now()); + }; + + const toggle = () => { + const at = Date.now(); + setNow(at); + if (finished) { + // At time up the main button starts the next speech, or sets the last one up again. + const next = format.speeches[speechIndex + 1]; + if (next) { + setSpeechIndex(speechIndex + 1); + setClock(startClock(idleClock(next.minutes), at)); + } else { + setClock(idleClock(speech.minutes)); + } + return; + } + // TODO(live-activity): start a Live Activity here, from the same timestamps, once the dev build lands. + setClock((current) => (current.startedAt === null ? startClock(current, at) : pauseClock(current, at))); + }; + + const reset = () => { + setClock(idleClock(speech.minutes)); + setNow(Date.now()); + }; + + const ringSize = Math.round(Math.min(width - 72, height * 0.36, 320)); + const warning = remaining < 30 && remaining > 0; + const status = finished ? t('timer.timeUp') : running ? t('timer.speaking') : t('timer.paused'); + const hasNext = speechIndex < format.speeches.length - 1; + const mainLabel = finished && !hasNext ? t('mobile.timer.reset') : running ? t('timer.pause') : t('timer.start'); + const mainIcon = finished && !hasNext ? 'reset' : running ? 'pause' : 'play'; + + return ( + + router.back()} testID="timer-close" />} + right={ + setSound((value) => !value)} + testID="timer-sound" + /> + } + title={t(speech.labelKey)} + subtitle={t('mobile.timer.speechOf', { n: speechIndex + 1, of: format.speeches.length })} + /> + + setFormatMenu(true)} + testID="timer-format" + style={({ pressed }) => [styles.format, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + + {t(format.nameKey)} + + + + + + 0 ? remaining / total : 0} warning={warning || finished}> + + {status} + + + {formatClock(remaining)} + + + {t('mobile.timer.ofTotal', { total: formatClock(total) })} + + + + + + {format.speeches.map((item, index) => ( + selectSpeech(index)} + testID={`timer-speech-${item.id}`} + /> + ))} + + + + + + + {t('timer.signals')} + + + + {`${t('timer.oneMinLeft')}: ${t('timer.oneBeep')}`} + + + {`${t('timer.timeUpSignal')}: ${t('timer.twoBeeps')}`} + + + + + + + + selectSpeech(speechIndex + 1)} disabled={!hasNext} testID="timer-next" /> + + + ({ key: item.id, label: t(item.nameKey), icon: item.id === formatId ? 'check' : undefined }))} + cancelLabel={t('common.cancel')} + onClose={() => setFormatMenu(false)} + onSelect={(key) => { + const next = TIMER_FORMATS.find((item) => item.id === key); + if (!next) return; + setFormatId(next.id); + selectSpeech(0, next.speeches); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { alignItems: 'center', gap: 22, paddingHorizontal: 20, paddingTop: 14 }, + format: { flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, minHeight: 38 }, + formatLabel: { fontSize: 15 }, + pressed: { opacity: 0.7 }, + status: { fontSize: 14, lineHeight: 19 }, + time: { letterSpacing: -2, fontVariant: ['tabular-nums'] }, + speechRow: { flexGrow: 0, alignSelf: 'stretch', marginHorizontal: -20 }, + // Centred when they fit, as on iPad; scrolling from the start when they don't. + speeches: { gap: 8, paddingHorizontal: 20, flexGrow: 1, justifyContent: 'center' }, + signals: { alignSelf: 'center', maxWidth: 520, width: '100%', borderWidth: 1, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, gap: 3 }, + signalTitle: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2 }, + signalText: { fontSize: 14, lineHeight: 19 }, + controls: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-evenly', paddingTop: 12 }, +}); diff --git a/apps/mobile/src/components/crossfire-views.tsx b/apps/mobile/src/components/crossfire-views.tsx new file mode 100644 index 000000000..1cb314d03 --- /dev/null +++ b/apps/mobile/src/components/crossfire-views.tsx @@ -0,0 +1,240 @@ +import type { CrossfireDebrief, CrossfireTurn } from '@debatelab/core/crossfire/schemas'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Callout, DocSection, Paragraph, Tag } from './document'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** One option card of a radio group, as the website's mode and style cards: a label, a line under it and a check. */ +export function ChoiceCards({ + label, + options, + value, + onChange, + testIDPrefix, +}: { + label: string; + options: { value: T; label: string; description: string }[]; + value: T; + onChange: (value: T) => void; + testIDPrefix: string; +}) { + const { colors } = useTheme(); + return ( + + + {label} + + + {options.map((option, index) => { + const selected = option.value === value; + return ( + onChange(option.value)} + testID={`${testIDPrefix}-${option.value}`} + style={({ pressed }) => [styles.card, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + {option.label} + + + {option.description} + + + {selected ? : } + + ); + })} + + + ); +} + +/** The labels of a turn's live coaching signal, as the website's badges. */ +function SignalTags({ turn }: { turn: CrossfireTurn }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const signal = turn.kind === 'answer' ? turn.signal : undefined; + if (!signal) return null; + return ( + + + + + + + + {signal.followUpOpportunity > 0.7 ? ( + + + + ) : null} + + ); +} + +/** The exchange so far: the student's turns on the right, the opponent's on the left, each with its id. */ +export function CrossfireTranscript({ turns }: { turns: CrossfireTurn[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (turns.length === 0) { + return ( + + + {t('crossfire.emptyDesc')} + + + ); + } + return ( + + {turns.map((turn) => { + const mine = turn.speaker === 'user'; + return ( + + + + {turn.id} + + + {turn.kind === 'question' ? t('crossfire.question') : t('crossfire.answer')} + + + + {turn.text} + + + + ); + })} + + ); +} + +/** The transcript turns a debrief point rests on. */ +function TurnRefs({ turnIds }: { turnIds: string[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + {turnIds.map((id, index) => ( + + + {id} + + + ))} + + ); +} + +function Findings({ title, items }: { title: string; items: { turnIds: string[]; claim: string }[] }) { + const { t } = useTranslation(); + return ( + + {items.length === 0 ? ( + + {t('crossfire.noGroundedFinding')} + + ) : ( + items.map((item, index) => ( + + + + + )) + )} + + ); +} + +/** The debrief as a document (mock 03's style), in the website's order. */ +export function CrossfireDebriefDocument({ debrief }: { debrief: CrossfireDebrief }) { + const { t } = useTranslation(); + return ( + + + + + + + + {debrief.betterFollowUpQuestions.length === 0 ? ( + + {t('crossfire.noGroundedFinding')} + + ) : ( + debrief.betterFollowUpQuestions.map((item, index) => ( + + + {item.question} + + + + + )) + )} + + + + {debrief.strongestExchange ? ( + + + + + ) : ( + + {t('crossfire.noGroundedFinding')} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + group: { gap: 7 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + cards: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + card: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 15, paddingVertical: 11, minHeight: 56 }, + cardText: { flex: 1, gap: 2 }, + cardLabel: { fontSize: 16.5 }, + cardDescription: { fontSize: 14, lineHeight: 19 }, + checkSpace: { width: 20 }, + pressed: { opacity: 0.7 }, + tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 4 }, + empty: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card, padding: 22 }, + emptyText: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, + turns: { gap: 10 }, + turn: { borderWidth: 1, borderRadius: 18, paddingHorizontal: 14, paddingVertical: 11, gap: 5, maxWidth: '92%' }, + mine: { alignSelf: 'flex-end', borderBottomRightRadius: 6 }, + theirs: { alignSelf: 'flex-start', borderBottomLeftRadius: 6 }, + turnHead: { flexDirection: 'row', justifyContent: 'space-between', gap: 16 }, + turnId: { fontSize: 13 }, + turnKind: { fontSize: 13 }, + turnText: { fontSize: 16, lineHeight: 23 }, + refs: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + ref: { borderWidth: 1, borderRadius: 7, paddingHorizontal: 7, paddingVertical: 2 }, + refText: { fontSize: 12.5, lineHeight: 17 }, + none: { fontSize: 15, lineHeight: 21 }, + finding: { gap: 8 }, + question: { fontSize: 15.5, lineHeight: 22 }, + debrief: { gap: 4 }, + strongest: { marginTop: 12 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 802e979aa..c74077206 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -41,6 +41,15 @@ export const iconPaths = { more: '', download: '', copy: '', + play: '', + pause: '', + skip: '', + skipBack: '', + reset: '', + volume: '', + volumeOff: '', + send: '', + mic: '', alert: '', } as const; diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index 95349fa22..bc26f7788 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,6 +1,6 @@ import type { IconName } from './icon-paths'; -/** The Practice tools, in the website's order. Attack and Ask are in the app; the rest follow. */ +/** The Practice tools, in the website's order. Attack, Ask, Crossfire and Timer are in the app; the rest follow. */ export const PRACTICE_TOOLS = [ { key: 'attack', icon: 'shield' }, { key: 'ask', icon: 'chat' }, diff --git a/apps/mobile/src/components/round-controls.tsx b/apps/mobile/src/components/round-controls.tsx new file mode 100644 index 000000000..93128bac2 --- /dev/null +++ b/apps/mobile/src/components/round-controls.tsx @@ -0,0 +1,117 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import { Text } from './text'; + +import { mainActionGradient } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The round button in a live screen's top bar, as mock 05's close and flow buttons. */ +export function RoundIconButton({ icon, label, onPress, testID }: { icon: IconName; label: string; onPress: () => void; testID?: string }) { + const { colors, scheme } = useTheme(); + return ( + [styles.round, { backgroundColor: scheme === 'dark' ? 'rgba(255,255,255,0.1)' : colors.segmentTrack }, pressed && styles.pressed]}> + + + ); +} + +/** A live screen's top bar: a button either side of a centred title and line under it. */ +export function LiveTopBar({ left, right, title, subtitle }: { left: ReactNode; right?: ReactNode; title: string; subtitle?: string }) { + const insets = useSafeAreaInsets(); + return ( + + {left} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {right} + + ); +} + +/** A small round control with its label under it, beside the main one. */ +export function SideControl({ icon, label, onPress, disabled, testID }: { icon: IconName; label: string; onPress: () => void; disabled?: boolean; testID?: string }) { + const { colors, scheme } = useTheme(); + return ( + [styles.control, (pressed || disabled) && styles.pressed]}> + + + + + {label} + + + ); +} + +/** The one main action of a live screen, in the gradient, with its label under it. */ +export function MainRoundControl({ icon, label, onPress, testID }: { icon: IconName; label: string; onPress: () => void; testID?: string }) { + return ( + [styles.control, pressed && styles.pressed]}> + + + {/* The gradient carries the rounding, so the circle's shadow is not clipped. */} + + {/* Above the gradient: the web preview paints positioned views over static ones. */} + + + + + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + bar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, gap: 8 }, + side: { width: 44, alignItems: 'flex-start' }, + sideRight: { alignItems: 'flex-end' }, + titles: { flex: 1, alignItems: 'center', gap: 1 }, + title: { fontSize: 17 }, + subtitle: { fontSize: 14 }, + round: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.6 }, + control: { alignItems: 'center', gap: 6, minWidth: 88 }, + controlLabel: { fontSize: 13.5 }, + sideCircle: { width: 56, height: 56, borderRadius: 28, alignItems: 'center', justifyContent: 'center' }, + sideBorder: { borderWidth: 1 }, + mainHalo: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(56,189,248,0.14)' }, + mainGradient: { borderRadius: 42 }, + mainIcon: { position: 'relative' }, + mainCircle: { + width: 84, + height: 84, + borderRadius: 42, + alignItems: 'center', + justifyContent: 'center', + boxShadow: '0 10px 30px -6px rgba(20,184,166,0.6)', + }, +}); diff --git a/apps/mobile/src/components/timer-ring.tsx b/apps/mobile/src/components/timer-ring.tsx new file mode 100644 index 000000000..7819c470d --- /dev/null +++ b/apps/mobile/src/components/timer-ring.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg'; + +import { useTheme } from '@/theme/use-theme'; + +const STROKE = 9; + +/** + * The live round's ring from mock 05: a faint track and a gradient arc for the + * time left, drawn from the top. `warning` turns the arc red for the last seconds. + */ +export function TimerRing({ size, fraction, warning, children }: { size: number; fraction: number; warning?: boolean; children: ReactNode }) { + const { colors, scheme } = useTheme(); + const radius = (size - STROKE) / 2; + const circumference = 2 * Math.PI * radius; + const left = Math.min(1, Math.max(0, fraction)); + const [from, to] = scheme === 'dark' ? ['#38bdf8', '#2dd4bf'] : ['#0284c7', '#14b8a6']; + return ( + + + + + + + + + + {left > 0 ? ( + + ) : null} + + {children} + + ); +} + +const styles = StyleSheet.create({ + rotated: { transform: [{ rotate: '-90deg' }] }, + center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index f6af55746..ee9674304 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -43,6 +43,20 @@ export function abortError(): Error { return error; } +/** Settles with the read, or rejects as soon as the signal aborts. The read itself cannot be cancelled, only no longer waited for. */ +export function untilAborted(read: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + // Nobody waits for the read any more, so its own failure must not go unhandled. + read.catch(() => {}); + return Promise.reject(abortError()); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + read.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + export function utf8Length(value: string): number { let bytes = 0; for (const char of value) { @@ -80,19 +94,25 @@ export async function payloadOf(response: { json: () => Promise }): Pro export type FunctionResponse = Awaited>; -/** One POST with the website's headers, a 200 s timeout and the caller's abort. */ -export async function postFunction(path: string, body: Record, signal: AbortSignal | undefined, run: (response: FunctionResponse) => Promise): Promise { +/** One POST with the website's headers, its timeout (200 s unless the feature sets its own) and the caller's abort. */ +export async function postFunction( + path: string, + body: Record, + signal: AbortSignal | undefined, + run: (response: FunctionResponse) => Promise, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { // The deadline and the caller's abort start before the session read, so - // leaving the screen while the token refreshes never sends the request. + // leaving the screen while the token refreshes never sends the request, and + // a refresh that stalls ends at the deadline instead of when it resolves. const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), timeoutMs); const abortForCaller = () => controller.abort(); signal?.addEventListener('abort', abortForCaller); if (signal?.aborted) controller.abort(); try { if (controller.signal.aborted) throw abortError(); - const token = await accessToken(); - if (controller.signal.aborted) throw abortError(); + const token = await untilAborted(accessToken(), controller.signal); const json = JSON.stringify(body); if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AIError('tooLarge', 'Request is too large'); const response = await fetch(`${supabaseProject.url}/functions/v1/${path}`, { diff --git a/apps/mobile/src/lib/crossfire.test.ts b/apps/mobile/src/lib/crossfire.test.ts new file mode 100644 index 000000000..3e7aa4b0f --- /dev/null +++ b/apps/mobile/src/lib/crossfire.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.hoisted(() => vi.fn()); + +// The session read never settles, as when a token refresh stalls. +const getSession = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); +vi.mock('./supabase', () => ({ supabase: { auth: { getSession } } })); +vi.mock('expo/fetch', () => ({ fetch: fetchMock })); + +import { AIError, postFunction, untilAborted } from './ai-request'; +import { withTurnDeadline } from './crossfire'; + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('withTurnDeadline', () => { + it('times out a turn whose settings read stalls, 75 s after the turn started', async () => { + vi.useFakeTimers(); + const stalled = new Promise(() => {}); + const turn = withTurnDeadline(new AbortController().signal, (signal) => untilAborted(stalled, signal)); + const outcome = turn.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(74_999); + expect(await Promise.race([outcome, Promise.resolve('pending')])).toBe('pending'); + await vi.advanceTimersByTimeAsync(1); + const error = await outcome; + expect(error).toBeInstanceOf(AIError); + expect((error as AIError).kind).toBe('timeout'); + }); + + it('cancels a stalled read as soon as the student leaves', async () => { + const leaving = new AbortController(); + const turn = withTurnDeadline(leaving.signal, (signal) => untilAborted(new Promise(() => {}), signal)); + leaving.abort(); + await expect(turn).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); + +describe('postFunction', () => { + it('ends at its deadline when the session read stalls, without sending the request', async () => { + vi.useFakeTimers(); + const request = postFunction('ai', {}, undefined, async () => 'answer', 75_000).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(75_000); + const error = await request; + expect(error).toBeInstanceOf(AIError); + expect((error as AIError).kind).toBe('timeout'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never reads the session for a request cancelled before it starts', async () => { + const leaving = new AbortController(); + leaving.abort(); + await expect(postFunction('ai', {}, leaving.signal, async () => 'answer')).rejects.toMatchObject({ name: 'AbortError' }); + expect(getSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts new file mode 100644 index 000000000..496fa5403 --- /dev/null +++ b/apps/mobile/src/lib/crossfire.ts @@ -0,0 +1,204 @@ +import { + collectDebriefTurnIds, + CrossfireDebriefSchema, + CrossfireReplySchema, + CrossfireTurnSignalSchema, + type CrossfireAIInput, + type CrossfireConfig, + type CrossfireDebrief, + type CrossfireReply, + type CrossfireState, + type CrossfireTurn, + type CrossfireTurnSignal, +} from '@debatelab/core/crossfire/schemas'; +import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; + +import { abortError, AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; +import type { FeatureConfig } from './feature-config'; +import { sessionSlot } from './session-memory'; +import { supabase } from './supabase'; + +/** + * Typed Crossfire, sent and saved as the website does it: crossfire and + * crossfireSignal in src/lib/ai/client.ts for the requests, and + * CrossfirePage's recordResult call for the saved row. Voice rounds need the + * development build; the requests here never ask for a spoken reply. + */ + +/** The website's Crossfire deadlines: 75 s for a turn or the debrief, 5 s for a coaching signal. */ +const TURN_TIMEOUT_MS = 75 * 1000; +const SIGNAL_TIMEOUT_MS = 5 * 1000; + +/** The model answered, but not in the shape the round needs. Shown as an unreadable response. */ +export class CrossfireResponseError extends Error { + constructor(message: string) { + super(message); + this.name = 'CrossfireResponseError'; + } +} + +interface RequestOptions { + config: FeatureConfig; + language: string; + /** Absent for one-off work. */ + debateContext?: DebateContextRequest; + signal?: AbortSignal; +} + +// Turn signals and measured delivery facts are client annotations. The Edge +// Function accepts only the turn fields it signs and prompts with. +function serverTranscript(turns: CrossfireTurn[]) { + return turns.map(({ signal: _signal, delivery: _delivery, ...turn }) => turn); +} + +function requestBody(input: Record & { transcript: CrossfireTurn[] }, options: RequestOptions) { + return { + feature: 'crossfire', + input: { ...input, transcript: serverTranscript(input.transcript), language: options.language }, + provider: PROVIDER, + model: options.config.model, + temperature: options.config.temperature, + reasoningEffort: options.config.reasoningEffort, + // Servers from before Current Debate context read only this field. + sourceIds: options.debateContext?.sourceIds ?? [], + ...(options.debateContext ? { debateContext: options.debateContext } : {}), + }; +} + +/** The Library sources a round may use: none unless the student turned grounding on. */ +export function crossfireContext(context: DebateContextRequest | undefined, config: CrossfireConfig): DebateContextRequest | undefined { + return context && !config.useSourceLibrary ? { ...context, sourceIds: [] } : context; +} + +/** + * A turn or debrief within the website's 75 s, counted from before the model + * settings and Library context are read, so a stalled read times out like the + * request. The caller's signal (leaving the screen) cancels it at any await. + */ +export async function withTurnDeadline(signal: AbortSignal, work: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, TURN_TIMEOUT_MS); + const abortForCaller = () => controller.abort(); + signal.addEventListener('abort', abortForCaller); + if (signal.aborted) controller.abort(); + try { + if (controller.signal.aborted) throw abortError(); + return await work(controller.signal); + } catch (error) { + if (signal.aborted) throw abortError(); + if (timedOut) throw new AIError('timeout', 'The request took too long'); + throw error; + } finally { + clearTimeout(timeout); + signal.removeEventListener('abort', abortForCaller); + } +} + +export type CrossfireNextTurn = CrossfireReply & { signalToken?: string }; + +/** The opponent's next question or answer, checked as the website checks it. */ +export function crossfireTurn( + input: Extract, + options: RequestOptions, +): Promise<{ reply: CrossfireNextTurn } & ResponseExtras> { + return postFunction('ai', requestBody(input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const reply = CrossfireReplySchema.safeParse(output); + if (!reply.success || reply.data.type !== input.expectedReply) { + throw new CrossfireResponseError('AI response did not match the expected Crossfire turn.'); + } + const token: unknown = data.signalToken; + if (token !== undefined && (typeof token !== 'string' || !token.trim() || token.length > 128)) { + throw new CrossfireResponseError('Crossfire signal token was invalid.'); + } + return { reply: typeof token === 'string' ? { ...reply.data, signalToken: token } : reply.data, ...responseExtras(data) }; + }, TURN_TIMEOUT_MS); +} + +/** The debrief at the end of a round. Every turn it cites must be in the transcript. */ +export function crossfireDebrief( + input: Extract, + options: RequestOptions, +): Promise<{ debrief: CrossfireDebrief } & ResponseExtras> { + return postFunction('ai', requestBody(input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const debrief = CrossfireDebriefSchema.safeParse(output); + if (!debrief.success) throw new CrossfireResponseError('AI response did not match the expected Crossfire debrief.'); + const known = new Set(input.transcript.map((turn) => turn.id)); + if (collectDebriefTurnIds(debrief.data).some((id) => !known.has(id))) { + throw new CrossfireResponseError('AI debrief referenced transcript turns that do not exist.'); + } + return { debrief: debrief.data, ...responseExtras(data) }; + }, TURN_TIMEOUT_MS); +} + +/** + * The live coaching signal for an answer, asked for after a turn as the + * website does: no debate material, a 5 s budget, and nothing at all when + * the server has none to give. + */ +export function crossfireSignal( + input: { config: CrossfireConfig; transcript: CrossfireTurn[]; reply: CrossfireReply; signalToken: string }, + options: Omit, +): Promise { + return postFunction('ai', requestBody({ operation: 'signal', ...input }, options), options.signal, async (response) => { + const { output } = await functionOutput(response); + if (!output || typeof output !== 'object' || Array.isArray(output) || !('signal' in output)) return undefined; + const parsed = CrossfireTurnSignalSchema.safeParse((output as { signal: unknown }).signal); + if (!parsed.success) throw new CrossfireResponseError('Crossfire signal did not match the expected schema.'); + const turns = [...input.transcript, { id: `T${input.transcript.length + 1}`, kind: input.reply.type }]; + const target = turns.find((turn) => turn.id === parsed.data.answerTurnId); + if (!target || target.kind !== 'answer') throw new CrossfireResponseError('Crossfire signal referenced an unknown or non-answer turn.'); + return parsed.data; + }, SIGNAL_TIMEOUT_MS); +} + +/** + * Saves a finished round into its debate, as CrossfirePage's recordResult + * call: the resolution as title, the config as input, and the transcript, + * debrief and why it ended as output. Rounds have no side. Returns the row's id. + */ +export async function recordCrossfire( + userId: string, + debateId: string, + round: Extract, +): Promise { + const { data, error } = await supabase + .from('crossfires') + .insert({ + user_id: userId, + debate_id: debateId, + side: 'neutral', + title: round.config.resolution.slice(0, 300) || 'Untitled', + input: round.config, + output: { turns: round.turns, debrief: round.debrief, endedReason: round.endedReason }, + }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the crossfire'); + return (data as { id: string }).id; +} + +/** How many of the debate's Library sources are active, for the grounding switch's hint. */ +export async function countActiveSources(userId: string, debateId: string): Promise { + const { count, error } = await supabase + .from('sources') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('debate_id', debateId) + .eq('is_active', true); + if (error) throw new Error(error.message); + return count ?? 0; +} + +/** + * The unfinished round, kept for the session as the website keeps its draft + * in the browser: leaving the screen and coming back continues it. It + * belongs to the signed-in account and is forgotten when that changes. The + * debate is the one chosen when the round started; null for one-off rounds. + */ +export const crossfireDraft = sessionSlot<{ state: CrossfireState; debateId: string | null } | null>(() => null); diff --git a/apps/mobile/src/lib/timer-signals.ts b/apps/mobile/src/lib/timer-signals.ts new file mode 100644 index 000000000..85e0b215b --- /dev/null +++ b/apps/mobile/src/lib/timer-signals.ts @@ -0,0 +1,50 @@ +import { setAudioModeAsync, useAudioPlayer } from 'expo-audio'; +import * as Haptics from 'expo-haptics'; +import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; +import { useCallback, useEffect } from 'react'; + +import type { TimerSignal } from './timer'; + +const KEEP_AWAKE_TAG = 'debate-timer'; + +/** + * The Timer's signals: the website's beeps from bundled sounds + * (assets/sounds, see its README) and a haptic tap with each. Muting the + * sound keeps the tap, so a muted timer still says when time is up. + */ +export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void { + const beep = useAudioPlayer(require('@/assets/sounds/beep.wav')); + const doubleBeep = useAudioPlayer(require('@/assets/sounds/double-beep.wav')); + + useEffect(() => { + // A timer is heard in a round even with the ringer off. + setAudioModeAsync({ playsInSilentMode: true, interruptionMode: 'duckOthers' }).catch((cause: unknown) => + console.warn('Could not set the audio mode:', cause instanceof Error ? cause.message : cause)); + }, []); + + return useCallback((signal: TimerSignal, sound: boolean) => { + const haptic = signal === 'beep' + ? Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) + : Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + haptic.catch(() => {}); + if (!sound) return; + const player = signal === 'beep' ? beep : doubleBeep; + try { + player.seekTo(0).catch(() => {}); + player.play(); + } catch (cause) { + console.warn('Could not play the timer signal:', cause instanceof Error ? cause.message : cause); + } + }, [beep, doubleBeep]); +} + +/** Keeps the screen on while a timer runs, and lets it sleep again when it stops or the screen closes. */ +export function useKeepAwakeWhile(active: boolean) { + useEffect(() => { + if (!active) return; + activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch(() => {}); + return () => { + deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {}); + }; + }, [active]); +} diff --git a/apps/mobile/src/lib/timer-signals.web.ts b/apps/mobile/src/lib/timer-signals.web.ts new file mode 100644 index 000000000..ce2777ac6 --- /dev/null +++ b/apps/mobile/src/lib/timer-signals.web.ts @@ -0,0 +1,16 @@ +import type { TimerSignal } from './timer'; + +/** + * The web preview has no haptics or wake lock, and a headless browser cannot + * play the beeps. It records each signal on `window` instead, so the + * screenshot script can check that the right one fired. The phone app uses + * timer-signals.ts. + */ +export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void { + return (signal, sound) => { + const target = window as unknown as { __debatelabTimerSignals?: { signal: TimerSignal; sound: boolean }[] }; + (target.__debatelabTimerSignals ??= []).push({ signal, sound }); + }; +} + +export function useKeepAwakeWhile(_active: boolean) {} diff --git a/apps/mobile/src/lib/timer.test.ts b/apps/mobile/src/lib/timer.test.ts new file mode 100644 index 000000000..15afc441c --- /dev/null +++ b/apps/mobile/src/lib/timer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { idleClock, pauseClock, remainingSeconds, signalBetween, signalFor, startClock, type TimerClock, type TimerSignal } from './timer'; + +const T0 = 1_000_000; + +describe('timer clock', () => { + it('shows the right time after a gap with no ticks, as after the app was in the background', () => { + const clock = startClock(idleClock(4), T0); + // No tick ran for 90 s; the time comes from the start timestamp alone. + expect(remainingSeconds(clock, T0 + 90_000)).toBe(150); + }); + + it('keeps the time that ran before a pause and stops at zero', () => { + let clock = startClock(idleClock(1), T0); + clock = pauseClock(clock, T0 + 20_500); + // Paused time does not count. + expect(remainingSeconds(clock, T0 + 600_000)).toBe(40); + clock = startClock(clock, T0 + 600_000); + expect(remainingSeconds(clock, T0 + 610_000)).toBe(30); + expect(remainingSeconds(clock, T0 + 900_000)).toBe(0); + // A finished clock does not start again. + expect(startClock(pauseClock(clock, T0 + 900_000), T0 + 900_000).startedAt).toBeNull(); + }); +}); + +describe('timer signals', () => { + it('beeps once on reaching the last minute and twice at time up', () => { + expect(signalBetween(61, 60)).toBe('beep'); + expect(signalBetween(60, 59)).toBeNull(); + expect(signalBetween(1, 0)).toBe('double-beep'); + expect(signalBetween(0, 0)).toBeNull(); + }); + + it('sounds only time up when a background gap crossed both thresholds', () => { + expect(signalBetween(150, 0)).toBe('double-beep'); + expect(signalBetween(150, 30)).toBe('beep'); + }); +}); + +describe('timer signals across a pause', () => { + /** Reads the clock as the screen does on each redraw, collecting the signals it sounds. */ + function reader(clock: TimerClock, now: number) { + let last = remainingSeconds(clock, now); + const heard: TimerSignal[] = []; + return { + heard, + read(next: TimerClock, at: number) { + const current = remainingSeconds(next, at); + const due = signalFor(last, current, next); + last = current; + if (due) heard.push(due); + }, + }; + } + + it('beeps once when a pause lands as 1:01 turns to 1:00, and not again on resume', () => { + let clock = startClock(idleClock(2), T0); + const screen = reader(clock, T0); + screen.read(clock, T0 + 59_200); // shows 1:01 + clock = pauseClock(clock, T0 + 60_100); // real time left is now 59.9 s, shown as 1:00 + screen.read(clock, T0 + 60_100); + clock = startClock(clock, T0 + 90_000); + screen.read(clock, T0 + 91_000); // 0:59 + expect(screen.heard).toEqual(['beep']); + }); + + it('sounds nothing when a reset or another speech moves the time left', () => { + let clock = startClock(idleClock(2), T0); + const screen = reader(clock, T0); + screen.read(clock, T0 + 30_000); // 1:30 + // A fresh one-minute clock drops the reading from 1:30 to 1:00 without the speech running. + clock = idleClock(1); + screen.read(clock, T0 + 30_000); + clock = idleClock(4); + screen.read(clock, T0 + 30_000); + expect(screen.heard).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/timer.ts b/apps/mobile/src/lib/timer.ts new file mode 100644 index 000000000..6850b5177 --- /dev/null +++ b/apps/mobile/src/lib/timer.ts @@ -0,0 +1,130 @@ +/** + * The debate timer, as the website's TimerPage (src/pages/TimerPage.tsx): the + * same formats, speeches and signals. The clock keeps timestamps, never counts + * ticks, so it shows the right time after the app has been in the background. + */ + +export interface TimerSpeech { + id: string; + labelKey: string; + minutes: number; +} + +export interface TimerFormat { + id: string; + nameKey: string; + speeches: TimerSpeech[]; +} + +export const TIMER_FORMATS: TimerFormat[] = [ + { + id: 'standard', + nameKey: 'timer.formatStandard', + speeches: [ + { id: 'intro', labelKey: 'timer.speeches.standard.intro', minutes: 4 }, + { id: 'r1', labelKey: 'timer.speeches.standard.r1', minutes: 6 }, + { id: 'r2', labelKey: 'timer.speeches.standard.r2', minutes: 6 }, + { id: 'conc', labelKey: 'timer.speeches.standard.conc', minutes: 3 }, + ], + }, + { + id: 'ld', + nameKey: 'timer.formatLD', + speeches: [ + { id: 'ac', labelKey: 'timer.speeches.ld.ac', minutes: 6 }, + { id: 'nc', labelKey: 'timer.speeches.ld.nc', minutes: 7 }, + { id: 'ar1', labelKey: 'timer.speeches.ld.ar1', minutes: 4 }, + { id: 'nr', labelKey: 'timer.speeches.ld.nr', minutes: 6 }, + { id: 'ar2', labelKey: 'timer.speeches.ld.ar2', minutes: 3 }, + ], + }, + { + id: 'pf', + nameKey: 'timer.formatPF', + speeches: [ + { id: 'team1_c', labelKey: 'timer.speeches.pf.team1c', minutes: 4 }, + { id: 'team2_c', labelKey: 'timer.speeches.pf.team2c', minutes: 4 }, + { id: 'team1_r', labelKey: 'timer.speeches.pf.team1r', minutes: 4 }, + { id: 'team2_r', labelKey: 'timer.speeches.pf.team2r', minutes: 4 }, + { id: 'team1_s', labelKey: 'timer.speeches.pf.team1s', minutes: 3 }, + { id: 'team2_s', labelKey: 'timer.speeches.pf.team2s', minutes: 3 }, + { id: 'team1_ff', labelKey: 'timer.speeches.pf.team1ff', minutes: 2 }, + { id: 'team2_ff', labelKey: 'timer.speeches.pf.team2ff', minutes: 2 }, + ], + }, + { + id: 'policy', + nameKey: 'timer.formatPolicy', + speeches: [ + { id: '1ac', labelKey: 'timer.speeches.policy.1ac', minutes: 8 }, + { id: '1nc', labelKey: 'timer.speeches.policy.1nc', minutes: 8 }, + { id: '2ac', labelKey: 'timer.speeches.policy.2ac', minutes: 8 }, + { id: '2nc', labelKey: 'timer.speeches.policy.2nc', minutes: 8 }, + { id: '1nr', labelKey: 'timer.speeches.policy.1nr', minutes: 5 }, + { id: '1ar', labelKey: 'timer.speeches.policy.1ar', minutes: 5 }, + { id: '2nr', labelKey: 'timer.speeches.policy.2nr', minutes: 5 }, + { id: '2ar', labelKey: 'timer.speeches.policy.2ar', minutes: 5 }, + ], + }, +]; + +/** One speech's clock: its length, when it last started running, and the time it ran before that. */ +export interface TimerClock { + durationMs: number; + /** When the clock last started; null while it is stopped. */ + startedAt: number | null; + /** Time that ran before the last start, across pauses. */ + elapsedBeforeMs: number; +} + +export function idleClock(minutes: number): TimerClock { + return { durationMs: minutes * 60_000, startedAt: null, elapsedBeforeMs: 0 }; +} + +export function elapsedMs(clock: TimerClock, now: number): number { + const running = clock.startedAt === null ? 0 : Math.max(0, now - clock.startedAt); + return Math.min(clock.durationMs, clock.elapsedBeforeMs + running); +} + +/** Whole seconds left, rounded up as the website shows them: 0:01 until the last moment. */ +export function remainingSeconds(clock: TimerClock, now: number): number { + return Math.max(0, Math.ceil((clock.durationMs - elapsedMs(clock, now)) / 1000)); +} + +export function startClock(clock: TimerClock, now: number): TimerClock { + if (clock.startedAt !== null || elapsedMs(clock, now) >= clock.durationMs) return clock; + return { ...clock, startedAt: now }; +} + +export function pauseClock(clock: TimerClock, now: number): TimerClock { + if (clock.startedAt === null) return clock; + return { ...clock, startedAt: null, elapsedBeforeMs: elapsedMs(clock, now) }; +} + +export type TimerSignal = 'beep' | 'double-beep'; + +/** + * The signal due when the time left moves from `previous` to `current` + * seconds: one beep on reaching the last minute, two beeps at time up. After + * a gap in the background that crossed both, only time up sounds. + */ +export function signalBetween(previous: number, current: number): TimerSignal | null { + if (previous > 0 && current === 0) return 'double-beep'; + if (previous > 60 && current <= 60 && current > 0) return 'beep'; + return null; +} + +/** + * The signal due between two readings of the time left, as the screen redraws. + * Time left only falls while the clock runs, so every fall is checked, also one + * that ends in a pause: pausing as 1:01 turns to 1:00 still beeps. A fresh + * clock (a reset or another speech) moves the reading up and sounds nothing. + */ +export function signalFor(previous: number, current: number, clock: TimerClock): TimerSignal | null { + if (clock.startedAt === null && clock.elapsedBeforeMs === 0) return null; + return signalBetween(previous, current); +} + +export function formatClock(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} diff --git a/eslint.config.js b/eslint.config.js index e8ffe872c..4d719f838 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -80,7 +80,7 @@ export default tseslint.config( ...reactHooks.configs.recommended.rules, "@typescript-eslint/no-unused-vars": "off", // Metro bundles images through require(). - "@typescript-eslint/no-require-imports": ["error", { allow: ["\\.png$"] }], + "@typescript-eslint/no-require-imports": ["error", { allow: ["\\.png$", "\\.wav$"] }], "no-restricted-globals": [ "error", ...["window", "document", "localStorage", "sessionStorage", "location"].map((name) => ({ diff --git a/package-lock.json b/package-lock.json index 51deaa67b..b7a6f6c45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,11 +126,14 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-keep-awake": "~57.0.2", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", @@ -554,6 +557,18 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-audio": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.5.tgz", + "integrity": "sha512-39fX5JC5zDvELB4/eDJPj2XAYRN0W9+K2DfDWm5N3aC5WZ7HRrhupWtT6hbH09NlfYpsRSqYOwEZnocFM42ynQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-clipboard": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", @@ -622,6 +637,15 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-haptics": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "integrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-keep-awake": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", diff --git a/src/locales/en.json b/src/locales/en.json index 3793495fb..b995e085e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2530,6 +2530,15 @@ "attack": { "caseRequired": "Paste or write the case you want attacked." }, + "timer": { + "speechOf": "Speech {{n}} of {{of}}", + "ofTotal": "of {{total}}", + "next": "Next speech", + "reset": "Reset" + }, + "crossfire": { + "voiceLater": "Coming with the app build." + }, "brainstorm": { "strengths": { "strong": "Strong", diff --git a/src/locales/es.json b/src/locales/es.json index 953fae7e5..99eb6035b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2530,6 +2530,15 @@ "attack": { "caseRequired": "Pega o escribe el caso que quieres atacar." }, + "timer": { + "speechOf": "Discurso {{n}} de {{of}}", + "ofTotal": "de {{total}}", + "next": "Siguiente discurso", + "reset": "Reiniciar" + }, + "crossfire": { + "voiceLater": "Llegará con la versión de la app." + }, "brainstorm": { "strengths": { "strong": "Fuerte", diff --git a/src/locales/hu.json b/src/locales/hu.json index 74d36fc21..aa83b303b 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2530,6 +2530,15 @@ "attack": { "caseRequired": "Illeszd be vagy írd le a megtámadandó esetet." }, + "timer": { + "speechOf": "{{n}}. beszéd / {{of}}", + "ofTotal": "összesen {{total}}", + "next": "Következő beszéd", + "reset": "Visszaállítás" + }, + "crossfire": { + "voiceLater": "Az alkalmazás következő verziójával érkezik." + }, "brainstorm": { "strengths": { "strong": "Erős",