Skip to content

Commit c524ed2

Browse files
committed
Merge remote-tracking branch 'origin/feat/native-ios' into native-ios/08a-entitlements
2 parents 0878606 + eb5edd7 commit c524ed2

24 files changed

Lines changed: 1981 additions & 258 deletions

File tree

‎apps/mobile/AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
1717

1818
## Layout
1919

20-
- `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.
20+
- `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/`, `factoids/` and `sparring/` (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`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab.
2121
- `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview.
2222
- `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.
2323
- `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.
@@ -26,8 +26,9 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
2626
- 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.
2727
- 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)`).
2828
- 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.
29+
- Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled.
2930
- 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`).
30-
- 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.
31+
- 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, Crossfire and Sparring 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.
3132
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
3233
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.
3334

‎apps/mobile/scripts/screenshots.ts‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@
4444
* each turn, signal and debrief with fixed text in the run's language, and
4545
* answers the crossfire write locally.
4646
*
47+
* Sparring screenshots never call a model and never save: the page answers
48+
* each opponent turn and the judge with fixed text in the run's language
49+
* (holding them open for the "opponent" and "judging" shots), and answers
50+
* the attach_to_debate call locally. The run checks the turn-start taps. The
51+
* flow button's Flowchart reads the fixed maps described below.
52+
*
4753
* The Timer runs on Playwright's clock, so its minutes pass at once, and the
4854
* run checks that it signalled one beep at the last minute and two at time up.
4955
*
@@ -819,6 +825,147 @@ async function shootCrossfireScreens(context: BrowserContext, prefix: string, la
819825
await page.close();
820826
}
821827

828+
// A made-up Lincoln-Douglas round as Con, in English and Hungarian, and a scorecard whose totals match its categories, as the client checks.
829+
const SPARRING_TEXT = {
830+
en: {
831+
opponent: [
832+
'Private cars fill the centre with fumes that children breathe on their way to school. Closing the centre to them is the fastest way to clean that air, and every city that tried it kept the ban.',
833+
'My opponent says shops will suffer, but footfall rises when streets are calm. People who walk stop more often and stay longer than people looking for a parking space.',
834+
'The night-shift worker is covered: permits for carers and early shifts already exist. What remains is a choice between cleaner air for everyone and convenience for a few.',
835+
],
836+
speeches: [
837+
'A ban punishes the people with the fewest choices. The nurse who drives in at five a.m. has no bus at that hour, and permits only help those who know how to apply.',
838+
'Cleaner air matters, but low-emission zones get most of the benefit without shutting anyone out. Charge the most polluting cars and leave the centre open.',
839+
],
840+
verdict: "Your second speech answered the night-shift worker case directly. Evidence was thin: name the transit data, don't just say it exists.",
841+
feedback: ['The burden on shift workers was clear and well framed.', 'You asserted that low-emission zones work without a source.', 'Delivery read clearly, with short and confident sentences.', 'You chose the right clash and stayed on it.'],
842+
overall: 'A disciplined round. Lead with your strongest example and back each claim with one named source.',
843+
moments: ['Turning the permit system into an access problem.'],
844+
next: ['Bring one figure for how many workers start before the buses run.'],
845+
},
846+
hu: {
847+
opponent: [
848+
'A magánautók kipufogógázzal töltik meg a belvárost, amelyet a gyerekek iskolába menet belélegeznek. A belváros lezárása a leggyorsabb módja a levegő megtisztításának, és minden város megtartotta a tilalmat, amely kipróbálta.',
849+
'Az ellenfelem szerint a boltok veszítenek, de a forgalom nő, ha az utcák csendesek. A gyalogosok gyakrabban megállnak és tovább maradnak, mint akik parkolóhelyet keresnek.',
850+
'Az éjszakás dolgozókra is gondoltunk: a gondozóknak és a korai műszakosoknak már ma is jár engedély. A választás a tisztább levegő mindenkinek és néhányak kényelme között van.',
851+
],
852+
speeches: [
853+
'A tilalom azokat bünteti, akiknek a legkevesebb választásuk van. A hajnali ötkor munkába autózó ápolónak abban az órában nincs busza, az engedély pedig csak annak segít, aki tudja, hogyan kérje.',
854+
'A tiszta levegő fontos, de az alacsony kibocsátású övezetek kizárás nélkül hozzák az előnyök nagy részét. Fizessenek a legjobban szennyező autók, a belváros maradjon nyitva.',
855+
],
856+
verdict: 'A második beszéded közvetlenül válaszolt az éjszakás dolgozó esetére. A bizonyítékok gyengék voltak: nevezd meg a közlekedési adatokat, ne csak állítsd, hogy léteznek.',
857+
feedback: ['A műszakban dolgozók terhét világosan és jól keretezted.', 'Forrás nélkül állítottad, hogy az alacsony kibocsátású övezetek működnek.', 'Az előadás világos volt, rövid és határozott mondatokkal.', 'A megfelelő ütközési pontot választottad, és kitartottál mellette.'],
858+
overall: 'Fegyelmezett forduló. Kezdd a legerősebb példáddal, és minden állítást támassz alá egy megnevezett forrással.',
859+
moments: ['Az engedélyrendszert hozzáférési problémává fordítottad.'],
860+
next: ['Hozz egy számot arról, hány dolgozó kezd a buszok indulása előtt.'],
861+
},
862+
} as const;
863+
864+
const SPARRING_CATEGORIES = [['Logic', 21, 18], ['Evidence', 15, 19], ['Delivery', 22, 17], ['Strategy', 20, 17]] as const;
865+
866+
/** Fakes Sparring's AI calls and its debate filing for this page (see the header). `hold` keeps the next answer of that kind open until it is released. */
867+
async function fakeSparring(page: Page, language: keyof typeof SPARRING_TEXT) {
868+
const text = SPARRING_TEXT[language];
869+
const held: Record<'respond' | 'score', (() => void) | null> = { respond: null, score: null };
870+
const holding = { respond: false, score: false };
871+
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => {
872+
const body = route.request().postDataJSON() as { feature?: string; input?: { action?: 'respond' | 'score'; speeches?: { speaker: string }[] } } | null;
873+
if (body?.feature !== 'sparring') return route.abort();
874+
const action = body.input?.action ?? 'respond';
875+
if (holding[action]) await new Promise<void>((resolve) => { held[action] = resolve; });
876+
let output: unknown;
877+
if (action === 'respond') {
878+
const index = (body.input?.speeches ?? []).filter((speech) => speech.speaker === 'ai').length;
879+
output = { action, speech: { content: text.opponent[index % text.opponent.length], strategy: 'Press the access argument.', targetedPoints: [], integrityToken: 'screenshot-integrity-token-0000000000000000' } };
880+
} else {
881+
const categories = SPARRING_CATEGORIES.map(([name, userScore, aiScore], index) => ({ name, userScore, aiScore, feedback: text.feedback[index] }));
882+
output = {
883+
action,
884+
scorecard: {
885+
userScore: categories.reduce((sum, item) => sum + item.userScore, 0),
886+
aiScore: categories.reduce((sum, item) => sum + item.aiScore, 0),
887+
categories,
888+
overallFeedback: text.overall,
889+
keyMoments: [...text.moments],
890+
improvementAreas: [...text.next],
891+
winner: 'user',
892+
verdict: text.verdict,
893+
},
894+
};
895+
}
896+
return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output } });
897+
});
898+
await page.route(`${REST}/rpc/attach_to_debate`, (route) => route.fulfill({ status: 204, body: '' }));
899+
return {
900+
hold: (action: 'respond' | 'score') => { holding[action] = true; },
901+
release: (action: 'respond' | 'score') => {
902+
holding[action] = false;
903+
held[action]?.();
904+
held[action] = null;
905+
},
906+
};
907+
}
908+
909+
/** Sparring from the Practice tab: the setup, the opponent's turn and yours, judging, and the scorecard, all from fixed outputs. */
910+
async function shootSparringScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) {
911+
const text = SPARRING_TEXT[language === 'hu' ? 'hu' : 'en'];
912+
const page = await context.newPage();
913+
watchErrors(page, problems, `${prefix} sparring`);
914+
await pinSettings(page, language);
915+
const fake = await fakeSparring(page, language === 'hu' ? 'hu' : 'en');
916+
await fakeFlowcharts(page);
917+
await page.goto(`${ORIGIN}/practice`);
918+
await page.getByTestId('practice-tool-sparring').click({ timeout: 30_000 });
919+
await page.getByTestId('sparring-setup').waitFor({ timeout: 15_000 });
920+
// The motion comes from the Current Debate, so the round attaches to it without the motion question.
921+
await page.waitForFunction(() => (document.querySelector('[data-testid="sparring-motion"]') as HTMLTextAreaElement | null)?.value.trim());
922+
await page.getByTestId('sparring-format').click();
923+
await page.getByRole('menuitem', { name: 'Lincoln-Douglas' }).click();
924+
await settle(page);
925+
await shoot(page, `${prefix}-sparring-setup`);
926+
927+
fake.hold('respond');
928+
await page.getByRole('button', { name: /^(Start round|Forduló indítása|Iniciar ronda)$/ }).click();
929+
await page.getByRole('menuitem', { name: /^(Con|Ellene|En contra)$/ }).click();
930+
await page.getByTestId('sparring-thinking').waitFor({ timeout: 15_000 });
931+
await page.waitForTimeout(400);
932+
await shoot(page, `${prefix}-sparring-opponent`);
933+
fake.release('respond');
934+
935+
for (const [index, speech] of text.speeches.entries()) {
936+
await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 });
937+
await page.getByTestId('sparring-input').fill(speech);
938+
if (index === 0) {
939+
await page.getByTestId('sparring-input').blur();
940+
await page.waitForTimeout(400);
941+
await shoot(page, `${prefix}-sparring-your-turn`);
942+
// The student's clock runs from the moment the turn starts.
943+
await page.getByTestId('sparring-time').filter({ hasText: /^6:5\d$/ }).waitFor({ timeout: 5_000 });
944+
// The flow button opens the Flowchart above the round, and the round is still there on the way back.
945+
await page.getByTestId('sparring-flow').click();
946+
await page.getByTestId('flow-card').first().waitFor({ timeout: 30_000 });
947+
await settle(page);
948+
await shoot(page, `${prefix}-sparring-flowchart`);
949+
await page.goBack();
950+
await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 });
951+
if ((await page.getByTestId('sparring-input').inputValue()) !== speech) problems.push(`${prefix} sparring: the speech draft was lost after the Flowchart`);
952+
}
953+
if (index === text.speeches.length - 1) fake.hold('score');
954+
await page.getByTestId('sparring-main').click();
955+
}
956+
await page.getByTestId('sparring-judging').waitFor({ timeout: 15_000 });
957+
await page.waitForTimeout(400);
958+
await shoot(page, `${prefix}-sparring-judging`);
959+
fake.release('score');
960+
961+
await page.getByTestId('sparring-scorecard').waitFor({ timeout: 15_000 });
962+
await page.waitForTimeout(400);
963+
await shoot(page, `${prefix}-sparring-scorecard`);
964+
const taps = await page.evaluate(() => (window as unknown as { __debatelabTurnTaps?: number }).__debatelabTurnTaps ?? 0);
965+
if (taps !== text.speeches.length) problems.push(`${prefix} sparring: expected ${text.speeches.length} turn-start taps, got ${taps}`);
966+
await page.close();
967+
}
968+
822969
/**
823970
* The Timer from the Practice tab: idle, running, and after time up. The page's
824971
* clock is Playwright's, so minutes pass at once; the web preview records the
@@ -1054,6 +1201,8 @@ async function main() {
10541201
shots += 3;
10551202
await shootCrossfireScreens(signedIn, prefix, language, problems);
10561203
shots += 3;
1204+
await shootSparringScreens(signedIn, prefix, language, problems);
1205+
shots += 6;
10571206
await shootFlowchart(signedIn, prefix, language, problems);
10581207
shots += 10;
10591208
const oneOffPage = await signedIn.newPage();

‎apps/mobile/src/app/(tabs)/(debate)/index.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { LinearGradient } from 'expo-linear-gradient';
2-
import { useFocusEffect, useRouter, type Href } from 'expo-router';
2+
import { useFocusEffect, useRouter } from 'expo-router';
33
import { useCallback, useRef, useState } from 'react';
44
import { useTranslation } from 'react-i18next';
55
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
@@ -160,7 +160,6 @@ function DebateDetails({ debateId }: { debateId: string }) {
160160
}, [load]),
161161
);
162162

163-
const open = (href: Href) => router.navigate(href);
164163
const results = counts?.results[view];
165164
const items = recent?.[view] ?? null;
166165

@@ -183,7 +182,7 @@ function DebateDetails({ debateId }: { debateId: string }) {
183182
<Stat value={counts?.rounds} label={t('mobile.debate.statRounds', { count: counts?.rounds ?? 0 })} />
184183
</View>
185184

186-
<SparCard onPress={() => open('/practice')} />
185+
<SparCard onPress={() => router.push({ pathname: '/sparring', params: { from: 'debate' } })} />
187186

188187
<Text accessibilityRole="header" weight="bold" style={styles.section}>
189188
{t('mobile.tabs.prepare')}

0 commit comments

Comments
 (0)