From a2554882af7ea601757963487ac7bc155ff9b47c Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 11:24:03 +0000 Subject: [PATCH 01/14] Show a live run on the Lock Screen and in the Dynamic Island Generate, Attack, Brainstorm and Factoids start a Live Activity while the model writes: Ollie in the step's pose, research, write and check, the parts written and the time since it started. When the app leaves the screen the activity goes stale shortly after and says to open DebateOwl, because iOS suspends the app and the stream with it. A finished run stays on the Lock Screen for a while when the student is away. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/attack/index.tsx | 7 +- apps/mobile/src/app/brainstorm/index.tsx | 7 +- apps/mobile/src/app/factoids/index.tsx | 7 +- apps/mobile/src/app/generate.tsx | 7 +- apps/mobile/src/components/live-run.tsx | 56 +++++- apps/mobile/src/lib/debate-widget.ts | 38 +--- .../src/lib/generating-activity-state.ts | 97 +++++++++ apps/mobile/src/lib/generating-activity.ts | 188 ++++++++++++++++++ .../mobile/src/lib/generating-activity.web.ts | 39 ++++ apps/mobile/src/lib/widget-images.ts | 67 +++++++ .../src/widgets/generating-activity.tsx | 97 +++++++++ 11 files changed, 554 insertions(+), 56 deletions(-) create mode 100644 apps/mobile/src/lib/generating-activity-state.ts create mode 100644 apps/mobile/src/lib/generating-activity.ts create mode 100644 apps/mobile/src/lib/generating-activity.web.ts create mode 100644 apps/mobile/src/lib/widget-images.ts create mode 100644 apps/mobile/src/widgets/generating-activity.tsx diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 355ca57f..4479c3d1 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -70,7 +70,7 @@ export default function AttackScreen() { return next; }), []); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'attack', parts: (draft) => attackDraftSections(draft, t).length }); const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; @@ -132,7 +132,7 @@ export default function AttackScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.caseText), }); // Review choices cover one request, as on the website. resetContextChoices('attack'); @@ -155,6 +155,7 @@ export default function AttackScreen() { if (!isCurrent()) return; const key = keepOpenResult({ kind: 'attack', savedId, title, side: 'neutral', debateId, createdAt: Date.now(), input, output: result, sources: discoveredSources }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + liveRun.complete(); router.replace({ pathname: '/attack/[id]', params: { @@ -169,7 +170,7 @@ export default function AttackScreen() { const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'attack')) return; diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index 42f58cf0..08344fec 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -64,7 +64,7 @@ export default function BrainstormScreen() { return next; }), []); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'brainstorm', parts: (draft) => brainstormDraftSections(draft, t).length }); const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; @@ -113,7 +113,7 @@ export default function BrainstormScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.motion), }); // Review choices cover one request, as on the website. resetContextChoices('brainstorm'); @@ -145,6 +145,7 @@ export default function BrainstormScreen() { sources: discoveredSources, }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + liveRun.complete(); router.replace({ pathname: '/brainstorm/[id]', params: { @@ -160,7 +161,7 @@ export default function BrainstormScreen() { if (!(cause instanceof AIError)) console.warn('Brainstorm failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'brainstorm')) return; diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index 014e544e..d1c72767 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/index.tsx @@ -70,7 +70,7 @@ export default function FactoidsScreen() { return next; }), []); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'factoids', parts: (draft) => factoidsDraftSections(draft, t).length }); const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; @@ -121,7 +121,7 @@ export default function FactoidsScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.claim), }); // Review choices cover one request, as on the website. resetContextChoices('factoids'); @@ -153,6 +153,7 @@ export default function FactoidsScreen() { sources: discoveredSources, }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + liveRun.complete(); router.replace({ pathname: '/factoids/[id]', params: { @@ -167,7 +168,7 @@ export default function FactoidsScreen() { const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Factoids failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'factoids')) return; diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index 8549ce15..25641c08 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -64,7 +64,7 @@ export default function GenerateScreen() { const requests = useRef(0); const abort = useRef(null); const live = useRef(true); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'case', parts: (draft) => generateDraftSections(draft, t).length }); // Dictation fills the motion, as the website's microphone beside it does. The // ref holds words that arrived before the last ones were drawn. const motionRef = useRef(form.motion); @@ -122,7 +122,7 @@ export default function GenerateScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.motion), }); // Review choices cover one request, as on the website. resetContextChoices('generate'); @@ -156,6 +156,7 @@ export default function GenerateScreen() { const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); // The case just made celebrates when its document opens; reopening it later does not. markCelebration(key); + liveRun.complete(); router.replace({ pathname: '/case/[id]', params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) }, @@ -166,7 +167,7 @@ export default function GenerateScreen() { if (!(cause instanceof AIError)) console.warn('Generate failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'generate')) return; diff --git a/apps/mobile/src/components/live-run.tsx b/apps/mobile/src/components/live-run.tsx index a8503e4c..4555e93c 100644 --- a/apps/mobile/src/components/live-run.tsx +++ b/apps/mobile/src/components/live-run.tsx @@ -10,6 +10,9 @@ import { OwlAvatar, useReduceMotion, type OwlPose } from './owl'; import { Text } from './text'; import { ToolProgress } from './tool-form'; +import { beginGeneratingActivity, type GeneratingActivityHandle } from '@/lib/generating-activity'; +import type { GeneratingTool } from '@/lib/generating-activity-state'; +import { noteFinishedResult } from '@/lib/review-prompt'; import { motionSetting, SPRING } from '@/theme/motion'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -27,35 +30,72 @@ export interface LiveRunState { draft?: Record; } -/** One screen's live run: `begin` hands the request its updates; a later `begin` or `end` silences them. */ -export function useLiveRun() { +/** What a tool tells its Live Activity: which tool it is and how many parts a draft has. */ +export interface LiveRunActivity { + tool: GeneratingTool; + parts: (draft: Record) => number; +} + +/** + * One screen's live run: `begin` hands the request its updates; a later + * `begin` or `end` silences them. With `activity`, the run also shows on the + * Lock Screen and in the Dynamic Island (generating-activity.ts): `complete` + * marks it ready, `end('failed')` as failed, and any other end takes it away. + */ +export function useLiveRun(activity?: LiveRunActivity) { const [state, setState] = useState({}); const run = useRef(0); const live = useRef(true); + const lockScreen = useRef(null); + const activityRef = useRef(activity); + activityRef.current = activity; useEffect(() => () => { live.current = false; + lockScreen.current?.finish('cancelled'); }, []); - const begin = useCallback((): LiveGeneration => { + const begin = useCallback((title?: string): LiveGeneration => { const id = ++run.current; setState({}); + lockScreen.current?.finish('cancelled'); + const tool = activityRef.current; + const handle = tool && title ? beginGeneratingActivity(tool.tool, title) : null; + lockScreen.current = handle; + let phase: LivePhase | undefined; + let parts = 0; const current = () => live.current && run.current === id; return { onDraft: (draft) => { - if (current()) setState((previous) => ({ ...previous, phase: 'writing', draft })); + if (!current()) return; + setState((previous) => ({ ...previous, phase: 'writing', draft })); + phase = 'writing'; + parts = activityRef.current?.parts(draft) ?? parts; + handle?.update({ phase, parts }); }, - onPhase: (phase) => { - if (current()) setState((previous) => ({ ...previous, phase })); + onPhase: (next) => { + if (!current()) return; + setState((previous) => ({ ...previous, phase: next })); + phase = next; + handle?.update({ phase, parts }); }, }; }, []); - const end = useCallback(() => { + const end = useCallback((outcome: 'failed' | 'cancelled' = 'cancelled') => { run.current += 1; + lockScreen.current?.finish(outcome); + lockScreen.current = null; if (live.current) setState({}); }, []); - return { state, begin, end }; + /** The answer arrived: the Lock Screen says it is ready. The screen moves on to the document itself. */ + const complete = useCallback(() => { + lockScreen.current?.finish('done'); + lockScreen.current = null; + noteFinishedResult(); + }, []); + + return { state, begin, end, complete }; } /** Whole seconds since the run started, as the website's useElapsedSeconds. */ diff --git a/apps/mobile/src/lib/debate-widget.ts b/apps/mobile/src/lib/debate-widget.ts index 15c9f1d0..06c28038 100644 --- a/apps/mobile/src/lib/debate-widget.ts +++ b/apps/mobile/src/lib/debate-widget.ts @@ -1,13 +1,10 @@ -import { requireOptionalNativeModule } from 'expo'; -import { File } from 'expo-file-system'; -import { Image } from 'react-native'; - import { palette } from '@/theme/colors'; import type { CurrentDebateWidgetProps, WidgetColors } from '@/widgets/current-debate'; import type { DebateCounts } from './debates'; import i18n from './i18n'; import { nativeCapabilities } from './native-capabilities'; +import { widgetOwl } from './widget-images'; /** * What the Current Debate's Home Screen widget shows: the motion and the @@ -19,15 +16,10 @@ import { nativeCapabilities } from './native-capabilities'; */ export type CurrentDebateSnapshot = { debateId?: string; motion: string; counts: DebateCounts | null } | null; -type WidgetsModule = { widgetsDirectory?: string | null }; type CurrentDebateWidget = { updateSnapshot: (props: CurrentDebateWidgetProps) => void }; -const OWL_FILE = 'owl-mark.png'; -const MARK = require('@/assets/owl/mark.png'); - let enabled: boolean | undefined; let widget: Promise | undefined; -let owl: Promise | undefined; let latest: CurrentDebateSnapshot | undefined; let known: { debateId?: string; motion: string; counts: DebateCounts } | null = null; let written: string | null = null; @@ -68,8 +60,7 @@ function sameDebate(a: { debateId?: string; motion: string }, b: { debateId?: st async function write(snapshot: CurrentDebateSnapshot): Promise { try { widget ??= loadWidget(); - owl ??= copyOwl(); - const [target, owlUri] = await Promise.all([widget, owl]); + const [target, owlUri] = await Promise.all([widget, widgetOwl('mark')]); // A newer publish landed while this one waited; it writes instead. if (!target || snapshot !== latest) return; const props = widgetProps(snapshot, owlUri); @@ -93,31 +84,6 @@ async function loadWidget(): Promise { } } -/** - * The widget extension cannot read the app's bundle, so the owl mark is - * copied into the App Group folder once. Without it the widget shows a symbol. - */ -async function copyOwl(): Promise { - try { - const directory = requireOptionalNativeModule('ExpoWidgets')?.widgetsDirectory; - if (!directory) return null; - const target = new File(directory, OWL_FILE); - if (target.exists) return target.uri; - const source = Image.resolveAssetSource(MARK)?.uri; - if (!source) return null; - // A development build serves assets from Metro; a release build has them on disk. - if (/^https?:/i.test(source)) { - await File.downloadFileAsync(source, target, { idempotent: true }); - } else { - new File(source).copy(target); - } - return target.uri; - } catch (cause) { - console.warn('Could not give the widget the owl mark:', cause instanceof Error ? cause.message : cause); - return null; - } -} - function colorsFor(scheme: 'light' | 'dark'): WidgetColors { const colors = palette[scheme]; return { diff --git a/apps/mobile/src/lib/generating-activity-state.ts b/apps/mobile/src/lib/generating-activity-state.ts new file mode 100644 index 00000000..803442fb --- /dev/null +++ b/apps/mobile/src/lib/generating-activity-state.ts @@ -0,0 +1,97 @@ +import type { LivePhase } from '@debatelab/core/ai/live'; + +/** + * What the generating Live Activity shows (src/widgets/generating-activity.tsx). + * The layout cannot reach i18n or the app's assets, so every word and each + * owl's file comes in here, in the app's language. + */ + +/** The four Prepare tools that write live. */ +export type GeneratingTool = 'case' | 'attack' | 'brainstorm' | 'factoids'; + +export type GeneratingStatus = 'working' | 'done' | 'failed'; + +export interface GeneratingActivityState { + /** The tool's name: "Generate", "Attack", "Brainstorm", "Factoids". */ + tool: string; + /** The motion or case it works on, as the student wrote it. */ + title: string; + status: GeneratingStatus; + /** The step the run is on. Before the server names one it is research, the step every run starts with. */ + phase: LivePhase; + /** The app's own line for that step ("Researching sources…", "Writing your case…"). */ + phaseLabel: string; + /** "3 parts written", or null before the first part. */ + partsLabel: string | null; + /** When the run started, in epoch ms: the layout counts the time since then. */ + startedAt: number; + labels: { done: string; failed: string; waiting: string }; + /** Ollie's pose for each step and outcome, as file URIs in the App Group folder; null draws a symbol. */ + owls: Record; +} + +type Translate = (key: string, options?: Record) => string; + +const TOOL_NAMES: Record = { + case: 'nav.generate', + attack: 'nav.attack', + brainstorm: 'nav.brainstorm', + factoids: 'nav.factoids', +}; + +// The tool's own line while it works, as the screen shows before the model names a step. +const WORKING: Record = { + case: 'generate.working', + attack: 'attack.working', + brainstorm: 'brainstorm.working', + factoids: 'factoids.working', +}; + +const READY: Record = { + case: 'mobile.liveActivity.caseReady', + attack: 'mobile.liveActivity.attackReady', + brainstorm: 'mobile.liveActivity.brainstormReady', + factoids: 'mobile.liveActivity.factoidsReady', +}; + +/** The same lines as the screen's live draft (components/live-run.tsx), so the Lock Screen never says something else. */ +export function phaseLabel(tool: GeneratingTool, phase: LivePhase | undefined, t: Translate): string { + if (phase === 'research') return t('tools.live.researching'); + if (phase === 'checking') return t('tools.live.checking'); + if (phase === 'writing') return t('tools.live.writing'); + return t(WORKING[tool]); +} + +export interface GeneratingProgress { + status: GeneratingStatus; + phase?: LivePhase; + /** Sections of the draft written so far. */ + parts: number; +} + +/** The activity's state for one moment of a run. */ +export function generatingActivityState( + tool: GeneratingTool, + title: string, + startedAt: number, + progress: GeneratingProgress, + owls: GeneratingActivityState['owls'], + t: Translate, +): GeneratingActivityState { + return { + tool: t(TOOL_NAMES[tool]), + // The Lock Screen has one line for it; the layout truncates the rest. + title: title.replace(/\s+/g, ' ').trim().slice(0, 160), + status: progress.status, + phase: progress.phase ?? 'research', + phaseLabel: phaseLabel(tool, progress.phase, t), + partsLabel: progress.parts > 0 ? t('mobile.liveActivity.parts', { count: progress.parts }) : null, + startedAt, + labels: { + done: t(READY[tool]), + failed: t('mobile.liveActivity.failed'), + waiting: t('mobile.liveActivity.waiting'), + }, + owls, + }; +} diff --git a/apps/mobile/src/lib/generating-activity.ts b/apps/mobile/src/lib/generating-activity.ts new file mode 100644 index 00000000..c87ed3f3 --- /dev/null +++ b/apps/mobile/src/lib/generating-activity.ts @@ -0,0 +1,188 @@ +import type { LivePhase } from '@debatelab/core/ai/live'; +import type { LiveActivity, LiveActivityDismissalPolicy } from 'expo-widgets'; +import { AppState } from 'react-native'; + +import { + generatingActivityState, + type GeneratingActivityState, + type GeneratingProgress, + type GeneratingTool, +} from './generating-activity-state'; +import i18n from './i18n'; +import { nativeCapabilities } from './native-capabilities'; +import { widgetOwls } from './widget-images'; + +// The Live Activity for a case, attack, brainstorm or factoids run (Lock +// Screen and Dynamic Island). Like the timer's (live-activity.ts) it needs the +// development build with an App Group, so everywhere else, Expo Go included, +// every call here does nothing and the layout module is never loaded. +// +// iOS suspends the app a few seconds after it leaves the screen, and the +// answer's stream stops with it; the run itself goes on on the server, which +// keeps the answer in ai_run_results for the app to read back when it is +// opened again (ai-request.ts). So when the app leaves the screen during a run +// the activity is given a stale date shortly after: if the app is still +// suspended then, the layout says to open DebateOwl instead of a step that is +// no longer moving. + +type LayoutModule = typeof import('@/widgets/generating-activity'); + +/** How long after leaving the screen the activity stops claiming progress. iOS gives a backgrounded app about this long. */ +const STALE_AFTER_BACKGROUND_MS = 30_000; +/** How long a finished or failed run stays on the Lock Screen when the student is not in the app to see it. */ +const KEEP_FINISHED_MS = 15 * 60_000; + +const POSES = { research: 'reading', writing: 'point', checking: 'skeptical', done: 'celebrating', failed: 'surprised' } as const; + +let layouts: LayoutModule | null | undefined; +let current: Run | null = null; +let watchingAppState = false; + +function load(): LayoutModule | null { + if (layouts !== undefined) return layouts; + layouts = null; + if (!nativeCapabilities().liveActivity) return null; + try { + // Loaded on first use: its top level registers the layout with the extension. + // eslint-disable-next-line @typescript-eslint/no-require-imports + layouts = require('@/widgets/generating-activity') as LayoutModule; + } catch (cause) { + console.warn('The generating Live Activity is unavailable:', cause instanceof Error ? cause.message : cause); + } + return layouts; +} + +function warn(what: string, cause: unknown) { + console.warn(`Could not ${what} the generating Live Activity:`, cause instanceof Error ? cause.message : cause); +} + +export interface GeneratingActivityHandle { + /** The step and how many parts are written. Unchanged values are not sent again. */ + update(progress: { phase?: LivePhase; parts: number }): void; + /** + * Ends the activity. A finished or failed run stays on the Lock Screen for a + * while when the app is not on screen, so the student sees it is ready; + * otherwise, and after a cancel, it goes at once. + */ + finish(outcome: 'done' | 'failed' | 'cancelled'): void; +} + +const NOOP: GeneratingActivityHandle = { update: () => {}, finish: () => {} }; + +class Run implements GeneratingActivityHandle { + private activity: LiveActivity | null = null; + private progress: GeneratingProgress = { status: 'working', parts: 0 }; + private sent = ''; + private ended = false; + private readonly startedAt = Date.now(); + private owls: GeneratingActivityState['owls'] | null = null; + + constructor( + private readonly module: LayoutModule, + private readonly tool: GeneratingTool, + private readonly title: string, + ) { + void widgetOwls(Object.values(POSES)).then((uris) => { + this.owls = { + research: uris.reading, + writing: uris.point, + checking: uris.skeptical, + done: uris.celebrating, + failed: uris.surprised, + }; + this.start(); + }); + } + + update(progress: { phase?: LivePhase; parts: number }) { + if (this.ended) return; + this.progress = { status: 'working', phase: progress.phase ?? this.progress.phase, parts: progress.parts }; + this.send(); + } + + finish(outcome: 'done' | 'failed' | 'cancelled') { + if (this.ended) return; + this.ended = true; + if (current === this) current = null; + const activity = this.activity; + if (!activity) return; + const away = AppState.currentState !== 'active'; + if (outcome === 'cancelled' || !away || !this.owls) { + activity.end('immediate').catch((cause: unknown) => warn('end', cause)); + return; + } + this.progress = { ...this.progress, status: outcome }; + const policy: LiveActivityDismissalPolicy = { after: new Date(Date.now() + KEEP_FINISHED_MS) }; + activity.end(policy, this.state()).catch((cause: unknown) => warn('end', cause)); + } + + /** The app left the screen or came back: claim progress only while it can still be making it. */ + appStateChanged() { + this.sent = ''; + this.send(); + } + + private state(): GeneratingActivityState { + return generatingActivityState(this.tool, this.title, this.startedAt, this.progress, this.owls!, i18n.t.bind(i18n)); + } + + private start() { + if (this.ended || !this.owls) return; + try { + // No link: tapping the activity opens the app where the student left it, on the tool's screen. + const state = this.state(); + this.activity = this.module.generatingActivity.start(state, undefined, this.staleDate()); + this.sent = JSON.stringify(state); + } catch (cause) { + // Live Activities may be off in Settings; the run goes on without one. + warn('start', cause); + } + } + + private send() { + if (this.ended || !this.activity || !this.owls) return; + const state = this.state(); + const key = JSON.stringify(state); + if (key === this.sent) return; + this.sent = key; + this.activity.update(state, this.staleDate()).catch((cause: unknown) => warn('update', cause)); + } + + private staleDate(): Date | undefined { + return AppState.currentState === 'active' ? undefined : new Date(Date.now() + STALE_AFTER_BACKGROUND_MS); + } +} + +/** + * Starts the Lock Screen activity for a live run, ending the one before it, so + * only the newest run shows. Returns a handle that does nothing where Live + * Activities are not available. + */ +export function beginGeneratingActivity(tool: GeneratingTool, title: string): GeneratingActivityHandle { + const module = load(); + if (!module) return NOOP; + current?.finish('cancelled'); + if (!watchingAppState) { + watchingAppState = true; + AppState.addEventListener('change', () => current?.appStateChanged()); + } + current = new Run(module, tool, title); + return current; +} + +/** + * Ends activities a previous launch left behind. A run does not survive the + * app being closed, so nothing can bring them up to date. Called once from + * the root layout. + */ +export function endLeftoverGeneratingActivities() { + const module = load(); + if (!module) return; + try { + for (const instance of module.generatingActivity.getInstances()) { + instance.end('immediate').catch((cause: unknown) => warn('end', cause)); + } + } catch (cause) { + warn('find', cause); + } +} diff --git a/apps/mobile/src/lib/generating-activity.web.ts b/apps/mobile/src/lib/generating-activity.web.ts new file mode 100644 index 00000000..04580941 --- /dev/null +++ b/apps/mobile/src/lib/generating-activity.web.ts @@ -0,0 +1,39 @@ +import type { LivePhase } from '@debatelab/core/ai/live'; + +import type { GeneratingTool } from './generating-activity-state'; + +/** + * The web preview has no Live Activities. It keeps the last run's steps on + * `window`, so the screenshot script can check what the Lock Screen would + * show. The phone app uses generating-activity.ts. + */ + +type Recorded = { tool: GeneratingTool; title: string; phase?: LivePhase; parts: number; outcome: 'working' | 'done' | 'failed' | 'cancelled' }; + +export interface GeneratingActivityHandle { + update(progress: { phase?: LivePhase; parts: number }): void; + finish(outcome: 'done' | 'failed' | 'cancelled'): void; +} + +function record(next: Recorded) { + (window as unknown as { __debateowlGeneratingActivity?: Recorded }).__debateowlGeneratingActivity = next; +} + +export function beginGeneratingActivity(tool: GeneratingTool, title: string): GeneratingActivityHandle { + let state: Recorded = { tool, title, parts: 0, outcome: 'working' }; + record(state); + return { + update: (progress) => { + if (state.outcome !== 'working') return; + state = { ...state, phase: progress.phase ?? state.phase, parts: progress.parts }; + record(state); + }, + finish: (outcome) => { + if (state.outcome !== 'working') return; + state = { ...state, outcome }; + record(state); + }, + }; +} + +export function endLeftoverGeneratingActivities() {} diff --git a/apps/mobile/src/lib/widget-images.ts b/apps/mobile/src/lib/widget-images.ts new file mode 100644 index 00000000..571f8620 --- /dev/null +++ b/apps/mobile/src/lib/widget-images.ts @@ -0,0 +1,67 @@ +import { requireOptionalNativeModule } from 'expo'; +import { File } from 'expo-file-system'; +import { Image } from 'react-native'; + +/** + * Ollie for the widget extension. The extension cannot read the app's + * bundle, so each owl pose it shows (the Current Debate widget's mark, the + * generating Live Activity's poses) is copied into the App Group folder once + * and drawn from there with `uiImage`. Without the folder (Expo Go, a build + * without DEBATELAB_APP_GROUP) every pose is null and the layouts show a symbol. + */ + +type WidgetsModule = { widgetsDirectory?: string | null }; + +const POSES = { + mark: require('@/assets/owl/mark.png'), + thinking: require('@/assets/owl/thinking.png'), + reading: require('@/assets/owl/reading.png'), + skeptical: require('@/assets/owl/skeptical.png'), + point: require('@/assets/owl/point.png'), + celebrating: require('@/assets/owl/celebrating.png'), + surprised: require('@/assets/owl/surprised.png'), +} as const; + +export type WidgetOwlPose = keyof typeof POSES; + +const copies = new Map>(); + +/** The pose's file URI in the App Group folder, copied there on first use; null when there is no folder or the copy fails. */ +export function widgetOwl(pose: WidgetOwlPose): Promise { + let copy = copies.get(pose); + if (!copy) { + copy = copyPose(pose); + copies.set(pose, copy); + } + return copy; +} + +/** Every pose the layouts may switch between, so a Live Activity never waits on a copy mid-run. */ +export async function widgetOwls

