Skip to content

Commit 73ee099

Browse files
authored
Merge pull request #129 from Programcode/native-ios/03d-m1-fixes
Native iOS app: fixes from the first phone test
2 parents fb84790 + 3ac60bb commit 73ee099

23 files changed

Lines changed: 467 additions & 307 deletions

‎apps/mobile/AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
1313
- Google sign-in needs a development build, because Expo Go cannot receive OAuth redirects. Until then, sign-in is email code or demo code.
1414
- Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview.
1515
- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.json`.
16+
- A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty.
1617

1718
## Layout
1819

@@ -22,7 +23,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
2223
- `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.
2324
- 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.
2425
- 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.
25-
- One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored.
26+
- One-off mode is session-only and never written, as on the website. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library, Generate and Attack (through `WorkingIn`). 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.
2627
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
2728
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.
2829

‎apps/mobile/app.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
"userInterfaceStyle": "automatic",
1010
"ios": {
1111
"bundleIdentifier": "com.adam.debatelab",
12-
"supportsTablet": true
12+
"supportsTablet": true,
13+
"infoPlist": {
14+
"CADisableMinimumFrameDurationOnPhone": true
15+
}
1316
},
1417
"android": {
1518
"adaptiveIcon": {

‎apps/mobile/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"dependencies": {
1212
"@debatelab/core": "*",
1313
"@expo-google-fonts/inter": "^0.4.2",
14+
"@formatjs/intl-pluralrules": "^6.3.15",
1415
"@react-native-async-storage/async-storage": "2.2.0",
1516
"@supabase/supabase-js": "^2.49.1",
1617
"aes-js": "^3.1.2",

‎apps/mobile/scripts/screenshots.ts‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
* therefore answer the app's user_settings reads with the run's language and
2222
* the System theme; nothing is written. The Debate tab shows the account's
2323
* real Current Debate, and its empty state comes from answering the
24-
* current_debates read with no row. The Debate tab is shot once per side view.
24+
* current_debates read with no row. The Debate tab is shot once per side view,
25+
* and with one-off mode on, with and without a Current Debate; Prepare and
26+
* Generate are shot in one-off mode too. One-off mode is in memory only; the
27+
* script toggles it and never taps a debate row.
2528
*
2629
* Ask screenshots never call a model and never save a chat: the page answers
2730
* ai-stream from a fake stream inside the page (expo/fetch uses the page's
@@ -149,12 +152,35 @@ async function shootDebateScreens(page: Page, prefix: string) {
149152
await page.waitForTimeout(700);
150153
await shoot(page, `${prefix}-debates-sheet`);
151154

155+
// One-off mode lives in memory only, so toggling it writes nothing. Never tap a debate row
156+
// here: switching saves the pointer and last activity to staging.
157+
await turnOnOneOff(page);
158+
await shoot(page, `${prefix}-debates-oneoff`);
159+
await closeSwitcher(page);
160+
await page.getByTestId('one-off-notice').waitFor();
161+
await shoot(page, `${prefix}-debate-oneoff`);
162+
await page.getByTestId('one-off-notice').getByRole('button').click();
163+
await page.getByTestId('one-off-notice').waitFor({ state: 'detached' });
164+
165+
await page.getByTestId('current-debate-pill').click();
166+
await page.getByTestId('debates-sheet').waitFor();
152167
await page.getByRole('button', { name: /^(New debate|Új vita|Nuevo debate)$/ }).click();
153168
await page.getByTestId('new-debate-sheet').waitFor();
154169
await page.waitForTimeout(700);
155170
await shoot(page, `${prefix}-new-debate`);
156171
}
157172

173+
async function turnOnOneOff(page: Page) {
174+
const toggle = page.getByTestId('debates-sheet').getByRole('switch');
175+
await toggle.click();
176+
if (!(await toggle.isChecked())) throw new Error('The one-off switch did not turn on');
177+
}
178+
179+
async function closeSwitcher(page: Page) {
180+
await page.getByTestId('debates-sheet').getByRole('button', { name: /^(Done|Kész|Listo)$/ }).click();
181+
await page.getByTestId('debates-sheet').waitFor({ state: 'detached' });
182+
}
183+
158184
// A made-up answer in the shape of mock 07, streamed a word at a time.
159185
const ASK_QUESTION = 'How do I answer "what about the nurse who drives in at 5 a.m."?';
160186
const ASK_STREAM = 'Concede the edge case, then shrink it. Three moves:\n\n1. **Exemptions exist.** Every real ban permits shift workers and carers.\n2. **Night buses follow demand.** Cities that closed their centres added routes in the first year.\n3. **Flip it.** The same nurse breathes the air on her walk from the car park.';
@@ -433,6 +459,34 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language
433459
await streaming.close();
434460
}
435461

462+
/** Prepare and Generate in one-off mode, reached in the app so the in-memory mode survives. Nothing is sent or saved. */
463+
async function shootOneOffTools(page: Page, prefix: string) {
464+
await page.goto(`${ORIGIN}/`);
465+
// Opening the switcher takes the tab out of focus, which drops a load still running; let it finish first.
466+
await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 });
467+
await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–'));
468+
await settle(page);
469+
await page.getByTestId('current-debate-pill').click();
470+
await page.getByTestId('debates-sheet').waitFor();
471+
await settle(page);
472+
await turnOnOneOff(page);
473+
await closeSwitcher(page);
474+
await page.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click();
475+
await page.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 });
476+
// The Debate tab stays mounted behind Prepare with its own notice; only the visible one is Prepare's.
477+
await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor();
478+
await settle(page);
479+
await shoot(page, `${prefix}-prepare-oneoff`);
480+
await page.getByTestId('prepare-tool-generate').click();
481+
await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 });
482+
const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice');
483+
await notice.waitFor();
484+
await shoot(page, `${prefix}-generate-oneoff`);
485+
// Return to debate turns the mode off in place, and the strip says where work goes again.
486+
await notice.getByRole('button').click();
487+
await page.getByTestId('generate-screen').getByTestId('working-in').waitFor();
488+
}
489+
436490
async function shootEmptyDebate(page: Page, prefix: string) {
437491
await page.route(`${REST}/current_debates*`, (route) =>
438492
route.fulfill({ status: 200, contentType: 'application/json', json: [] }),
@@ -441,6 +495,18 @@ async function shootEmptyDebate(page: Page, prefix: string) {
441495
await page.getByTestId('current-debate-pill').waitFor({ timeout: 30_000 });
442496
await settle(page);
443497
await shoot(page, `${prefix}-debate-empty`);
498+
499+
// One-off mode works without a Current Debate too; there is nothing to return to.
500+
await page.getByTestId('current-debate-pill').click();
501+
await page.getByTestId('debates-sheet').waitFor();
502+
await settle(page);
503+
await turnOnOneOff(page);
504+
await closeSwitcher(page);
505+
await page.getByTestId('one-off-notice').waitFor();
506+
if ((await page.getByTestId('one-off-notice').getByRole('button').count()) > 0) {
507+
throw new Error('One-off mode offered a return without a Current Debate');
508+
}
509+
await shoot(page, `${prefix}-debate-empty-oneoff`);
444510
}
445511

446512
async function signIn(browser: Browser, problems: string[]): Promise<StorageState> {
@@ -555,7 +621,7 @@ async function main() {
555621
await shoot(tabPage, `${prefix}-failed`);
556622
throw error;
557623
});
558-
shots += 4;
624+
shots += 6;
559625
await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => {
560626
await shoot(tabPage, `${prefix}-failed`);
561627
throw error;
@@ -573,11 +639,17 @@ async function main() {
573639
watchErrors(emptyPage, problems, `${prefix} empty`);
574640
await pinSettings(emptyPage, language);
575641
await shootEmptyDebate(emptyPage, prefix);
576-
shots += 1;
642+
shots += 2;
577643
await shootAskScreens(signedIn, prefix, language, problems);
578644
shots += 3;
579645
await shootResultScreens(signedIn, prefix, language, problems);
580646
shots += 9;
647+
const oneOffPage = await signedIn.newPage();
648+
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
649+
await pinSettings(oneOffPage, language);
650+
await shootOneOffTools(oneOffPage, prefix);
651+
await oneOffPage.close();
652+
shots += 2;
581653
await signedIn.close();
582654
}
583655
}

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

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { MainActionButton, TextButton } from '@/components/buttons';
88
import { Card, IconTile } from '@/components/card';
99
import { Icon } from '@/components/icon';
1010
import type { IconName } from '@/components/icon-paths';
11+
import { OneOffNotice } from '@/components/one-off-notice';
1112
import { PREPARE_TOOLS } from '@/components/prepare-tools';
1213
import { Screen } from '@/components/screen';
1314
import { SegmentedControl } from '@/components/segmented-control';
@@ -31,7 +32,7 @@ const RECENT_LIMIT = 3;
3132
export default function DebateScreen() {
3233
const { t } = useTranslation();
3334
const router = useRouter();
34-
const { status, current, oneOff, setOneOff, refresh } = useDebates();
35+
const { status, current, refresh } = useDebates();
3536

3637
// Debates switched on the website show up when the tab comes back into view.
3738
const focusedBefore = useRef(false);
@@ -69,6 +70,7 @@ export default function DebateScreen() {
6970
return (
7071
<Screen safeTop>
7172
<SwitcherPill onPress={openSwitcher} />
73+
<OneOffNotice />
7274
<View style={styles.emptyText}>
7375
<Text accessibilityRole="header" weight="bold" style={styles.emptyTitle}>
7476
{t('debate.noCurrentTitle')}
@@ -85,7 +87,7 @@ export default function DebateScreen() {
8587
return (
8688
<Screen safeTop gap={0}>
8789
<SwitcherPill onPress={openSwitcher} />
88-
{oneOff ? <OneOffNotice onReturn={() => setOneOff(false)} /> : null}
90+
<OneOffNotice style={styles.notice} />
8991
<Text accessibilityRole="header" weight="bold" style={styles.motion} testID="current-debate-motion">
9092
{current.motion}
9193
</Text>
@@ -112,19 +114,6 @@ function SwitcherPill({ onPress }: { onPress: () => void }) {
112114
);
113115
}
114116

115-
function OneOffNotice({ onReturn }: { onReturn: () => void }) {
116-
const { t } = useTranslation();
117-
const { colors } = useTheme();
118-
return (
119-
<View style={[styles.notice, { borderColor: colors.border }]}>
120-
<Text tone="secondary" style={styles.noticeText}>
121-
{t('debate.oneOffStrip')}
122-
</Text>
123-
<TextButton label={t('debate.returnToDebate')} onPress={onReturn} />
124-
</View>
125-
);
126-
}
127-
128117
/**
129118
* Counts, the main action, the Prepare tools and recent work for one debate.
130119
* Students prepare both sides, so the toggle only chooses which side's work is
@@ -317,16 +306,7 @@ const styles = StyleSheet.create({
317306
marginTop: 4,
318307
},
319308
pillLabel: { fontSize: 15 },
320-
notice: {
321-
marginTop: 12,
322-
borderWidth: 1,
323-
borderStyle: 'dashed',
324-
borderRadius: radius.field,
325-
paddingHorizontal: 14,
326-
paddingTop: 10,
327-
alignItems: 'flex-start',
328-
},
329-
noticeText: { fontSize: 14, lineHeight: 19 },
309+
notice: { marginTop: 12 },
330310
emptyText: { gap: 8 },
331311
emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 },
332312
motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 },

‎apps/mobile/src/app/(tabs)/(library)/library.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Card, IconTile } from '@/components/card';
99
import { Chip } from '@/components/chip';
1010
import { Icon } from '@/components/icon';
1111
import { LibraryHeader, type AddSourceKind } from '@/components/library-header';
12+
import { OneOffNotice } from '@/components/one-off-notice';
1213
import { Screen } from '@/components/screen';
1314
import { SearchField } from '@/components/search-field';
1415
import { SOURCE_ICONS, sourceMeta } from '@/components/source-display';
@@ -47,6 +48,8 @@ export default function LibraryScreen() {
4748
return (
4849
<Screen>
4950
<LibraryHeader title={t('mobile.tabs.library')} onAdd={onAdd} disabled={!debateId} />
51+
{/* New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. */}
52+
<OneOffNotice message="debate.oneOffActive" />
5053
{status === 'loading' ? <ActivityIndicator style={styles.loading} /> : null}
5154
{status !== 'loading' && !debateId ? (
5255
<Card>

‎apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
66
import { TextButton } from '@/components/buttons';
77
import { Card, IconTile } from '@/components/card';
88
import { Icon } from '@/components/icon';
9+
import { OneOffNotice } from '@/components/one-off-notice';
910
import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools';
1011
import { Screen } from '@/components/screen';
1112
import { ScreenTitle } from '@/components/screen-title';
@@ -14,6 +15,7 @@ import { WorkRow, workHref } from '@/components/work-row';
1415
import { useAuth } from '@/lib/auth';
1516
import { useDebates } from '@/lib/debate-context';
1617
import { fetchPrepareWork, type DebateItem } from '@/lib/debates';
18+
import { keepIfSame } from '@/lib/keep-if-same';
1719
import { useTheme } from '@/theme/use-theme';
1820

1921
const SAVED_LIMIT = 30;
@@ -25,7 +27,7 @@ export default function PrepareScreen() {
2527
const { colors } = useTheme();
2628
const { session } = useAuth();
2729
const userId = session?.user.id ?? null;
28-
const { current, oneOff } = useDebates();
30+
const { current } = useDebates();
2931
const debateId = current?.id ?? null;
3032
// Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one.
3133
const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null);
@@ -45,7 +47,8 @@ export default function PrepareScreen() {
4547
try {
4648
const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT);
4749
if (!isCurrent()) return;
48-
setLoaded({ debateId: forDebate, items: next, failed: false });
50+
// Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render.
51+
setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false }));
4952
} catch (cause) {
5053
if (!isCurrent()) return;
5154
console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause);
@@ -66,6 +69,7 @@ export default function PrepareScreen() {
6669
return (
6770
<Screen>
6871
<ScreenTitle title={t('mobile.tabs.prepare')} />
72+
<OneOffNotice />
6973
<Card style={styles.list}>
7074
{PREPARE_TOOLS.map((tool, index) => (
7175
<Pressable
@@ -91,9 +95,10 @@ export default function PrepareScreen() {
9195
<Text accessibilityRole="header" weight="bold" style={styles.section}>
9296
{t('mobile.prepare.saved')}
9397
</Text>
94-
{oneOff || !debateId ? (
98+
{/* The Current Debate's results stay listed in one-off mode, as on the Debate tab and the website's home. */}
99+
{!debateId ? (
95100
<Text tone="secondary" style={styles.body}>
96-
{oneOff ? t('debate.oneOffStrip') : t('debate.noCurrentStrip')}
101+
{t('debate.noCurrentStrip')}
97102
</Text>
98103
) : failed ? (
99104
<Card>

0 commit comments

Comments
 (0)