diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 659159dd3..4ab5568cc 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -33,10 +33,11 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. - You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). Upgrade, Manage subscription and promo codes wait for in-app purchase. - 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. -- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. - No build can get a token until `app.config.ts` has `extra.eas.projectId`, from `eas init` on build day (see [docs/development.md](../../docs/development.md#build-day)). Until then the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go. +- iPad layouts (iPad mock A, with mock C's docked claim) come from one hook, `useLayoutClass()` in `src/lib/layout.ts`, which also holds every width they use: `phone` on every iPhone and in iPad windows under 700 points, `regular` from 700, `wide` from 1100. Phone layouts must not change, so each screen keeps its phone JSX and branches before it; compare iPhone screenshots before and after. NativeTabs is `sidebarAdaptable` (the iPadOS 18 sidebar; `app-tabs.web.tsx` draws a sidebar or a top tab bar for the preview). The Library opens a source beside its list (`SourceView`, shared with the source sheet), Ask lists its conversations beside the chat (`ask-chat-list.tsx`), the Debate tab is a two-column dashboard, saved documents get an "On this page" rail from 900 points (`DocumentScreen`'s `outline`, `components/outline.tsx`), and the Flowchart docks a card's claim beside the map (`ClaimPanel` in `claim-panel.tsx`, shared with the claim sheet). A docked claim or a source open beside its list is dropped when that map, debate, account or row is no longer the one on screen. All are plain views sized from the window, so they run in Expo Go. - `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/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 484eb871a..710c74c73 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -7,13 +7,17 @@ * npm run -w @debatelab/mobile export:web * PORT= npx tsx apps/mobile/scripts/screenshots.ts * - * ONLY=iphone-light-en limits a run to the combinations whose name starts with it. + * ONLY=iphone-light-en limits a run to the combinations whose name starts with it; + * separate several with commas (ONLY=iphone-light-en,ipad-dark-hu). * SCREENS=you,timer limits the signed-in screens to those groups: debate, * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, * timer, crossfire, sparring, flowchart, one-off, history, announcements, * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * + * The iPad sizes are portrait (1024x1366), landscape (1366x1024) and a 600-point + * window (Split View or Slide Over), which takes the phone layout. + * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the * script signs in once through the real sign-in screen and also checks that a * cold start without a network keeps the user signed in. The deployed Edge @@ -89,7 +93,8 @@ const DIST = join(__dirname, '../dist'); const OUT = join(__dirname, '../../../.verify/mobile-shots'); const PORT = Number(process.env.PORT); const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim(); -const ONLY = process.env.ONLY?.trim() ?? ''; +const ONLY = (process.env.ONLY ?? '').split(',').map((value) => value.trim()).filter((value) => value.length > 0); +const offlineOnly = ONLY.length === 1 && ONLY[0] === 'offline'; const SCREENS = process.env.SCREENS?.split(',').map((name) => name.trim()).filter(Boolean); const want = (group: string) => !SCREENS?.length || SCREENS.includes(group); const ORIGIN = `http://127.0.0.1:${PORT}`; @@ -98,7 +103,9 @@ const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`; const DEVICES = { iphone: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, - ipad: { viewport: { width: 820, height: 1180 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, + ipad: { viewport: { width: 1024, height: 1366 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, + 'ipad-landscape': { viewport: { width: 1366, height: 1024 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, + 'ipad-narrow': { viewport: { width: 600, height: 1366 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, } as const; const SCHEMES = ['light', 'dark'] as const; const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const; @@ -447,6 +454,18 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); await page.waitForTimeout(400); await shoot(page, `${prefix}-case`); + // A wide iPad shows the outline rail: jumping to a part scrolls that part's heading to the top of the case. + const outline = page.getByTestId('document-outline'); + if (await outline.isVisible()) { + const part = /^(Case Structure|Eset Struktúra|Estructura del Caso)$/; + const heading = page.getByTestId('case-document').getByRole('heading', { name: part }); + const before = (await heading.boundingBox())?.y ?? 0; + await outline.getByRole('menuitem').filter({ hasText: part }).click(); + await page.waitForTimeout(800); + const after = (await heading.boundingBox())?.y ?? 0; + if (!(after < before && after < 200)) throw new Error(`The outline did not scroll to Case Structure (heading at ${before}, then ${after})`); + await shoot(page, `${prefix}-case-outline`); + } // Close the open argument, so every argument shows collapsed. await page.getByTestId('document-item').first().click(); await shoot(page, `${prefix}-case-collapsed`); @@ -618,7 +637,10 @@ async function shootFlowchart(context: BrowserContext, prefix: string, language: await page.getByTestId('claim-link').click(); await page.getByTestId('link-targets').waitFor(); await shoot(page, `${prefix}-flowchart-link`); - await page.goBack(); + // On iPad the claim is docked beside the map, so it closes instead of going back. + const docked = page.getByTestId('claim-close'); + if (await docked.isVisible()) await docked.click(); + else await page.goBack(); await page.getByTestId('flowchart-screen').waitFor(); await page.getByTestId('document-add').click(); @@ -1546,7 +1568,7 @@ async function main() { try { const session = DEMO_CODE ? await signIn(browser, problems) : null; if (!session) console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); - else if (ONLY === 'offline') { + else if (offlineOnly) { for (const [index, run] of OFFLINE_RUNS.entries()) shots += await shootOfflineScreens(browser, session, problems, run, index === 0); } else await checkOfflineColdStart(browser, session, problems); @@ -1554,7 +1576,7 @@ async function main() { for (const scheme of SCHEMES) { for (const [language, locale] of Object.entries(LOCALES)) { const prefix = `${device}-${scheme}-${language}`; - if (!prefix.startsWith(ONLY)) continue; + if (ONLY.length > 0 && !ONLY.some((only) => prefix.startsWith(only))) continue; const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); const page = await signedOut.newPage(); watchErrors(page, problems, prefix); diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 1276ceb39..1192a8f32 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -13,6 +13,7 @@ import { OneOffNotice } from '@/components/one-off-notice'; import { PREPARE_TOOLS, prepareToolHref } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; +import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; @@ -24,19 +25,25 @@ import { type DebateItem, type DebateSide, } from '@/lib/debates'; +import { HOME_COLUMNS_WIDTH, useLayoutClass } from '@/lib/layout'; +import { fetchSources, type Source } from '@/lib/sources'; import { useAnnouncements } from '@/lib/use-announcements'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; const RECENT_LIMIT = 3; +/** The iPad dashboard lists more work, and the newest sources beside it (iPad mock A). */ +const DASHBOARD_RECENT_LIMIT = 5; +const DASHBOARD_SOURCES = 3; /** Home: the Current Debate, as in mock 01. */ export default function DebateScreen() { const { t } = useTranslation(); const router = useRouter(); const { status, current, refresh } = useDebates(); - // The website's announcement banner, at the top of the Debate tab. + const dashboard = useLayoutClass() !== 'phone'; + // The website's announcement banner, at the top of the Debate tab, including the iPad dashboard. const announcements = useAnnouncements(); const refreshAnnouncements = announcements.refresh; @@ -93,6 +100,20 @@ export default function DebateScreen() { ); } + if (dashboard) { + return ( + + + + + + {current.motion} + + + + ); + } + return ( @@ -128,8 +149,9 @@ function SwitcherPill({ onPress }: { onPress: () => void }) { * Counts, the main action, the Prepare tools and recent work for one debate. * Students prepare both sides, so the toggle only chooses which side's work is * shown. It starts on In favor for each debate and is never stored. + * On iPad (`dashboard`) they sit in two columns, with the newest sources. */ -function DebateDetails({ debateId }: { debateId: string }) { +function DebateDetails({ debateId, dashboard = false }: { debateId: string; dashboard?: boolean }) { const { t } = useTranslation(); const router = useRouter(); const { session } = useAuth(); @@ -137,29 +159,34 @@ function DebateDetails({ debateId }: { debateId: string }) { const [view, setView] = useState('pro'); const [counts, setCounts] = useState(null); const [recent, setRecent] = useState | null>(null); + const [sources, setSources] = useState(null); const [failed, setFailed] = useState(false); + const [width, setWidth] = useState(0); const copies = useCopyHolder(); // Both sides load together, so switching the view is instant. const load = useCallback(async (isCurrent: () => boolean) => { if (!userId) return; + const limit = dashboard ? DASHBOARD_RECENT_LIMIT : RECENT_LIMIT; const mark = copies.mark(); try { - const [nextCounts, pro, con] = await Promise.all([ + const [nextCounts, pro, con, nextSources] = await Promise.all([ fetchDebateCounts(userId, debateId, mark), - fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT, mark), - fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT, mark), + fetchRecentWork(userId, debateId, 'pro', limit, mark), + fetchRecentWork(userId, debateId, 'con', limit, mark), + dashboard ? fetchSources(userId, debateId, mark) : null, ]); if (!isCurrent()) return; setCounts(nextCounts); setRecent({ pro, con }); + setSources(nextSources?.slice(0, DASHBOARD_SOURCES) ?? null); setFailed(false); } catch (cause) { if (!isCurrent()) return; console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [userId, debateId, copies]); + }, [userId, debateId, dashboard, copies]); // Reload on focus, so work saved on the website shows up. Out of view, its saved copies no longer count. useFocusEffect( @@ -176,7 +203,7 @@ function DebateDetails({ debateId }: { debateId: string }) { const results = counts?.results[view]; const items = recent?.[view] ?? null; - return ( + const sideAndStats = ( <> @@ -195,8 +222,11 @@ function DebateDetails({ debateId }: { debateId: string }) { - router.push({ pathname: '/sparring', params: { from: 'debate' } })} /> - + + ); + const spar = router.push({ pathname: '/sparring', params: { from: 'debate' } })} />; + const prepare = ( + <> {t('mobile.tabs.prepare')} @@ -210,8 +240,12 @@ function DebateDetails({ debateId }: { debateId: string }) { /> ))} - - + + ); + const twoColumns = dashboard && width >= HOME_COLUMNS_WIDTH; + const pickUp = ( + <> + {t('mobile.debate.pickUp')} @@ -244,6 +278,78 @@ function DebateDetails({ debateId }: { debateId: string }) { )} ); + + if (!dashboard) { + return ( + <> + {sideAndStats} + {spar} + {prepare} + {pickUp} + + ); + } + + const recentSources = failed ? null : sources === null ? ( + + ) : sources.length === 0 ? ( + + + {t('libraryPage.emptyDesc')} + + + ) : ( + + {sources.map((source, index) => ( + router.push({ pathname: '/source/[id]', params: { id: source.id } })} /> + ))} + + ); + + return ( + setWidth(event.nativeEvent.layout.width)} testID="debate-dashboard"> + + {sideAndStats} + {spar} + {prepare} + + + {pickUp} + {failed ? null : ( + <> + + {t('mobile.debate.recentSources')} + + {recentSources} + + )} + + + ); +} + +/** A source in the dashboard's Recent sources; it opens in the source sheet. */ +function RecentSource({ source, first, onPress }: { source: Source; first: boolean; onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [styles.sourceRow, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {source.title || t('libraryPage.untitledSource')} + + + {sourceMeta(t, source)} + + + + + ); } function Stat({ value, label }: { value: number | undefined; label: string }) { @@ -366,4 +472,13 @@ const styles = StyleSheet.create({ }, tileLabel: { flex: 1, fontSize: 15.5 }, list: { padding: 0, gap: 0 }, + motionWide: { fontSize: 34, lineHeight: 40, letterSpacing: -0.9 }, + columns: { flexDirection: 'row', alignItems: 'flex-start', gap: 32 }, + column: { flex: 1, minWidth: 0 }, + columnTop: { marginTop: 0 }, + sideColumn: { maxWidth: 420 }, + sourceRow: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + sourceText: { flex: 1, gap: 2 }, + sourceTitle: { fontSize: 16 }, + sourceMeta: { fontSize: 14 }, }); diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 19fef2639..71783e6d7 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -1,7 +1,7 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; import { TextButton } from '@/components/buttons'; @@ -9,14 +9,18 @@ import { Card, IconTile } from '@/components/card'; import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import { LibraryHeader, type AddSourceKind } from '@/components/library-header'; +import { OfflineNotice } from '@/components/offline-notice'; import { OneOffNotice } from '@/components/one-off-notice'; import { Screen } from '@/components/screen'; import { SearchField } from '@/components/search-field'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; +import { SourceView } from '@/components/source-view'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; +import { LIBRARY_ROOMY_WIDTH, LIST_WIDTH, SIDEBAR_WIDTH, useLayoutClass } from '@/lib/layout'; +import { openRowId } from '@/lib/open-pane'; import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; @@ -37,15 +41,21 @@ function matches(source: Source, query: string): boolean { return !needle || [source.title, source.content, source.origin ?? ''].some((value) => value.toLocaleLowerCase().includes(needle)); } -/** The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. */ +/** + * The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. + * On iPad the list and the open source sit side by side (iPad mock A). + */ export default function LibraryScreen() { const { t } = useTranslation(); const router = useRouter(); const { status, current } = useDebates(); const debateId = current?.id ?? null; + const layout = useLayoutClass(); const onAdd = (kind: AddSourceKind) => router.push(kind === 'link' ? '/add-link' : '/new-note'); + if (layout !== 'phone') return ; + return ( @@ -65,7 +75,81 @@ export default function LibraryScreen() { ); } -function SourceList({ debateId }: { debateId: string }) { +/** The iPad Library: the list on the left, the source it opened on the right, in place of the source sheet. */ +function LibrarySplit({ wide, onAdd }: { wide: boolean; onAdd: (kind: AddSourceKind) => void }) { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { width } = useWindowDimensions(); + const { session } = useAuth(); + const { status, current } = useDebates(); + const userId = session?.user.id ?? null; + const debateId = current?.id ?? null; + // The selection belongs to the account and debate it was made in; switching either clears it. + const [selected, setSelected] = useState<{ userId: string; debateId: string; id: string } | null>(null); + const shown = selected && selected.userId === userId && selected.debateId === debateId ? selected.id : null; + const clearSelected = useCallback((id: string) => { + setSelected((previous) => (previous?.id === id ? null : previous)); + }, []); + const listWidth = width - (wide ? SIDEBAR_WIDTH : 0) >= LIBRARY_ROOMY_WIDTH ? LIST_WIDTH.libraryRoomy : LIST_WIDTH.library; + + return ( + + + + + + {status === 'loading' ? : null} + {status !== 'loading' && !debateId ? ( + + + {t('debate.libraryNeedsDebate')} + + router.push('/debates')} /> + + ) : null} + {debateId && userId ? ( + setSelected({ userId, debateId, id: source.id })} + onRemoved={clearSelected} + /> + ) : null} + + + {shown && userId ? ( + + ) : ( + + + + {t('mobile.library.pickSource')} + + + )} + + + ); +} + +function SourceList({ + debateId, + selectedId, + onOpen, + onRemoved, +}: { + debateId: string; + /** The source open beside the list on iPad. */ + selectedId?: string | null; + /** Opens a source beside the list instead of in the source sheet. */ + onOpen?: (source: Source) => void; + onRemoved?: (id: string) => void; +}) { const { t } = useTranslation(); const router = useRouter(); const { session } = useAuth(); @@ -104,6 +188,12 @@ function SourceList({ debateId }: { debateId: string }) { ); useEffect(() => subscribeSourcesChanged(() => void load()), [load]); + // A refresh that no longer has the open source closes the detail pane. Searching does not: `sources` is the full list. + useEffect(() => { + if (!selectedId || sources === null) return; + if (openRowId(selectedId, sources) === null) onRemoved?.(selectedId); + }, [sources, selectedId, onRemoved]); + const visible = useMemo( () => (sources ?? []).filter((source) => (filter === 'all' || source.type === filter) && matches(source, query)), [sources, filter, query], @@ -122,6 +212,7 @@ function SourceList({ debateId }: { debateId: string }) { try { await removeSource(userId, source.id); setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous); + onRemoved?.(source.id); return true; } catch (cause) { console.warn('Could not delete the source:', cause instanceof Error ? cause.message : cause); @@ -188,7 +279,8 @@ function SourceList({ debateId }: { debateId: string }) { key={source.id} source={source} first={index === 0} - onOpen={() => router.push({ pathname: '/source/[id]', params: { id: source.id } })} + selected={selectedId === source.id} + onOpen={() => (onOpen ? onOpen(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } }))} onDelete={() => remove(source)} /> ))} @@ -199,7 +291,19 @@ function SourceList({ debateId }: { debateId: string }) { } /** A source row; swipe left to delete, with a confirmation, as on iOS. */ -function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: boolean; onOpen: () => void; onDelete: () => Promise }) { +function SourceRow({ + source, + first, + selected = false, + onOpen, + onDelete, +}: { + source: Source; + first: boolean; + selected?: boolean; + onOpen: () => void; + onDelete: () => Promise; +}) { const { t } = useTranslation(); const { colors } = useTheme(); const swipeable = useRef(null); @@ -230,16 +334,17 @@ function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: )}> { if (event.nativeEvent.actionName === 'delete') void remove(); }} onPress={onOpen} testID="source-row" - style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> + style={({ pressed }) => [styles.row, { backgroundColor: selected ? colors.tile : colors.card }, pressed && styles.pressed]}> - + {source.title || t('libraryPage.untitledSource')} @@ -265,4 +370,10 @@ const styles = StyleSheet.create({ pressed: { opacity: 0.7 }, deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, deleteLabel: { color: '#ffffff', fontSize: 13 }, + split: { flex: 1, flexDirection: 'row' }, + listPane: { flexGrow: 0, flexShrink: 0, borderRightWidth: StyleSheet.hairlineWidth }, + listContent: { paddingHorizontal: 20, paddingBottom: 32, gap: 16 }, + detailPane: { flex: 1 }, + detailContent: { width: '100%', maxWidth: 700, alignSelf: 'center', paddingHorizontal: 36, paddingTop: 14, paddingBottom: 48, gap: 14 }, + pick: { alignItems: 'center', gap: 10, paddingTop: 120 }, }); diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 01bb261ac..79d3e3c47 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollVie import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AskAnswer } from '@/components/ask-answer'; +import { AskChatPane } from '@/components/ask-chat-list'; import { AskHistory } from '@/components/ask-history'; import { TextButton } from '@/components/buttons'; import { Chip } from '@/components/chip'; @@ -21,6 +22,7 @@ import type { FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; +import { LIST_WIDTH, useLayoutClass } from '@/lib/layout'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; @@ -48,9 +50,13 @@ const RETRYABLE_ERRORS = new Set(['network', 'timeout', 'failed', ' let localId = 0; const nextId = () => `local-${Date.now()}-${(localId += 1)}`; -/** Ask, as in mock 07: a coaching chat that streams, grounded in the Current Debate. */ +/** + * Ask, as in mock 07: a coaching chat that streams, grounded in the Current Debate. + * On iPad the debate's conversations stay listed beside the chat (iPad mock A). + */ export default function AskScreen() { const { t } = useTranslation(); + const split = useLayoutClass() !== 'phone'; const router = useRouter(); const { prompt, chat: resumeChatId } = useLocalSearchParams<{ prompt?: string; chat?: string }>(); const insets = useSafeAreaInsets(); @@ -339,16 +345,23 @@ export default function AskScreen() { const subtitle = oneOff ? t('debate.oneOffActive') : current?.motion ?? ''; const busy = loading || opening; + // Beside the list, the chat is titled by its first question, as its saved row is. + const question = messages.find((message) => message.role === 'user')?.content; + const title = split ? question ?? t('askChat.newChat') : t('nav.ask'); - return ( - + const chat = ( + <> - router.back()} style={styles.headerButton}> - - + {split ? ( + + ) : ( + router.back()} style={styles.headerButton}> + + + )} - - {t('nav.ask')} + + {title} {subtitle ? ( @@ -356,15 +369,19 @@ export default function AskScreen() { ) : null} - setHistoryOpen(true)} - style={styles.headerButton} - testID="ask-history-button"> - - + {split ? ( + + ) : ( + setHistoryOpen(true)} + style={styles.headerButton} + testID="ask-history-button"> + + + )} + + ); + const newChat = () => { + setHistoryOpen(false); + startConversation([], NEW_CHAT); + }; + + if (split) { + return ( + + + router.back()} + onPick={(chatId) => void openChat(chatId)} + onNewChat={newChat} + /> + {chat} + + + ); + } + + return ( + + {chat} setHistoryOpen(false)} onPick={(chatId) => void openChat(chatId)} - onNewChat={() => { - setHistoryOpen(false); - startConversation([], NEW_CHAT); - }} + onNewChat={newChat} /> ); @@ -503,6 +546,8 @@ function Thinking() { const styles = StyleSheet.create({ root: { flex: 1 }, + split: { flex: 1, flexDirection: 'row' }, + chatPane: { flex: 1 }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 10, borderBottomWidth: StyleSheet.hairlineWidth }, headerButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, headerText: { flex: 1, alignItems: 'center' }, diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx index 69817e897..038db2f7f 100644 --- a/apps/mobile/src/app/attack/[id].tsx +++ b/apps/mobile/src/app/attack/[id].tsx @@ -69,6 +69,7 @@ export default function AttackResultScreen() { return ( run(() => shareText(title, text()))} actions={actions} />} actions={ diff --git a/apps/mobile/src/app/brainstorm/[id].tsx b/apps/mobile/src/app/brainstorm/[id].tsx index 980bac6a5..aba4e01f9 100644 --- a/apps/mobile/src/app/brainstorm/[id].tsx +++ b/apps/mobile/src/app/brainstorm/[id].tsx @@ -72,6 +72,7 @@ export default function BrainstormResultScreen() { return ( run(() => shareText(title, brainstormToText(output, t)))} actions={actions} />} // The website shows only the questions when the model needs more, with no export or discussion. diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx index 699221708..1d7ecf878 100644 --- a/apps/mobile/src/app/case/[id].tsx +++ b/apps/mobile/src/app/case/[id].tsx @@ -71,6 +71,7 @@ export default function CaseScreen() { return ( run(() => shareText(title, caseToText(output)))} actions={actions} />} actions={ diff --git a/apps/mobile/src/app/factoids/[id].tsx b/apps/mobile/src/app/factoids/[id].tsx index 9ccf523e7..1559b80be 100644 --- a/apps/mobile/src/app/factoids/[id].tsx +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -119,6 +119,7 @@ export default function FactoidsResultScreen() { return ( run(() => shareText(title, factoidsToText(output, t)))} actions={actions} />} actions={ diff --git a/apps/mobile/src/app/flowchart/claim.tsx b/apps/mobile/src/app/flowchart/claim.tsx index 669c6a687..66bddc957 100644 --- a/apps/mobile/src/app/flowchart/claim.tsx +++ b/apps/mobile/src/app/flowchart/claim.tsx @@ -1,268 +1,11 @@ -import { - canDeleteCard, - canLink, - cardLinks, - deleteCard, - editCard, - findNode, - flowNodes, - linkCards, - unlinkCards, - type FlowEdgeKind, - type FlowNodeType, -} from '@debatelab/core/flowchart'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; -import { ActionMenu } from '@/components/action-menu'; -import { TextButton } from '@/components/buttons'; -import { Chip } from '@/components/chip'; -import { CardForm } from '@/components/flowchart-card-form'; -import { useFlowColors } from '@/components/flowchart-views'; -import { Icon } from '@/components/icon'; -import { SegmentedControl } from '@/components/segmented-control'; +import { ClaimPanel } from '@/components/claim-panel'; import { SheetScrollView } from '@/components/sheet'; -import { Text } from '@/components/text'; -import { confirmDestructive } from '@/lib/confirm'; -import { findDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; -import { cardKind, linkLine, linkSummary, typeLabel } from '@/lib/flowchart-text'; -import { radius } from '@/theme/colors'; -import { useTheme } from '@/theme/use-theme'; -const TYPES: FlowNodeType[] = ['contention', 'attack', 'rebuttal', 'dropped']; - -/** The kind a new link starts on: what this card's type usually does. */ -function defaultKind(type: FlowNodeType): FlowEdgeKind { - return type === 'attack' ? 'attack' : type === 'rebuttal' ? 'rebuttal' : 'support'; -} - -/** - * A card's sheet, as mock A: its type, text and links, and the website's - * actions on a node: link it, edit it, change its type, or delete it. Every - * change lands in the map on screen at once and stays unsaved until Save. - */ +/** A card's claim in a form sheet, as mock A. iPad docks the same panel beside the map instead. */ export default function ClaimSheet() { - const { t } = useTranslation(); const router = useRouter(); - const { colors } = useTheme(); - const flow = useFlowColors(); - const { scope, map, node: nodeId } = useLocalSearchParams<{ scope: string; map: string; node: string }>(); - useFlowchartDrafts(); - // `map` is the map's handle, so a save that changes its key while the sheet is open keeps it. - const held = scope && map ? findDraft(scope, map) : null; - const draft = held?.draft; - const node = draft && nodeId ? findNode(draft.doc, nodeId) : undefined; - const [mode, setMode] = useState<'view' | 'link' | 'edit'>('view'); - const [kind, setKind] = useState(() => defaultKind(node?.data.type ?? 'contention')); - const [typeMenu, setTypeMenu] = useState(false); - - if (!held || !draft || !node) { - return ( - - - {t('mobile.flowchart.cardGone')} - - router.back()} /> - - ); - } - - // Found again when it runs: a save while a confirm was open may have moved the map to a new key. - const change = (edit: Parameters[2]) => { - const now = findDraft(scope, map); - if (now) updateDraft(scope, now.key, edit); - }; - const { responds, answeredBy } = cardLinks(draft.doc, node.id); - const links = [...responds.map((link) => ({ link, direction: 'responds' as const })), ...answeredBy.map((link) => ({ link, direction: 'answeredBy' as const }))]; - const deletable = canDeleteCard(node.id); - const isThesis = node.data.type === 'thesis'; - - const remove = async () => { - const confirmed = await confirmDestructive({ - title: t('flowchart.deleteNodeTitle'), - message: t('flowchart.deleteNodeDescription', { label: node.data.label }), - confirm: t('flowchart.deleteNodeConfirm'), - cancel: t('flowchart.deleteNodeCancel'), - }); - if (!confirmed) return; - change((current) => ({ ...current, doc: deleteCard(current.doc, node.id), edited: true })); - router.back(); - }; - - const header = ( - - - - {cardKind(t, node)} - - - {node.data.label} - - - router.back()} /> - - ); - - if (mode === 'edit') { - return ( - - {header} - - {t('flowchart.editNode')} - - { - change((current) => ({ ...current, doc: editCard(current.doc, node.id, fields), edited: true })); - setMode('view'); - }} - /> - setMode('view')} /> - - ); - } - - if (mode === 'link') { - const others = flowNodes(draft.doc).filter((other) => other.id !== node.id); - return ( - - {header} - - - {t('mobile.flowchart.linkTitle')} - - - {t('mobile.flowchart.linkHint')} - - - - - {others.map((other, index) => { - const allowed = canLink(draft.doc, other.id, node.id); - return ( - { - change((current) => ({ ...current, doc: linkCards(current.doc, other.id, node.id, kind), edited: true })); - setMode('view'); - }} - style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed, !allowed && styles.disabled]} - testID="link-target"> - - - - {other.data.label} - - - {allowed ? typeLabel(t, other.data.type) : t('mobile.flowchart.alreadyLinked')} - - - - ); - })} - - setMode('view')} /> - - ); - } - - return ( - - {header} - {node.data.description ? ( - - {node.data.description} - - ) : null} - - {linkSummary(t, draft.doc, node)} - - - setMode('link')} testID="claim-link" /> - setMode('edit')} testID="claim-edit" /> - {isThesis ? null : setTypeMenu(true)} testID="claim-type" />} - - {links.length > 0 ? ( - - - {t('mobile.flowchart.linksTitle')} - - - {links.map(({ link, direction }, index) => { - const line = linkLine(t, link.kind, direction, link.other); - return ( - 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> - - {line} - change((current) => ({ ...current, doc: unlinkCards(current.doc, link.edgeId), edited: true }))} - style={({ pressed }) => [styles.remove, pressed && styles.pressed]} - testID="remove-link"> - - - - ); - })} - - - ) : null} - {deletable ? void remove()} /> : null} - type !== node.data.type).map((type) => ({ key: type, label: typeLabel(t, type) }))} - cancelLabel={t('common.cancel')} - onClose={() => setTypeMenu(false)} - onSelect={(type) => - change((current) => ({ - ...current, - doc: editCard(current.doc, node.id, { - label: node.data.label, - description: node.data.description ?? '', - type: type as FlowNodeType, - strength: node.data.strength ?? 'moderate', - }), - edited: true, - })) - } - /> - - ); + const { scope, map, node } = useLocalSearchParams<{ scope: string; map: string; node: string }>(); + return router.back()} />; } - -const styles = StyleSheet.create({ - top: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, - topText: { flex: 1, gap: 4 }, - who: { fontSize: 14 }, - title: { fontSize: 21, lineHeight: 27, letterSpacing: -0.3 }, - body: { fontSize: 15.5, lineHeight: 22 }, - section: { fontSize: 16 }, - block: { gap: 8 }, - chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, - list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, - row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 11, minHeight: 50 }, - rowText: { flex: 1, gap: 2 }, - rowLabel: { fontSize: 15.5, lineHeight: 21 }, - rowMeta: { fontSize: 13, lineHeight: 17 }, - dot: { width: 10, height: 10, borderRadius: 3 }, - remove: { minWidth: 32, minHeight: 32, alignItems: 'center', justifyContent: 'center' }, - pressed: { opacity: 0.7 }, - disabled: { opacity: 0.45 }, -}); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 8c6d7db61..8f61ead77 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -7,6 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ActionMenu } from '@/components/action-menu'; import { TextButton } from '@/components/buttons'; +import { ClaimInspector } from '@/components/claim-panel'; import { DocumentHeader, type HeaderAction } from '@/components/document-header'; import { FlowColumnsView, FlowLegend, MapTreeView } from '@/components/flowchart-views'; import { Icon } from '@/components/icon'; @@ -36,6 +37,8 @@ import { } from '@/lib/flowchart-drafts'; import { flowchartToText } from '@/lib/flowchart-text'; import { deleteFlowchart, fetchFlowchart, fetchFlowchartList, flowchartScreenCopies, insertFlowchart, updateFlowchart } from '@/lib/flowcharts'; +import { PANE_WIDTH, useLayoutClass } from '@/lib/layout'; +import { openClaim } from '@/lib/open-pane'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; @@ -44,8 +47,9 @@ const NOTICE_MS = 3500; /** * The website's Flowchart in mock A's phone look: the Current Debate's newest * saved map (or the starter map), as role columns (Flow) or a tree (Map). - * Tapping a card opens its claim sheet; "+" adds a card; "..." saves, saves - * as, deletes and exports. Edits stay in session memory until saved. + * Tapping a card opens its claim sheet, or on iPad its claim docked beside the + * map (iPad mock C); "+" adds a card; "..." saves, saves as, deletes and + * exports. Edits stay in session memory until saved. */ export default function FlowchartScreen() { const { t } = useTranslation(); @@ -69,6 +73,9 @@ export default function FlowchartScreen() { const [busy, setBusy] = useState(false); const [menu, setMenu] = useState<'export' | null>(null); const [notice, setNoticeState] = useState(''); + const layout = useLayoutClass(); + // The card docked in the iPad inspector, with the account and map it was opened on. + const [claim, setClaim] = useState<{ userId: string; scope: string; map: string; node: string } | null>(null); const live = useRef(true); const owner = useRef(userId); @@ -143,8 +150,19 @@ export default function FlowchartScreen() { const openCard = (node: FlowNode) => { if (!scope || !open) return; + if (layout !== 'phone') { + if (userId) setClaim({ userId, scope, map: open.draft.handle, node: node.id }); + return; + } router.push({ pathname: '/flowchart/claim', params: { scope, map: open.draft.handle, node: node.id } }); }; + const mapHandle = open?.draft.handle ?? null; + // Switching maps, debates or accounts drops the docked card, so coming back does not reopen it. + // A narrower window only hides it; widening again keeps the card the student had open. + useEffect(() => { + setClaim((current) => openClaim(current, { userId, scope, map: mapHandle })); + }, [userId, scope, mapHandle]); + const docked = layout !== 'phone' ? openClaim(claim, { userId, scope, map: mapHandle }) : null; /** Runs a save or delete for the map on screen, as the account and map were when the student chose it. */ const act = async (work: (forUser: string, at: { scope: string; key: string; draft: FlowchartDraft }) => Promise) => { @@ -265,113 +283,142 @@ export default function FlowchartScreen() { const shownFailure = failure && failure.scope === (requestedId ? null : scopeKey(activeDebateId)) ? failure.kind : null; - return ( - - router.push({ pathname: '/flowchart/card', params: { scope, map: open.draft.handle } }) }] : []} - /> - - - - {t('nav.flowchart')} + const header = ( + router.push({ pathname: '/flowchart/card', params: { scope, map: open.draft.handle } }) }] : []} + /> + ); + const page = ( + + + + {t('nav.flowchart')} + + {draft && scope ? ( + router.push({ pathname: '/flowchart/maps', params: { scope } })} + style={({ pressed }) => [styles.mapRow, pressed && styles.pressed]} + testID="flowchart-maps"> + + + {mapTitle} + + + {draft.edited + ? t('mobile.flowchart.edited') + : draft.createdAt + ? t('mobile.flowchart.savedWhen', { when: formatWhen(draft.createdAt) }) + : t('mobile.flowchart.notSaved')} + + + {busy ? : } + + ) : null} + {oneOff ? : null} + + + + {draft ? ( + view === 'flow' ? ( + <> + + + + ) : ( + + ) + ) : shownFailure ? ( + + + {shownFailure === 'missing' ? t('mobile.flowchart.mapMissing') : t('mobile.flowchart.loadFailed')} - {draft && scope ? ( - router.push({ pathname: '/flowchart/maps', params: { scope } })} - style={({ pressed }) => [styles.mapRow, pressed && styles.pressed]} - testID="flowchart-maps"> - - - {mapTitle} - - - {draft.edited - ? t('mobile.flowchart.edited') - : draft.createdAt - ? t('mobile.flowchart.savedWhen', { when: formatWhen(draft.createdAt) }) - : t('mobile.flowchart.notSaved')} - - - {busy ? : } - - ) : null} - {oneOff ? : null} - - - - {draft ? ( - view === 'flow' ? ( - <> - - - + {shownFailure === 'missing' ? ( + setRequestedId(null)} /> ) : ( - - ) - ) : shownFailure ? ( - - - {shownFailure === 'missing' ? t('mobile.flowchart.mapMissing') : t('mobile.flowchart.loadFailed')} - - {shownFailure === 'missing' ? ( - setRequestedId(null)} /> - ) : ( - setAttempt((n) => n + 1)} /> - )} - - ) : ( - - + setAttempt((n) => n + 1)} /> + )} + + ) : ( + + + + )} + + ); + const toast = notice ? ( + + {notice} + + ) : null; + const exportMenu = ( + setMenu(null)} + onSelect={(choice) => { + if (!draft) return; + const meta = t('mobile.debate.itemMeta', { kind: t('debate.kinds.flowchart'), when: formatWhen(draft.createdAt ?? Date.now()) }); + if (choice === 'pdf') runExport(() => sharePdf(flowchartExportHtml(mapTitle, meta, draft.doc, t), FLOWCHART_EXPORT_FILE_NAME, mapTitle)); + if (choice === 'copy') + runExport(async () => { + await copyText(flowchartToText(mapTitle, draft.doc, t)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + ); + + if (layout !== 'phone') { + return ( + + {header} + + + {page} + {toast} - )} - - {notice ? ( - - {notice} + {docked ? setClaim(null)} /> : null} - ) : null} - setMenu(null)} - onSelect={(choice) => { - if (!draft) return; - const meta = t('mobile.debate.itemMeta', { kind: t('debate.kinds.flowchart'), when: formatWhen(draft.createdAt ?? Date.now()) }); - if (choice === 'pdf') runExport(() => sharePdf(flowchartExportHtml(mapTitle, meta, draft.doc, t), FLOWCHART_EXPORT_FILE_NAME, mapTitle)); - if (choice === 'copy') - runExport(async () => { - await copyText(flowchartToText(mapTitle, draft.doc, t)); - setNotice(t('generateResults.copiedToClipboard')); - }); - }} - /> + {exportMenu} + + ); + } + + return ( + + {header} + {page} + {toast} + {exportMenu} ); } const styles = StyleSheet.create({ root: { flex: 1 }, + split: { flex: 1, flexDirection: 'row' }, + canvas: { flex: 1 }, content: { paddingTop: 4, gap: 14 }, head: { paddingHorizontal: 16, gap: 12, width: '100%', maxWidth: 980, alignSelf: 'center' }, title: { fontSize: 32, lineHeight: 38, letterSpacing: -0.9 }, diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index def0477f3..c19522ba6 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -1,124 +1,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; -import * as WebBrowser from 'expo-web-browser'; -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; -import { TextButton } from '@/components/buttons'; -import { Card, IconTile } from '@/components/card'; -import { Icon } from '@/components/icon'; import { SheetScrollView } from '@/components/sheet'; -import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; -import { Text } from '@/components/text'; -import { useAuth } from '@/lib/auth'; -import { fetchSource, type Source } from '@/lib/sources'; -import { useCopyHolder } from '@/lib/use-copy-holder'; -import { useTheme } from '@/theme/use-theme'; +import { SourceView } from '@/components/source-view'; -function webUrl(origin: string | null): string | null { - return origin && /^https?:\/\//i.test(origin) ? origin : null; -} - -/** A source, read-only: its title, where it came from and its text. */ +/** A source, read-only, in a form sheet. The iPad Library shows the same view beside its list. */ export default function SourceSheet() { - const { t } = useTranslation(); const router = useRouter(); - const { colors } = useTheme(); const { id } = useLocalSearchParams<{ id: string }>(); - const { session } = useAuth(); - const userId = session?.user.id ?? null; - const [source, setSource] = useState(undefined); - const [failed, setFailed] = useState(false); - const copies = useCopyHolder(); - - useEffect(() => { - if (!userId) return; - let active = true; - fetchSource(userId, id, copies.mark()) - .then((next) => { - if (active) setSource(next); - }) - .catch((cause: unknown) => { - console.warn('Could not load the source:', cause instanceof Error ? cause.message : cause); - if (active) setFailed(true); - }); - return () => { - active = false; - }; - }, [userId, id, copies]); - - const url = webUrl(source?.origin ?? null); - return ( - - - {source ? ( - - - - {sourceMeta(t, source)} - - - ) : null} - - router.back()} /> - - - {failed ? ( - - {t('mobile.library.loadFailed')} - - ) : source === undefined ? ( - - ) : source === null ? ( - - {t('mobile.library.sourceMissing')} - - ) : ( - <> - - {source.title || t('libraryPage.untitledSource')} - - {url ? ( - void WebBrowser.openBrowserAsync(url)} - style={({ pressed }) => [styles.origin, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> - - - {url.replace(/^https?:\/\/(www\.)?/i, '')} - - - - ) : source.origin ? ( - - {source.origin} - - ) : null} - - - {source.content} - - - - )} + router.back()} /> ); } const styles = StyleSheet.create({ content: { paddingTop: 18, gap: 14 }, - header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, - headerText: { flex: 1 }, - kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, - meta: { fontSize: 14, flexShrink: 1 }, - loading: { marginTop: 32 }, - body: { fontSize: 15, lineHeight: 21 }, - title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, - origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 1, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, - originText: { flex: 1, fontSize: 15 }, - pressed: { opacity: 0.7 }, - text: { fontSize: 16, lineHeight: 24 }, }); diff --git a/apps/mobile/src/components/app-tabs.tsx b/apps/mobile/src/components/app-tabs.tsx index 6da3c8225..d0949ad20 100644 --- a/apps/mobile/src/components/app-tabs.tsx +++ b/apps/mobile/src/components/app-tabs.tsx @@ -10,7 +10,9 @@ export default function AppTabs() { const { t } = useTranslation(); const { colors } = useTheme(); return ( + // On iPad (iPadOS 18+) the tabs become the system sidebar; iPhone keeps the tab bar. + + + {t('home.title')} + + {TABS.map((tab) => ( + + + + ))} + + + + ); + } + + if (layout === 'regular') { + return ( + + {/* Tabs reads its triggers from a TabList that is its own child, so the bar is the TabList itself. */} + + {TABS.map((tab) => ( + + + + ))} + + + + ); + } + return ( @@ -54,10 +97,55 @@ const TabButton = forwardRef(function SidebarButton( + { icon, label, isFocused, ...props }, + ref, +) { + const { colors } = useTheme(); + const color = isFocused ? colors.onPrimary : colors.foreground; + return ( + + + + {label} + + + ); +}); + +const TopTabButton = forwardRef(function TopTabButton({ label, isFocused, ...props }, ref) { + const { colors } = useTheme(); + return ( + + + {label} + + + ); +}); + const styles = StyleSheet.create({ root: { flex: 1 }, + row: { flexDirection: 'row' }, slot: { flex: 1 }, bar: { flexDirection: 'row', justifyContent: 'space-around', paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth }, tab: { flex: 1, alignItems: 'center', gap: 3, minHeight: 44 }, label: { fontSize: 10.5 }, + sidebar: { width: SIDEBAR_WIDTH, flexDirection: 'column', justifyContent: 'flex-start', gap: 4, paddingHorizontal: 12, borderRightWidth: StyleSheet.hairlineWidth }, + sidebarTitle: { fontSize: 22, letterSpacing: -0.4, marginLeft: 8, marginBottom: 14 }, + sidebarRow: { flexDirection: 'row', alignItems: 'center', gap: 12, borderRadius: 10, paddingHorizontal: 12, minHeight: 40 }, + sidebarLabel: { fontSize: 16.5 }, + topTabs: { alignSelf: 'center', marginBottom: 6, flexDirection: 'row', justifyContent: 'center', gap: 2, borderWidth: StyleSheet.hairlineWidth, borderRadius: 999, padding: 4 }, + topTab: { borderRadius: 999, paddingHorizontal: 16, minHeight: 36, justifyContent: 'center' }, + topLabel: { fontSize: 15 }, }); diff --git a/apps/mobile/src/components/ask-chat-list.tsx b/apps/mobile/src/components/ask-chat-list.tsx new file mode 100644 index 000000000..3709ba3d4 --- /dev/null +++ b/apps/mobile/src/components/ask-chat-list.tsx @@ -0,0 +1,178 @@ +import { useFocusEffect } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Card } from './card'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { fetchChats, type ChatSummary } from '@/lib/chats'; +import { formatDate } from '@/lib/dates'; +import { useCopyHolder } from '@/lib/use-copy-holder'; +import { useTheme } from '@/theme/use-theme'; + +/** Saved chats as rows, with their loading, failed and empty states. */ +export function ChatRows({ + chats, + failed, + openChatId, + onPick, + highlightOpen = false, +}: { + chats: ChatSummary[] | null; + failed: boolean; + openChatId: string | null; + onPick: (chatId: string) => void; + /** The iPad list marks the open chat with a tinted row instead of a check. */ + highlightOpen?: boolean; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (failed && !chats) { + return ( + + {t('mobile.ask.historyFailed')} + + ); + } + if (chats === null) return ; + if (chats.length === 0) { + return ( + + + {t('askChat.noSavedChats')} + + + {t('askChat.noSavedChatsDesc')} + + + ); + } + return ( + + {chats.map((chat, index) => { + const open = chat.id === openChatId; + return ( + onPick(chat.id)} + testID="ask-chat-row" + style={({ pressed }) => [ + styles.row, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + highlightOpen && open && { backgroundColor: colors.tile }, + pressed && styles.pressed, + ]}> + + + {chat.title} + + + {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} + + + {open && !highlightOpen ? : null} + + ); + })} + + ); +} + +/** + * Ask's conversations beside the chat on iPad (mock A, like Messages): the + * Current Debate's saved chats, or the unsorted ones in one-off mode. A list + * from another account or debate is never shown while the next one loads. + */ +export function AskChatPane({ + debateId, + openChatId, + reloadKey, + width, + onBack, + onPick, + onNewChat, +}: { + debateId: string | null; + openChatId: string | null; + /** Changes when the open chat is saved, so its row and count show up. */ + reloadKey: string; + width: number; + onBack: () => void; + onPick: (chatId: string) => void; + onNewChat: () => void; +}) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const identity = userId ? `${userId}:${debateId ?? ''}` : null; + const [state, setState] = useState<{ identity: string | null; chats: ChatSummary[] | null; failed: boolean }>({ identity: null, chats: null, failed: false }); + const copies = useCopyHolder(); + + // Leaving Ask drops the saved copies this list is showing, the same as the phone's history sheet. + useFocusEffect( + useCallback(() => { + if (!userId) return; + // A new reloadKey means the open chat was saved, so this list loads again. + void reloadKey; + const key = `${userId}:${debateId ?? ''}`; + let active = true; + fetchChats(userId, debateId, copies.mark()) + .then((chats) => { + if (active) setState({ identity: key, chats, failed: false }); + }) + .catch((cause: unknown) => { + console.warn('Could not load chats:', cause instanceof Error ? cause.message : cause); + // A reload that fails keeps the list it already shows for this debate. + if (active) setState((previous) => ({ identity: key, chats: previous.identity === key ? previous.chats : null, failed: true })); + }); + return () => { + active = false; + copies.release(); + }; + }, [userId, debateId, reloadKey, copies]), + ); + + const mine = state.identity === identity; + return ( + + + + + + + + + + + + {t('nav.ask')} + + + + + ); +} + +const styles = StyleSheet.create({ + body: { fontSize: 15, lineHeight: 21 }, + loading: { marginTop: 24 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, + rowText: { flex: 1, gap: 3 }, + rowTitle: { fontSize: 16, lineHeight: 21 }, + meta: { fontSize: 14 }, + pressed: { opacity: 0.6 }, + pane: { borderRightWidth: StyleSheet.hairlineWidth }, + paneTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingBottom: 4 }, + iconButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + paneContent: { paddingHorizontal: 18, gap: 14 }, + paneTitle: { fontSize: 32, lineHeight: 38, letterSpacing: -0.9 }, +}); diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx index 3d5e95ff6..50f2fafe6 100644 --- a/apps/mobile/src/components/ask-history.tsx +++ b/apps/mobile/src/components/ask-history.tsx @@ -1,17 +1,15 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Modal, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ChatRows } from './ask-chat-list'; import { PrimaryButton, TextButton } from './buttons'; -import { Card } from './card'; -import { Icon } from './icon'; import { Text } from './text'; import { useAuth } from '@/lib/auth'; import { fetchChats, type ChatSummary } from '@/lib/chats'; import { useCopyHolder } from '@/lib/use-copy-holder'; -import { formatDate } from '@/lib/dates'; import { useTheme } from '@/theme/use-theme'; /** Past Ask chats in this debate (or the unsorted ones in one-off mode), in a native page sheet. */ @@ -73,43 +71,7 @@ export function AskHistory({ - {failed ? ( - - {t('mobile.ask.historyFailed')} - - ) : chats === null ? ( - - ) : chats.length === 0 ? ( - - - {t('askChat.noSavedChats')} - - - {t('askChat.noSavedChatsDesc')} - - - ) : ( - - {chats.map((chat, index) => ( - onPick(chat.id)} - style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> - - - {chat.title} - - - {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} - - - {chat.id === openChatId ? : null} - - ))} - - )} + ); @@ -119,12 +81,4 @@ const styles = StyleSheet.create({ content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, - body: { fontSize: 15, lineHeight: 21 }, - loading: { marginTop: 24 }, - list: { padding: 0, gap: 0 }, - row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, - rowText: { flex: 1, gap: 3 }, - rowTitle: { fontSize: 16, lineHeight: 21 }, - meta: { fontSize: 14 }, - pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/components/case-document.tsx b/apps/mobile/src/components/case-document.tsx index 01a80fbda..beed4e20e 100644 --- a/apps/mobile/src/components/case-document.tsx +++ b/apps/mobile/src/components/case-document.tsx @@ -136,7 +136,7 @@ export function CaseDocument({ ) : null} - + {t('generateResults.burdenOfProof')} diff --git a/apps/mobile/src/components/claim-panel.tsx b/apps/mobile/src/components/claim-panel.tsx new file mode 100644 index 000000000..d9c890713 --- /dev/null +++ b/apps/mobile/src/components/claim-panel.tsx @@ -0,0 +1,322 @@ +import { + canDeleteCard, + canLink, + cardLinks, + deleteCard, + editCard, + findNode, + flowNodes, + linkCards, + unlinkCards, + type FlowEdgeKind, + type FlowNodeType, +} from '@debatelab/core/flowchart'; +import { useState, type ComponentType, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; + +import { ActionMenu } from './action-menu'; +import { TextButton } from './buttons'; +import { Chip } from './chip'; +import { CardForm } from './flowchart-card-form'; +import { useFlowColors } from './flowchart-views'; +import { Icon } from './icon'; +import { SegmentedControl } from './segmented-control'; +import { Text } from './text'; + +import { confirmDestructive } from '@/lib/confirm'; +import { findDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; +import { cardKind, linkLine, linkSummary, typeLabel } from '@/lib/flowchart-text'; +import { useHeaderCover } from '@/lib/layout'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +const TYPES: FlowNodeType[] = ['contention', 'attack', 'rebuttal', 'dropped']; + +/** The kind a new link starts on: what this card's type usually does. */ +function defaultKind(type: FlowNodeType): FlowEdgeKind { + return type === 'attack' ? 'attack' : type === 'rebuttal' ? 'rebuttal' : 'support'; +} + +/** The scroll view the claim sits in: the form sheet's on a phone, the docked inspector's on iPad. */ +export type ClaimContainer = ComponentType<{ children: ReactNode; testID?: string }>; + +/** + * A card's claim, as mock A: its type, text and links, and the website's + * actions on a node: link it, edit it, change its type, or delete it. Every + * change lands in the map on screen at once and stays unsaved until Save. + * A phone shows it in a form sheet; iPad docks it beside the map (mock C), + * where the inspector's own header closes it. Mount it with a key per card. + */ +export function ClaimPanel({ + scope, + map, + nodeId, + container: Container, + onClose, + docked = false, +}: { + scope: string; + map: string; + nodeId: string; + container: ClaimContainer; + onClose: () => void; + docked?: boolean; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const flow = useFlowColors(); + useFlowchartDrafts(); + // `map` is the map's handle, so a save that changes its key while the sheet is open keeps it. + const held = scope && map ? findDraft(scope, map) : null; + const draft = held?.draft; + const node = draft && nodeId ? findNode(draft.doc, nodeId) : undefined; + const [mode, setMode] = useState<'view' | 'link' | 'edit'>('view'); + const [kind, setKind] = useState(() => defaultKind(node?.data.type ?? 'contention')); + const [typeMenu, setTypeMenu] = useState(false); + + if (!held || !draft || !node) { + return ( + + + {t('mobile.flowchart.cardGone')} + + + + ); + } + + // Found again when it runs: a save while a confirm was open may have moved the map to a new key. + const change = (edit: Parameters[2]) => { + const now = findDraft(scope, map); + if (now) updateDraft(scope, now.key, edit); + }; + const { responds, answeredBy } = cardLinks(draft.doc, node.id); + const links = [...responds.map((link) => ({ link, direction: 'responds' as const })), ...answeredBy.map((link) => ({ link, direction: 'answeredBy' as const }))]; + const deletable = canDeleteCard(node.id); + const isThesis = node.data.type === 'thesis'; + + const remove = async () => { + const confirmed = await confirmDestructive({ + title: t('flowchart.deleteNodeTitle'), + message: t('flowchart.deleteNodeDescription', { label: node.data.label }), + confirm: t('flowchart.deleteNodeConfirm'), + cancel: t('flowchart.deleteNodeCancel'), + }); + if (!confirmed) return; + change((current) => ({ ...current, doc: deleteCard(current.doc, node.id), edited: true })); + onClose(); + }; + + const header = ( + + + + {cardKind(t, node)} + + + {node.data.label} + + + {docked ? null : } + + ); + + if (mode === 'edit') { + return ( + + {header} + + {t('flowchart.editNode')} + + { + change((current) => ({ ...current, doc: editCard(current.doc, node.id, fields), edited: true })); + setMode('view'); + }} + /> + setMode('view')} /> + + ); + } + + if (mode === 'link') { + const others = flowNodes(draft.doc).filter((other) => other.id !== node.id); + return ( + + {header} + + + {t('mobile.flowchart.linkTitle')} + + + {t('mobile.flowchart.linkHint')} + + + + + {others.map((other, index) => { + const allowed = canLink(draft.doc, other.id, node.id); + return ( + { + change((current) => ({ ...current, doc: linkCards(current.doc, other.id, node.id, kind), edited: true })); + setMode('view'); + }} + style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed, !allowed && styles.disabled]} + testID="link-target"> + + + + {other.data.label} + + + {allowed ? typeLabel(t, other.data.type) : t('mobile.flowchart.alreadyLinked')} + + + + ); + })} + + setMode('view')} /> + + ); + } + + return ( + + {header} + {node.data.description ? ( + + {node.data.description} + + ) : null} + + {linkSummary(t, draft.doc, node)} + + + setMode('link')} testID="claim-link" /> + setMode('edit')} testID="claim-edit" /> + {isThesis ? null : setTypeMenu(true)} testID="claim-type" />} + + {links.length > 0 ? ( + + + {t('mobile.flowchart.linksTitle')} + + + {links.map(({ link, direction }, index) => { + const line = linkLine(t, link.kind, direction, link.other); + return ( + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> + + {line} + change((current) => ({ ...current, doc: unlinkCards(current.doc, link.edgeId), edited: true }))} + style={({ pressed }) => [styles.remove, pressed && styles.pressed]} + testID="remove-link"> + + + + ); + })} + + + ) : null} + {deletable ? void remove()} /> : null} + type !== node.data.type).map((type) => ({ key: type, label: typeLabel(t, type) }))} + cancelLabel={t('common.cancel')} + onClose={() => setTypeMenu(false)} + onSelect={(type) => + change((current) => ({ + ...current, + doc: editCard(current.doc, node.id, { + label: node.data.label, + description: node.data.description ?? '', + type: type as FlowNodeType, + strength: node.data.strength ?? 'moderate', + }), + edited: true, + })) + } + /> + + ); +} + +/** The claim's scroll view in the docked inspector. */ +function DockScrollView({ children, testID }: { children: ReactNode; testID?: string }) { + return ( + + {children} + + ); +} + +/** + * The claim docked beside the map on iPad (iPad mock C), in place of the form + * sheet. It stays open while the student moves between cards; each card gets + * a fresh panel, so a half-made link or edit never carries over. + */ +export function ClaimInspector({ claim, width, onClose }: { claim: { scope: string; map: string; node: string }; width: number; onClose: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const covered = useHeaderCover(); + return ( + + + + {t('export.claim')} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + inspector: { flexGrow: 0, flexShrink: 0, borderLeftWidth: StyleSheet.hairlineWidth }, + inspectorHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingLeft: 20, paddingRight: 12, minHeight: 50, borderBottomWidth: StyleSheet.hairlineWidth }, + inspectorTitle: { fontSize: 17 }, + dockContent: { paddingHorizontal: 20, paddingTop: 18, paddingBottom: 32, gap: 18 }, + top: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, + topText: { flex: 1, gap: 4 }, + who: { fontSize: 14 }, + title: { fontSize: 21, lineHeight: 27, letterSpacing: -0.3 }, + body: { fontSize: 15.5, lineHeight: 22 }, + section: { fontSize: 16 }, + block: { gap: 8 }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 11, minHeight: 50 }, + rowText: { flex: 1, gap: 2 }, + rowLabel: { fontSize: 15.5, lineHeight: 21 }, + rowMeta: { fontSize: 13, lineHeight: 17 }, + dot: { width: 10, height: 10, borderRadius: 3 }, + remove: { minWidth: 32, minHeight: 32, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.7 }, + disabled: { opacity: 0.45 }, +}); diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx index 1d6d6d612..1af7be7ef 100644 --- a/apps/mobile/src/components/document.tsx +++ b/apps/mobile/src/components/document.tsx @@ -1,5 +1,5 @@ -import { Children, Fragment, isValidElement, useState, type ReactNode } from 'react'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Children, Fragment, isValidElement, useRef, useState, type ReactNode } from 'react'; +import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Chip, CiteBadge } from './chip'; @@ -7,8 +7,10 @@ import type { CitationTarget } from './citation-target'; import { Icon } from './icon'; import { MarkdownView } from './markdown-view'; import { OfflineNotice } from './offline-notice'; +import { OutlineProvider, OutlineRail, useOutlineAnchor, useOutlineItems, useOutlineRail } from './outline'; import { Text } from './text'; +import { RAIL_MIN_WIDTH, RAIL_WIDTH, useHeaderCover, useLayoutClass } from '@/lib/layout'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -30,6 +32,7 @@ export function DocumentScreen({ actionWidths, notice, testID, + outline = false, }: { header: ReactNode; children: ReactNode; @@ -38,10 +41,21 @@ export function DocumentScreen({ actionWidths?: number[]; notice?: string; testID?: string; + /** A saved document: on a wide iPad window its headed parts are listed in a rail beside it (iPad mock A). */ + outline?: boolean; }) { const { colors } = useTheme(); const insets = useSafeAreaInsets(); + const layout = useLayoutClass(); + const { width } = useWindowDimensions(); const bottom = Math.max(insets.bottom - 6, 14); + if (outline && layout !== 'phone' && width >= RAIL_MIN_WIDTH) { + return ( + + {children} + + ); + } return ( {header} @@ -72,6 +86,69 @@ export function DocumentScreen({ ); } +/** The document with its outline rail on the left, the readable column beside it. */ +function RailDocument({ + header, + children, + actions, + actionWidths, + notice, + testID, +}: { + header: ReactNode; + children: ReactNode; + actions?: ReactNode; + actionWidths?: number[]; + notice?: string; + testID?: string; +}) { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const bottom = Math.max(insets.bottom - 6, 14); + const scroll = useRef(null); + const content = useRef(null); + const rail = useOutlineRail(content, scroll, useHeaderCover()); + return ( + + {header} + + + + + + rail.onScroll(event.nativeEvent.contentOffset.y)} + onContentSizeChange={rail.measure}> + + + {children} + + + {notice ? ( + + {notice} + + ) : null} + {actions ? ( + + + {Children.map(unwrapFragment(actions), (action, index) => (action ? {action} : null))} + + + ) : null} + + + + ); +} + function unwrapFragment(node: ReactNode): ReactNode { return isValidElement<{ children?: ReactNode }>(node) && node.type === Fragment ? node.props.children : node; } @@ -94,8 +171,9 @@ export function DocumentTitle({ title, meta }: { title: string; meta?: string }) /** The pale-blue card the document opens with. */ export function FramingCard({ blocks }: { blocks: { label: string; text: string }[] }) { const { colors } = useTheme(); + const anchor = useOutlineAnchor(blocks[0]?.label); return ( - + {blocks.map((block, index) => ( @@ -121,9 +199,11 @@ export interface NumberedItem { /** * Numbered items that open one at a time or together. The first is open. An * open item gets its own card; neighbouring closed items share one, as in the mock. + * With `outline`, each item is listed in the iPad document rail. */ -export function NumberedItems({ items, testID }: { items: NumberedItem[]; testID?: string }) { +export function NumberedItems({ items, testID, outline = false }: { items: NumberedItem[]; testID?: string; outline?: boolean }) { const { colors } = useTheme(); + const anchorFor = useOutlineItems(items, outline); const [open, setOpen] = useState>(() => new Set(items[0] ? [items[0].key] : [])); const toggle = (key: string) => setOpen((previous) => { @@ -150,6 +230,7 @@ export function NumberedItems({ items, testID }: { items: NumberedItem[]; testID {index > 0 ? : null} toggle(item.key)} @@ -229,8 +310,9 @@ export function SourceChips({ chips, onCite }: { chips: CitationTarget[]; onCite } export function SectionHeading({ title, description }: { title: string; description?: string }) { + const anchor = useOutlineAnchor(title); return ( - + {title} @@ -246,8 +328,9 @@ export function SectionHeading({ title, description }: { title: string; descript /** A titled part of the document, in the same card style as the arguments. */ export function DocSection({ title, description, children, testID }: { title: string; description?: string; children: ReactNode; testID?: string }) { const { colors } = useTheme(); + const anchor = useOutlineAnchor(title); return ( - + {title} @@ -329,6 +412,12 @@ const styles = StyleSheet.create({ pinned: { position: 'absolute', left: 0, right: 0, alignItems: 'center' }, pinnedRow: { flexDirection: 'row', gap: 10, width: '100%', maxWidth: 680, paddingHorizontal: 14 }, pinnedItem: { flex: 1 }, + railRow: { flex: 1, flexDirection: 'row' }, + railPane: { width: RAIL_WIDTH, flexGrow: 0, flexShrink: 0 }, + railDocument: { flex: 1 }, + contentRail: { alignSelf: 'flex-start', paddingLeft: 20, paddingRight: 28 }, + pinnedRail: { alignItems: 'flex-start' }, + pinnedRowRail: { paddingLeft: 16, paddingRight: 24 }, pressed: { opacity: 0.7 }, notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, noticeText: { fontSize: 15, lineHeight: 20, textAlign: 'center' }, diff --git a/apps/mobile/src/components/flowchart-views.tsx b/apps/mobile/src/components/flowchart-views.tsx index 2b26ac1a1..e7ddcdbf5 100644 --- a/apps/mobile/src/components/flowchart-views.tsx +++ b/apps/mobile/src/components/flowchart-views.tsx @@ -46,7 +46,7 @@ const SIDE = 16; /** From this width every column fits, as on iPad. */ const WIDE = 600; -export function FlowCard({ node, meta, onPress, testID }: { node: FlowNode; meta: string | null; onPress: () => void; testID?: string }) { +export function FlowCard({ node, meta, onPress, testID, selected = false }: { node: FlowNode; meta: string | null; onPress: () => void; testID?: string; selected?: boolean }) { const { t } = useTranslation(); const { colors } = useTheme(); const flow = useFlowColors(); @@ -54,11 +54,14 @@ export function FlowCard({ node, meta, onPress, testID }: { node: FlowNode; meta [ styles.card, { backgroundColor: flow.cardFill, borderColor: colors.border, borderLeftColor: flow.type[node.data.type] }, + // The card open in the iPad inspector. + selected && { backgroundColor: colors.tile, borderColor: colors.chipSelectedBorder, borderLeftColor: flow.type[node.data.type] }, pressed && styles.pressed, ]}> @@ -98,7 +101,7 @@ export function FlowLegend() { } /** The Flow tab: your case, their attacks and your rebuttals side by side, snapping column by column on a phone. */ -export function FlowColumnsView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void }) { +export function FlowColumnsView({ doc, onOpen, selectedId }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void; selectedId?: string }) { const { t } = useTranslation(); const flow = useFlowColors(); const { width } = useWindowDimensions(); @@ -119,7 +122,7 @@ export function FlowColumnsView({ doc, onOpen }: { doc: FlowchartDocument; onOpe {t('mobile.flowchart.emptyColumn')} ) : ( - column.cards.map((node, index) => onOpen(node)} testID="flow-card" />) + column.cards.map((node, index) => onOpen(node)} testID="flow-card" selected={node.id === selectedId} />) )} )); @@ -141,7 +144,7 @@ const INDENT = 18; const MAX_INDENT = 4; /** The Map tab: from the thesis down through what supports, attacks and rebuts it. */ -export function MapTreeView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void }) { +export function MapTreeView({ doc, onOpen, selectedId }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void; selectedId?: string }) { const { t } = useTranslation(); const flow = useFlowColors(); return ( @@ -153,7 +156,7 @@ export function MapTreeView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: ( {`↳ ${viaLabel(t, via)}`} ) : null} - onOpen(node)} testID="map-card" /> + onOpen(node)} testID="map-card" selected={node.id === selectedId} /> ))} diff --git a/apps/mobile/src/components/outline.tsx b/apps/mobile/src/components/outline.tsx new file mode 100644 index 000000000..a830ad487 --- /dev/null +++ b/apps/mobile/src/components/outline.tsx @@ -0,0 +1,156 @@ +import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; + +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** + * The iPad document outline (mock A's "On this page" rail). Headed parts of a + * document register themselves while a rail is shown; without one they do + * nothing, so the phone document is untouched. + */ + +interface Anchor { + id: string; + label: string; + number?: number; + view: () => View | null; +} + +interface Outline { + register: (anchor: Anchor) => () => void; +} + +const OutlineContext = createContext(null); + +/** A ref for a headed part's root view, or undefined when no rail is shown. */ +export function useOutlineAnchor(label: string | undefined): RefObject | undefined { + const outline = useContext(OutlineContext); + const ref = useRef(null); + const id = useId(); + useEffect(() => { + if (!outline || !label) return; + return outline.register({ id, label, view: () => ref.current }); + }, [outline, label, id]); + return outline ? ref : undefined; +} + +/** Ref callbacks for a list of numbered items, or undefined when no rail is shown. */ +export function useOutlineItems(items: { key: string; title: string }[], enabled: boolean): ((key: string) => (view: View | null) => void) | undefined { + const outline = useContext(OutlineContext); + const views = useRef(new Map()); + const id = useId(); + const active = outline && enabled ? outline : null; + const signature = items.map((item) => `${item.key}\u0000${item.title}`).join('\u0001'); + useEffect(() => { + if (!active) return; + const removals = items.map((item, index) => + active.register({ id: `${id}-${item.key}`, label: item.title, number: index + 1, view: () => views.current.get(item.key) ?? null }), + ); + return () => removals.forEach((remove) => remove()); + // The signature stands for the items' keys and titles. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, id, signature]); + if (!active) return undefined; + return (key) => (view) => { + views.current.set(key, view); + }; +} + +/** + * The rail beside the document and the state behind it. `content` is the view + * that holds the document inside its scroll view; positions are measured from it. + * `covered` is how much of the page's top a translucent bar hides. + */ +export function useOutlineRail(content: RefObject, scroll: RefObject, covered: number) { + const [anchors, setAnchors] = useState>(new Map()); + const [positions, setPositions] = useState>(new Map()); + const [scrollY, setScrollY] = useState(0); + + const outline = useMemo( + () => ({ + register: (anchor) => { + setAnchors((previous) => new Map(previous).set(anchor.id, anchor)); + return () => + setAnchors((previous) => { + const next = new Map(previous); + next.delete(anchor.id); + return next; + }); + }, + }), + [], + ); + + const measure = useCallback(() => { + const root = content.current; + if (!root) return; + anchors.forEach((anchor) => { + const view = anchor.view(); + view?.measureLayout(root, (_x, y) => + setPositions((previous) => (previous.get(anchor.id) === y ? previous : new Map(previous).set(anchor.id, y))), + ); + }); + }, [anchors, content]); + + useEffect(measure, [measure]); + + const entries = [...anchors.values()].sort((a, b) => (positions.get(a.id) ?? Infinity) - (positions.get(b.id) ?? Infinity)); + // The part whose heading has scrolled to the top of the page, or the first. + const current = entries.reduce((found, anchor) => ((positions.get(anchor.id) ?? Infinity) <= scrollY + covered + 32 ? anchor.id : found), entries[0]?.id); + + const jump = (anchor: Anchor) => { + const root = content.current; + const view = anchor.view(); + if (!root || !view) return; + view.measureLayout(root, (_x, y) => scroll.current?.scrollTo({ y: y - covered - 12, animated: true })); + }; + + return { outline, entries, current, measure, jump, onScroll: setScrollY }; +} + +export function OutlineProvider({ outline, children }: { outline: Outline; children: ReactNode }) { + return {children}; +} + +export function OutlineRail({ entries, current, onJump }: { entries: Anchor[]; current: string | undefined; onJump: (anchor: Anchor) => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (entries.length === 0) return null; + return ( + + + {t('mobile.document.onThisPage')} + + {entries.map((anchor) => { + const on = anchor.id === current; + return ( + onJump(anchor)} + style={({ pressed }) => [styles.entry, on && { backgroundColor: colors.tile }, pressed && styles.pressed]}> + + {anchor.number ?? ''} + + + {anchor.label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + rail: { paddingHorizontal: 18, paddingTop: 10, gap: 2 }, + heading: { fontSize: 13.5, marginBottom: 8, marginLeft: 8 }, + entry: { flexDirection: 'row', gap: 8, borderRadius: 10, paddingVertical: 7, paddingHorizontal: 8 }, + number: { width: 14, fontSize: 14, lineHeight: 19 }, + label: { flex: 1, fontSize: 14.5, lineHeight: 19 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/screen.tsx b/apps/mobile/src/components/screen.tsx index a316bbfc6..1b0ab98f4 100644 --- a/apps/mobile/src/components/screen.tsx +++ b/apps/mobile/src/components/screen.tsx @@ -4,17 +4,20 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { OfflineNotice } from './offline-notice'; +import { HOME_MAX_WIDTH } from '@/lib/layout'; + /** * A tab's scrolling content. On iOS the native header and tab bar adjust the - * insets automatically; `safeTop` is for screens without a header. The - * offline notice leads every tab while saved copies are shown. + * insets automatically; `safeTop` is for screens without a header. `wide` is + * an iPad dashboard's width instead of the phone-width column. The offline + * notice leads every tab while saved copies are shown. */ -export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTop?: boolean; gap?: number }) { +export function Screen({ children, safeTop, gap, wide }: { children: ReactNode; safeTop?: boolean; gap?: number; wide?: boolean }) { const insets = useSafeAreaInsets(); return ( + contentContainerStyle={[styles.content, wide && styles.wide, safeTop && { paddingTop: insets.top + 12 }, gap !== undefined && { gap }]}> {children} @@ -22,6 +25,7 @@ export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTo } const styles = StyleSheet.create({ - // Content stays phone-width on iPad until the iPad layouts land. + // Tabs without an iPad layout keep a phone-width column on iPad. content: { paddingHorizontal: 20, paddingBottom: 32, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + wide: { paddingHorizontal: 36, paddingBottom: 48, maxWidth: HOME_MAX_WIDTH }, }); diff --git a/apps/mobile/src/components/source-view.tsx b/apps/mobile/src/components/source-view.tsx new file mode 100644 index 000000000..941208c31 --- /dev/null +++ b/apps/mobile/src/components/source-view.tsx @@ -0,0 +1,132 @@ +import { useFocusEffect } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; + +import { TextButton } from './buttons'; +import { Card, IconTile } from './card'; +import { Icon } from './icon'; +import { SOURCE_ICONS, sourceMeta } from './source-display'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { fetchSource, type Source } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; +import { useTheme } from '@/theme/use-theme'; + +function webUrl(origin: string | null): string | null { + return origin && /^https?:\/\//i.test(origin) ? origin : null; +} + +/** + * A source, read-only: its title, where it came from and its text. The source + * sheet shows it with a Done button; the iPad Library shows it beside the list. + * Mount it with a key per source, so a new selection never shows the last one. + */ +export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [source, setSource] = useState(undefined); + const [failed, setFailed] = useState(false); + const copies = useCopyHolder(); + + // The same read as the source sheet. Leaving the screen drops the saved copy it is showing. + useFocusEffect( + useCallback(() => { + if (!userId) return; + let active = true; + setFailed(false); + fetchSource(userId, id, copies.mark()) + .then((next) => { + if (!active) return; + setSource(next); + setFailed(false); + }) + .catch((cause: unknown) => { + console.warn('Could not load the source:', cause instanceof Error ? cause.message : cause); + if (active) setFailed(true); + }); + return () => { + active = false; + copies.release(); + }; + }, [userId, id, copies]), + ); + + const url = webUrl(source?.origin ?? null); + + return ( + <> + + + {source ? ( + + + + {sourceMeta(t, source)} + + + ) : null} + + {onDone ? : null} + + + {failed ? ( + + {t('mobile.library.loadFailed')} + + ) : source === undefined ? ( + + ) : source === null ? ( + + {t('mobile.library.sourceMissing')} + + ) : ( + <> + + {source.title || t('libraryPage.untitledSource')} + + {url ? ( + void WebBrowser.openBrowserAsync(url)} + style={({ pressed }) => [styles.origin, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> + + + {url.replace(/^https?:\/\/(www\.)?/i, '')} + + + + ) : source.origin ? ( + + {source.origin} + + ) : null} + + + {source.content} + + + + )} + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + headerText: { flex: 1 }, + kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, + meta: { fontSize: 14, flexShrink: 1 }, + loading: { marginTop: 32 }, + body: { fontSize: 15, lineHeight: 21 }, + title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, + origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 1, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, + originText: { flex: 1, fontSize: 15 }, + pressed: { opacity: 0.7 }, + text: { fontSize: 16, lineHeight: 24 }, +}); diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index 247f5c034..5b73d1ac3 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -97,6 +97,7 @@ export function SparringScorecardScreen({ return ( ({ OS: 'ios' as string, isPad: false })); + +vi.mock('react-native', () => ({ + Platform: platform, + useWindowDimensions: () => ({ width: 0, height: 0, scale: 1, fontScale: 1 }), +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); + +import { layoutClassFor } from './layout'; + +describe('layoutClassFor', () => { + beforeEach(() => { + platform.OS = 'ios'; + platform.isPad = false; + }); + + it('keeps every iPhone on the phone layout, including a wide landscape window', () => { + expect(layoutClassFor(390, false)).toBe('phone'); + expect(layoutClassFor(844, false)).toBe('phone'); + expect(layoutClassFor(1200, false)).toBe('phone'); + }); + + it('uses the phone layout for a narrow iPad window', () => { + expect(layoutClassFor(699, true)).toBe('phone'); + expect(layoutClassFor(600, true)).toBe('phone'); + }); + + it('splits an iPad window from 700 points and widens it from 1100', () => { + expect(layoutClassFor(700, true)).toBe('regular'); + expect(layoutClassFor(1024, true)).toBe('regular'); + expect(layoutClassFor(1099, true)).toBe('regular'); + expect(layoutClassFor(1100, true)).toBe('wide'); + expect(layoutClassFor(1366, true)).toBe('wide'); + }); + + it('follows the window width in the web preview, where there is no iPad', () => { + platform.OS = 'web'; + expect(layoutClassFor(390, false)).toBe('phone'); + expect(layoutClassFor(820, false)).toBe('regular'); + expect(layoutClassFor(1366, false)).toBe('wide'); + }); +}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts new file mode 100644 index 000000000..c4c7007f8 --- /dev/null +++ b/apps/mobile/src/lib/layout.ts @@ -0,0 +1,62 @@ +import { Platform, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** + * The iPad layouts (plans: iPad mock A, with mock C's docked claim inspector). + * Every width the layouts depend on lives here, so screens ask for a layout + * class instead of checking widths themselves. + * + * - phone: iPhones at any width, and iPad windows under 700 points (Split View, + * Slide Over, small Stage Manager windows). Today's phone layout, unchanged. + * - regular: iPad windows from 700 to 1100 points (portrait, two-thirds Split View). + * - wide: iPad windows from 1100 points (landscape full screen). + */ +export type LayoutClass = 'phone' | 'regular' | 'wide'; + +export const BREAKPOINTS = { regular: 700, wide: 1100 } as const; + +/** A docked pane beside the main content, such as the Flowchart's claim inspector. */ +export const PANE_WIDTH = { regular: 360, wide: 400 } as const; + +/** The list beside a detail pane: the Library's sources and Ask's conversations. */ +export const LIST_WIDTH = { library: 340, libraryRoomy: 380, ask: 320 } as const; +/** The Library list widens once the window has this much room. */ +export const LIBRARY_ROOMY_WIDTH = 960; + +/** The sidebar the web preview draws in the wide layout; iPadOS draws its own. */ +export const SIDEBAR_WIDTH = 300; + +/** The Debate home splits into two columns from this content width. */ +export const HOME_COLUMNS_WIDTH = 760; +export const HOME_MAX_WIDTH = 1120; + +/** Documents show the outline rail from this window width. */ +export const RAIL_MIN_WIDTH = 900; +export const RAIL_WIDTH = 250; + +export function layoutClassFor(width: number, isPad: boolean): LayoutClass { + // An iPhone in landscape can be wider than 700 points; it keeps the phone layout. + if (Platform.OS === 'ios' && !isPad) return 'phone'; + if (width >= BREAKPOINTS.wide) return 'wide'; + if (width >= BREAKPOINTS.regular) return 'regular'; + return 'phone'; +} + +/** The layout class for the current window; it changes with rotation and Split View. */ +export function useLayoutClass(): LayoutClass { + const { width } = useWindowDimensions(); + return layoutClassFor(width, Platform.OS === 'ios' && Platform.isPad); +} + +/** The height of iOS's navigation bar under the status bar. */ +const NAV_BAR_HEIGHT = 52; + +/** + * How much of a screen's top a translucent native header covers, for docked + * panes that sit beside a scroll view instead of in it. The web preview draws + * its bar above the content, so nothing is covered there. + */ +export function useHeaderCover(): number { + const insets = useSafeAreaInsets(); + return Platform.OS === 'ios' ? insets.top + NAV_BAR_HEIGHT : 0; +} diff --git a/apps/mobile/src/lib/open-pane.test.ts b/apps/mobile/src/lib/open-pane.test.ts new file mode 100644 index 000000000..7fcbb8992 --- /dev/null +++ b/apps/mobile/src/lib/open-pane.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { openClaim, openRowId, type PaneClaim } from './open-pane'; + +const claim: PaneClaim & { node: string } = { userId: 'user-1', scope: 'debate-1', map: 'map-1', node: 'card-1' }; +const screen = { userId: 'user-1', scope: 'debate-1', map: 'map-1' }; + +describe('openRowId', () => { + it('keeps the open row while the list is still loading', () => { + expect(openRowId('source-1', null)).toBe('source-1'); + }); + + it('keeps the open row when the refreshed list still has it', () => { + expect(openRowId('source-1', [{ id: 'source-2' }, { id: 'source-1' }])).toBe('source-1'); + }); + + it('closes the pane once a refresh no longer has the row', () => { + expect(openRowId('source-1', [{ id: 'source-2' }])).toBeNull(); + expect(openRowId('source-1', [])).toBeNull(); + }); + + it('has nothing open when nothing was selected', () => { + expect(openRowId(null, [{ id: 'source-1' }])).toBeNull(); + }); +}); + +describe('openClaim', () => { + it('keeps the docked card for the map on screen', () => { + expect(openClaim(claim, screen)).toBe(claim); + }); + + it('clears the card when the map changes, so returning does not reopen it', () => { + expect(openClaim(claim, { ...screen, map: 'map-2' })).toBeNull(); + expect(openClaim(claim, { ...screen, map: null })).toBeNull(); + }); + + it('clears the card when the debate or the account changes', () => { + expect(openClaim(claim, { ...screen, scope: 'debate-2' })).toBeNull(); + expect(openClaim(claim, { ...screen, scope: null })).toBeNull(); + expect(openClaim(claim, { ...screen, userId: 'user-2' })).toBeNull(); + expect(openClaim(claim, { ...screen, userId: null })).toBeNull(); + }); + + it('stays closed when nothing is docked', () => { + expect(openClaim(null, screen)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/open-pane.ts b/apps/mobile/src/lib/open-pane.ts new file mode 100644 index 000000000..defd1f143 --- /dev/null +++ b/apps/mobile/src/lib/open-pane.ts @@ -0,0 +1,29 @@ +/** + * What an iPad split keeps open beside the main pane. The phone never uses + * these: a source and a claim open in their own sheets there. + */ + +/** A row open beside a list. A refresh that no longer has it closes the pane; a list that is still loading keeps it. */ +export function openRowId(selectedId: string | null, rows: readonly { id: string }[] | null): string | null { + if (!selectedId || rows === null) return selectedId; + return rows.some((row) => row.id === selectedId) ? selectedId : null; +} + +export interface PaneClaim { + userId: string; + scope: string; + map: string; +} + +/** + * A docked claim stays open only while the same account, debate and map are on + * screen. Switching maps clears it, so coming back does not reopen that card. + */ +export function openClaim( + claim: T | null, + screen: { userId: string | null; scope: string | null; map: string | null }, +): T | null { + if (!claim) return null; + if (claim.userId !== screen.userId || claim.scope !== screen.scope || claim.map !== screen.map) return null; + return claim; +} diff --git a/src/locales/en.json b/src/locales/en.json index b41d37fbc..520f3fb9e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2424,6 +2424,7 @@ "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Could not load your debate.", "viewAria": "Show preparation for", + "recentSources": "Recent sources", "seeAll": "See all" }, "common": { @@ -2486,7 +2487,8 @@ "deleteFailed": "Could not delete the source. Try again.", "saveFailed": "Could not save the source. Try again.", "sourceMissing": "This source is no longer in your Library.", - "openPage": "Opens the page in the browser" + "openPage": "Opens the page in the browser", + "pickSource": "Pick a source to read it here." }, "ask": { "placeholder": "Ask your coach", @@ -2645,6 +2647,9 @@ "discardDesc": "Your unsaved map will be replaced by the starter map.", "startNew": "Start new" }, + "document": { + "onThisPage": "On this page" + }, "history": { "loadFailed": "Could not load your history." }, diff --git a/src/locales/es.json b/src/locales/es.json index 52b98c2ba..51e7e9594 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2424,6 +2424,7 @@ "itemMeta": "{{kind}}, {{when}}", "loadFailed": "No se pudo cargar tu debate.", "viewAria": "Mostrar la preparación de", + "recentSources": "Fuentes recientes", "seeAll": "Ver todo" }, "common": { @@ -2486,7 +2487,8 @@ "deleteFailed": "No se pudo eliminar la fuente. Inténtalo de nuevo.", "saveFailed": "No se pudo guardar la fuente. Inténtalo de nuevo.", "sourceMissing": "Esta fuente ya no está en tu biblioteca.", - "openPage": "Abre la página en el navegador" + "openPage": "Abre la página en el navegador", + "pickSource": "Elige una fuente para leerla aquí." }, "ask": { "placeholder": "Pregunta a tu coach", @@ -2645,6 +2647,9 @@ "discardDesc": "Tu mapa sin guardar se sustituirá por el mapa inicial.", "startNew": "Empezar nuevo" }, + "document": { + "onThisPage": "En esta página" + }, "history": { "loadFailed": "No se pudo cargar tu historial." }, diff --git a/src/locales/hu.json b/src/locales/hu.json index 0801e286a..01af05ff8 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2424,6 +2424,7 @@ "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Nem sikerült betölteni a vitát.", "viewAria": "Felkészülés megjelenítése", + "recentSources": "Legutóbbi források", "seeAll": "Összes" }, "common": { @@ -2486,7 +2487,8 @@ "deleteFailed": "Nem sikerült törölni a forrást. Próbáld újra.", "saveFailed": "Nem sikerült menteni a forrást. Próbáld újra.", "sourceMissing": "Ez a forrás már nincs a könyvtáradban.", - "openPage": "Megnyitja az oldalt a böngészőben" + "openPage": "Megnyitja az oldalt a böngészőben", + "pickSource": "Válassz egy forrást, hogy itt elolvasd." }, "ask": { "placeholder": "Kérdezd az edződet", @@ -2645,6 +2647,9 @@ "discardDesc": "A mentetlen térképed helyére a kezdő térkép kerül.", "startNew": "Új kezdése" }, + "document": { + "onThisPage": "Ezen az oldalon" + }, "history": { "loadFailed": "Nem sikerült betölteni az előzményeidet." },