(poses: readonly P[]): Promise> { + const uris = await Promise.all(poses.map((pose) => widgetOwl(pose))); + return Object.fromEntries(poses.map((pose, index) => [pose, uris[index]])) as Record; +} + +async function copyPose(pose: WidgetOwlPose): Promise { + try { + const directory = requireOptionalNativeModule('ExpoWidgets')?.widgetsDirectory; + if (!directory) return null; + // The mark keeps the name the Current Debate widget has always used. + const target = new File(directory, pose === 'mark' ? 'owl-mark.png' : `owl-${pose}.png`); + if (target.exists) return target.uri; + const source = Image.resolveAssetSource(POSES[pose])?.uri; + if (!source) return null; + // A development build serves assets from Metro; a release build has them on disk. + if (/^https?:/i.test(source)) { + await File.downloadFileAsync(source, target, { idempotent: true }); + } else { + new File(source).copy(target); + } + return target.uri; + } catch (cause) { + // Forget the failure, so the next caller tries again. + copies.delete(pose); + console.warn(`Could not give the widgets the ${pose} owl:`, cause instanceof Error ? cause.message : cause); + return null; + } +} diff --git a/apps/mobile/src/widgets/generating-activity.tsx b/apps/mobile/src/widgets/generating-activity.tsx new file mode 100644 index 00000000..b67c47ad --- /dev/null +++ b/apps/mobile/src/widgets/generating-activity.tsx @@ -0,0 +1,97 @@ +import { Capsule, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { font, foregroundStyle, frame, lineLimit, monospacedDigit, padding, resizable } from '@expo/ui/swift-ui/modifiers'; +import { createLiveActivity, type LiveActivityEnvironment } from 'expo-widgets'; + +import type { GeneratingActivityState } from '@/lib/generating-activity-state'; + +// A case, attack, brainstorm or factoids run on the Lock Screen and in the +// Dynamic Island while the model writes it: Ollie in the pose the app's own +// live draft uses, the step it is on, how many parts are written, and the time +// since it started (a SwiftUI timer text, so it counts without the app). +// Like timer-widgets.tsx, the layout runs in the widget extension's own runtime +// ('widget' directive): no imports or outside names, every string in the props. +// +// The app is suspended soon after it leaves the screen, and the stream stops +// with it. The activity then goes stale (generating-activity.ts sets the date), +// and says to open DebateOwl, which picks the answer up from the server. +// +// Only generating-activity.ts loads this file, and only in a build that has +// the widget extension (nativeCapabilities().liveActivity). Expo Go never does. + +const GeneratingActivity = (props: GeneratingActivityState, environment: LiveActivityEnvironment) => { + 'widget'; + const BLUE = '#4CA6FD'; + const MINT = '#2FD39A'; + const CORAL = '#FF6B70'; + const TRACK = '#8E9FB666'; + const waiting = props.status === 'working' && environment.isStale === true; + const accent = props.status === 'done' ? MINT : props.status === 'failed' ? CORAL : BLUE; + const headline = + props.status === 'done' ? props.labels.done : props.status === 'failed' ? props.labels.failed : waiting ? props.labels.waiting : props.phaseLabel; + const symbol = + props.status === 'done' ? 'checkmark.circle.fill' : props.status === 'failed' ? 'exclamationmark.circle.fill' : waiting ? 'hand.tap.fill' : 'sparkles'; + const owlUri = props.status === 'done' ? props.owls.done : props.status === 'failed' ? props.owls.failed : props.owls[props.phase]; + const owl = (size: number) => + owlUri ? ( + + ) : ( + + ); + const elapsed = (size: number) => + props.status === 'working' ? ( + + ) : ( + + ); + // Research, write, check: filled up to the step the run is on; all three when it is done. + const reached = props.status === 'done' ? 3 : props.phase === 'checking' ? 3 : props.phase === 'writing' ? 2 : 1; + const steps = (width: number) => ( + + = 1 ? accent : TRACK)]} /> + = 2 ? accent : TRACK)]} /> + = 3 ? accent : TRACK)]} /> + + ); + const detail = props.status === 'working' && props.partsLabel && !waiting ? `${props.title} · ${props.partsLabel}` : props.title; + + return { + banner: ( + + {owl(52)} + + + {props.tool} + + {elapsed(13)} + + {headline} + {detail} + {props.status === 'failed' || waiting ? null : steps(40)} + + + ), + compactLeading: owlUri ? owl(22) : , + compactTrailing: props.status === 'working' && !waiting ? elapsed(14) : , + minimal: , + expandedLeading: {owl(44)}, + expandedCenter: ( + + {props.tool} + {headline} + + ), + expandedTrailing: {elapsed(15)}, + expandedBottom: ( + + {detail} + {props.status === 'failed' || waiting ? null : steps(88)} + + ), + }; +}; + +export const generatingActivity = createLiveActivity('DebateOwlGenerating', GeneratingActivity); From 70659655c6d0f6797cb72d7f10fe896c993cebf9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 11:24:12 +0000 Subject: [PATCH 02/14] Pause, resume and move on from the timer's Lock Screen, and hear time up From iOS 17 the timer's Live Activity has Pause or Resume and Next speech, which act on the clock it shows as the timer screen's buttons do. When the app leaves the screen with a clock running, a time-sensitive local notification is set for the moment it runs out, because the app's own signals stop when iOS suspends it. Notification permission is asked the first time a clock starts. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/timer.tsx | 3 + apps/mobile/src/components/timer-runner.tsx | 12 ++- apps/mobile/src/lib/live-activity.ts | 28 ++++++- apps/mobile/src/lib/timer-alerts.ts | 89 +++++++++++++++++++++ apps/mobile/src/lib/timer-alerts.web.ts | 6 ++ apps/mobile/src/lib/timer.test.ts | 48 ++++++++++- apps/mobile/src/lib/timer.ts | 55 ++++++++++++- apps/mobile/src/widgets/timer-widgets.tsx | 32 +++++++- 8 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/lib/timer-alerts.ts create mode 100644 apps/mobile/src/lib/timer-alerts.web.ts diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 46bfe658..12706729 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -28,6 +28,7 @@ import { confirmDestructive } from '@/lib/confirm'; import { haptics } from '@/lib/haptics'; import { useLayoutClass } from '@/lib/layout'; import { formatFor, formatName, sideNameFor, speechStatus, type FormatChoice, type TimerSession, type Translate } from '@/lib/timer'; +import { askForTimerAlerts } from '@/lib/timer-alerts'; import { useKeepAwakeWhile } from '@/lib/timer-signals'; import { deleteSavedFormat, updateSession, useTimerStore } from '@/lib/timer-store'; import { useTheme } from '@/theme/use-theme'; @@ -86,6 +87,7 @@ export default function TimerScreen() { }; const toggle = () => { haptics.tap(); + if (!speechRunning) void askForTimerAlerts(); change((current) => ({ ...current, round: toggleSpeech(current.round, Date.now()) })); }; const reset = () => change((current) => ({ ...current, round: resetSpeech(current.round) })); @@ -95,6 +97,7 @@ export default function TimerScreen() { }; const prep = (side: PrepSide) => { haptics.tap(); + if (!prepRunning) void askForTimerAlerts(); change((current) => ({ ...current, round: togglePrep(current.round, side, Date.now()) })); }; const choose = (next: FormatChoice) => change((current) => ({ ...current, choice: next, round: initialRoundState(formatFor(next)) })); diff --git a/apps/mobile/src/components/timer-runner.tsx b/apps/mobile/src/components/timer-runner.tsx index cba9edd8..a1ebcfe9 100644 --- a/apps/mobile/src/components/timer-runner.tsx +++ b/apps/mobile/src/components/timer-runner.tsx @@ -1,14 +1,17 @@ import { cueBetween, elapsedMs, isSpeechRunning, settlePrep } from '@debatelab/core/free-tools/round-timer'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AppState } from 'react-native'; +import { AppState, Platform } from 'react-native'; import { syncTimerActivity } from '@/lib/live-activity'; import { formatFor, isActive, sideNameFor, timerActivityState, type TimerSession } from '@/lib/timer'; +import { useTimerAlerts } from '@/lib/timer-alerts'; import { useTimerSignals } from '@/lib/timer-signals'; import { loadTimer, updateSession, useTimerStore } from '@/lib/timer-store'; const TICK_MS = 250; +/** iOS runs a Live Activity's buttons from iOS 17; before that they would draw and do nothing. */ +const LIVE_ACTIVITY_BUTTONS = Platform.OS === 'ios' && Number.parseInt(String(Platform.Version), 10) >= 17; /** A longer gap between readings means the app was in the background. */ const GAP_MS = 2_000; /** How long the signals stay after the last clock stops, so a time-up signal plays to its end. */ @@ -44,12 +47,19 @@ export function TimerRunner() { paused: tools('timer.paused'), overtime: tools('timer.overtime'), prep: tools('timer.prepGroup'), + pause: tools('timer.pause'), + resume: t('mobile.liveActivity.resume'), + next: t('mobile.liveActivity.next'), ...idle, }, + controls: LIVE_ACTIVITY_BUTTONS, }); syncTimerActivity(state, idle); }, [format, i18n.language, loaded, session, t]); + // A clock that runs out while the phone is locked still says so (timer-alerts.ts). + useTimerAlerts(session, loaded); + const active = loaded && isActive(session.round); const [lingering, setLingering] = useState(false); useEffect(() => { diff --git a/apps/mobile/src/lib/live-activity.ts b/apps/mobile/src/lib/live-activity.ts index 48beb240..26f19f88 100644 --- a/apps/mobile/src/lib/live-activity.ts +++ b/apps/mobile/src/lib/live-activity.ts @@ -1,7 +1,8 @@ import type { LiveActivity } from 'expo-widgets'; import { nativeCapabilities } from './native-capabilities'; -import type { TimerActivityState } from './timer'; +import { applyTimerActivityAction, type TimerActivityAction, type TimerActivityState } from './timer'; +import { updateSession } from './timer-store'; import type { TimerWidgetProps } from '@/widgets/timer-widgets'; @@ -11,6 +12,10 @@ import type { TimerWidgetProps } from '@/widgets/timer-widgets'; // here does nothing and the widgets module is never loaded. type WidgetsModule = typeof import('@/widgets/timer-widgets'); +type ExpoWidgets = typeof import('expo-widgets'); + +/** The Live Activity's buttons (src/widgets/timer-widgets.tsx) by their fixed targets. */ +const BUTTONS: Record = { 'timer-toggle': 'toggle', 'timer-next': 'next' }; let widgets: WidgetsModule | null | undefined; let activity: LiveActivity | null = null; @@ -25,12 +30,33 @@ function load(): WidgetsModule | null { // Loaded on first use: its top level registers the layouts with the extension. // eslint-disable-next-line @typescript-eslint/no-require-imports widgets = require('@/widgets/timer-widgets') as WidgetsModule; + listenForButtons(); } catch (cause) { console.warn('Timer widgets are unavailable:', cause instanceof Error ? cause.message : cause); } return widgets; } +/** + * Pause, Resume and Next on the Lock Screen and in the Dynamic Island. iOS + * runs the button's intent in the app's process, waking it in the background + * if it has to; expo-widgets passes the press on here, and the round changes + * as if the student had pressed the timer screen's own button. The change + * reaches the activity through TimerRunner, like any other. + */ +function listenForButtons() { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { addUserInteractionListener } = require('expo-widgets') as ExpoWidgets; + addUserInteractionListener((event) => { + const action = event.source === 'DebateTimerActivity' ? BUTTONS[event.target] : undefined; + if (action) updateSession((current) => applyTimerActivityAction(current, action, Date.now())); + }); + } catch (cause) { + console.warn("Could not listen for the timer's Live Activity buttons:", cause instanceof Error ? cause.message : cause); + } +} + function warn(what: string, cause: unknown) { console.warn(`Could not ${what} the timer's Live Activity:`, cause instanceof Error ? cause.message : cause); } diff --git a/apps/mobile/src/lib/timer-alerts.ts b/apps/mobile/src/lib/timer-alerts.ts new file mode 100644 index 00000000..72ef2970 --- /dev/null +++ b/apps/mobile/src/lib/timer-alerts.ts @@ -0,0 +1,89 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Notifications from 'expo-notifications'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppState, type AppStateStatus } from 'react-native'; + +import { timerAlertAt, type TimerSession } from './timer'; + +/** + * Time up while the app is away. The timer's own signals (timer-signals.ts) + * need the app running, and iOS suspends it a few seconds after the phone + * locks, so a speech or prep bank that runs out on a locked phone would pass + * in silence. When the app leaves the screen with a clock running, a local + * notification is set for the moment it runs out, with sound unless the timer + * is muted; coming back, or any change to the round, takes it away again. + * Local notifications need no server and work in Expo Go. + * + * It is marked time-sensitive, so it can break through a Focus. That needs the + * Time Sensitive Notifications capability on the App ID; without it iOS + * delivers it as an ordinary notification. + */ + +const ALERT_ID = 'debateowl-timer-up'; +const ASKED_KEY = 'debateowl.timer-alerts-asked'; + +/** + * Asks for notification permission the first time the student starts a clock, + * which is when the reason is plain. Asked once per install; afterwards iOS + * Settings decides. + */ +export async function askForTimerAlerts(): Promise { + try { + if (await AsyncStorage.getItem(ASKED_KEY)) return; + await AsyncStorage.setItem(ASKED_KEY, '1'); + const current = await Notifications.getPermissionsAsync(); + if (current.status !== 'undetermined') return; + await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowSound: true, allowBadge: false } }); + } catch (cause) { + console.warn('Could not ask for timer alerts:', cause instanceof Error ? cause.message : cause); + } +} + +async function allowed(): Promise { + const permissions = await Notifications.getPermissionsAsync(); + return permissions.granted || permissions.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; +} + +/** Keeps the time-up alert in step with the round and with whether the app is on screen. Used by TimerRunner. */ +export function useTimerAlerts(session: TimerSession, loaded: boolean) { + const { t } = useTranslation(); + const [appState, setAppState] = useState(AppState.currentState); + useEffect(() => { + const subscription = AppState.addEventListener('change', setAppState); + return () => subscription.remove(); + }, []); + + useEffect(() => { + if (!loaded) return; + let cancelled = false; + void (async () => { + try { + await Notifications.cancelScheduledNotificationAsync(ALERT_ID); + // On screen the timer sounds its own signals. + if (appState === 'active' || cancelled) return; + const alert = timerAlertAt(session, Date.now()); + if (!alert || !(await allowed()) || cancelled) return; + const content = + alert.kind === 'prep' + ? { title: t('mobile.timerAlert.prepTitle'), body: t('mobile.timerAlert.prepBody') } + : { title: t('mobile.timerAlert.speechTitle', { speech: alert.segment.short }), body: t('mobile.timerAlert.speechBody', { speech: alert.segment.label }) }; + await Notifications.scheduleNotificationAsync({ + identifier: ALERT_ID, + content: { + ...content, + sound: session.muted ? false : 'default', + interruptionLevel: 'timeSensitive', + data: { kind: 'timer' }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(alert.at) }, + }); + } catch (cause) { + console.warn('Could not set the time-up alert:', cause instanceof Error ? cause.message : cause); + } + })(); + return () => { + cancelled = true; + }; + }, [appState, loaded, session, t]); +} diff --git a/apps/mobile/src/lib/timer-alerts.web.ts b/apps/mobile/src/lib/timer-alerts.web.ts new file mode 100644 index 00000000..bfe6cfc7 --- /dev/null +++ b/apps/mobile/src/lib/timer-alerts.web.ts @@ -0,0 +1,6 @@ +import type { TimerSession } from './timer'; + +/** The web preview has no local notifications; the phone app uses timer-alerts.ts. */ +export async function askForTimerAlerts(): Promise {} + +export function useTimerAlerts(_session: TimerSession, _loaded: boolean) {} diff --git a/apps/mobile/src/lib/timer.test.ts b/apps/mobile/src/lib/timer.test.ts index 8d681bb1..0ce31b4b 100644 --- a/apps/mobile/src/lib/timer.test.ts +++ b/apps/mobile/src/lib/timer.test.ts @@ -1,13 +1,22 @@ import { togglePrep, toggleSpeech } from '@debatelab/core/free-tools/round-timer'; import { describe, expect, it } from 'vitest'; -import { initialSession, parseSavedFormats, parseSession, serializeSession, timerActivityState, type TimerSession } from './timer'; +import { + applyTimerActivityAction, + initialSession, + parseSavedFormats, + parseSession, + serializeSession, + timerActivityState, + timerAlertAt, + type TimerSession, +} from './timer'; const T0 = 1_000_000; const text = { sideName: (side: string) => side, prepLine: (side: string) => `prep ${side}`, - labels: { speaking: 'Speaking', paused: 'Paused', overtime: 'Over time', prep: 'Prep', idleTitle: 'Timer', idleLine: 'Idle' }, + labels: { speaking: 'Speaking', paused: 'Paused', overtime: 'Over time', prep: 'Prep', idleTitle: 'Timer', idleLine: 'Idle', pause: 'Pause', resume: 'Resume', next: 'Next' }, }; const custom = { name: 'Club', prepSeconds: 120, segments: [{ label: 'Opening', seconds: 300, kind: 'speech' as const, side: 'a' as const, poi: true }] }; @@ -53,3 +62,38 @@ describe('Live Activity clock', () => { expect(prep).toMatchObject({ kind: 'prep', line: 'prep b', side: 'b', endsAt: T0 + 780_000, pausedAt: null }); }); }); + +describe('Live Activity buttons and the time-up alert', () => { + it('pauses the clock the activity shows: a running prep bank before the speech', () => { + let session = initialSession({ id: 'custom', custom }); + session = { ...session, round: toggleSpeech(session.round, T0) }; + session = { ...session, round: togglePrep(session.round, 'b', T0 + 10_000) }; + const paused = applyTimerActivityAction(session, 'toggle', T0 + 20_000); + expect(paused.round.prep.b.deadline).toBeNull(); + expect(paused.round.speech.startedAt).toBeNull(); + // With no prep running, the same button resumes the speech where it stopped. + const resumed = applyTimerActivityAction(paused, 'toggle', T0 + 30_000); + expect(resumed.round.speech).toEqual({ elapsedMs: 10_000, startedAt: T0 + 30_000 }); + }); + + it('moves on to a fresh, paused next speech, and not past the last one', () => { + let session = initialSession({ id: 'custom', custom }); + expect(applyTimerActivityAction(session, 'next', T0)).toBe(session); + session = initialSession({ id: 'bp' }); + session = { ...session, round: toggleSpeech(session.round, T0) }; + const next = applyTimerActivityAction(session, 'next', T0 + 5_000); + expect(next.round.segmentIndex).toBe(1); + expect(next.round.speech).toEqual({ elapsedMs: 0, startedAt: null }); + expect(timerActivityState(next, T0 + 5_000, { ...text, controls: true })).toBeNull(); + }); + + it('alerts at the limit of the running clock, and never for a paused or finished one', () => { + let session = initialSession({ id: 'custom', custom }); + expect(timerAlertAt(session, T0)).toBeNull(); + session = { ...session, round: toggleSpeech(session.round, T0) }; + expect(timerAlertAt(session, T0 + 60_000)).toMatchObject({ at: T0 + 300_000, kind: 'speech' }); + expect(timerAlertAt(session, T0 + 301_000)).toBeNull(); + session = { ...session, round: togglePrep(session.round, 'a', T0 + 1_000) }; + expect(timerAlertAt(session, T0 + 1_000)).toMatchObject({ at: T0 + 121_000, kind: 'prep' }); + }); +}); diff --git a/apps/mobile/src/lib/timer.ts b/apps/mobile/src/lib/timer.ts index 94a6e200..d3d4c35d 100644 --- a/apps/mobile/src/lib/timer.ts +++ b/apps/mobile/src/lib/timer.ts @@ -11,12 +11,15 @@ import { customToRoundFormat, parseCustomFormat, type CustomFormat } from '@deba import { FORMATS, isBuiltInFormatId, type BuiltInFormatId, type RoundFormat, type RoundSegment } from '@debatelab/core/free-tools/formats'; import { elapsedMs, + goToSegment, initialRoundState, isPrepRunning, isSpeechRunning, parseRoundState, prepRemainingMs, segmentStatus, + togglePrep, + toggleSpeech, type PrepSide, type RoundState, type TimerCue, @@ -147,6 +150,10 @@ export interface TimerActivityLabels { prep: string; idleTitle: string; idleLine: string; + /** The Live Activity's buttons. */ + pause: string; + resume: string; + next: string; } /** @@ -169,6 +176,14 @@ export interface TimerActivityState { /** Side colour: `a`, `b` or `both`. */ side: RoundSegment['side']; labels: TimerActivityLabels; + /** + * Pause or Resume, and Next speech, on the Lock Screen and in the Dynamic + * Island. iOS runs a Live Activity's buttons from iOS 17, so the app turns + * them off before that (the layout cannot ask which iOS it runs on). + */ + controls: boolean; + /** A later speech exists to move on to (never while prep runs). */ + canAdvance: boolean; } /** @@ -179,8 +194,9 @@ export interface TimerActivityState { export function timerActivityState( session: TimerSession, now: number, - text: { sideName: (side: RoundSegment['side']) => string; prepLine: (side: PrepSide) => string; labels: TimerActivityLabels }, + text: { sideName: (side: RoundSegment['side']) => string; prepLine: (side: PrepSide) => string; labels: TimerActivityLabels; controls?: boolean }, ): TimerActivityState | null { + const controls = text.controls === true; const format = formatFor(session.choice); const { round } = session; for (const side of ['a', 'b'] as PrepSide[]) { @@ -196,6 +212,8 @@ export function timerActivityState( pausedAt: null, side, labels: text.labels, + controls, + canAdvance: false, }; } const segment = format.segments[round.segmentIndex] ?? format.segments[0]; @@ -212,9 +230,44 @@ export function timerActivityState( pausedAt: running ? null : now, side: segment.side, labels: text.labels, + controls, + canAdvance: round.segmentIndex < format.segments.length - 1, }; } +/** A button on the timer's Live Activity: Pause or Resume, or Next speech. */ +export type TimerActivityAction = 'toggle' | 'next'; + +/** + * What a Live Activity button does to the round, at `now`. Toggle acts on the + * clock the activity shows: a running prep bank, else the speech. Next moves + * to the next speech with a fresh, paused clock, as the timer screen's Next does. + */ +export function applyTimerActivityAction(session: TimerSession, action: TimerActivityAction, now: number): TimerSession { + const { round } = session; + if (action === 'next') { + const next = goToSegment(round, formatFor(session.choice), round.segmentIndex + 1, now); + return next === round ? session : { ...session, round: next }; + } + const prepSide = (['a', 'b'] as PrepSide[]).find((side) => isPrepRunning(round, side)); + return { ...session, round: prepSide ? togglePrep(round, prepSide, now) : toggleSpeech(round, now) }; +} + +/** When the clock on screen reaches its limit while the app is away, for the time-up alert; null when nothing will. */ +export function timerAlertAt(session: TimerSession, now: number): { at: number; kind: 'speech' | 'prep'; segment: RoundSegment } | null { + const format = formatFor(session.choice); + const { round } = session; + const segment = format.segments[round.segmentIndex] ?? format.segments[0]; + for (const side of ['a', 'b'] as PrepSide[]) { + if (!isPrepRunning(round, side)) continue; + const left = prepRemainingMs(round, side, now); + return left > 0 ? { at: now + left, kind: 'prep', segment } : null; + } + if (!isSpeechRunning(round)) return null; + const left = segment.seconds * 1000 - elapsedMs(round.speech, now); + return left > 0 ? { at: now + left, kind: 'speech', segment } : null; +} + /** The speech's status for the screen: time left (negative in overtime), POI window and grace. */ export function speechStatus(session: TimerSession, now: number) { const format = formatFor(session.choice); diff --git a/apps/mobile/src/widgets/timer-widgets.tsx b/apps/mobile/src/widgets/timer-widgets.tsx index 6af63bae..f4a35eb1 100644 --- a/apps/mobile/src/widgets/timer-widgets.tsx +++ b/apps/mobile/src/widgets/timer-widgets.tsx @@ -1,5 +1,5 @@ -import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; -import { font, foregroundStyle, lineLimit, monospacedDigit, padding } from '@expo/ui/swift-ui/modifiers'; +import { Button, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { buttonStyle, controlSize, font, foregroundStyle, lineLimit, monospacedDigit, padding, tint } from '@expo/ui/swift-ui/modifiers'; import { createLiveActivity, createWidget, type LiveActivityEnvironment, type WidgetEnvironment } from 'expo-widgets'; import type { TimerActivityState } from '@/lib/timer'; @@ -47,6 +47,28 @@ const DebateTimerActivity = (props: TimerActivityState, environment: LiveActivit /> ); const icon = props.kind === 'prep' ? 'hourglass' : props.pausedAt !== null ? 'pause.fill' : 'timer'; + // iOS 17's interactive Live Activities: the app runs the press (live-activity.ts) and sends the new clock back. + // Fixed targets, so the app knows which button it was whatever the layout around them. + const paused = props.pausedAt !== null; + const controls = (spacing: number) => + props.controls ? ( + +