Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions apps/mobile/AGENTS.md

Large diffs are not rendered by default.

34 changes: 27 additions & 7 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins';
// One config for Expo Go, the web export and the development build.
// DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the
// native modules Expo Go lacks. Without it the config is the one Expo Go has
// always used. DEBATELAB_APP_GROUP adds the lock-screen timer's widget
// extension, which needs an App Group: set it only when the provisioning
// profile carries that group. See docs/development.md#development-build.
// always used. DEBATELAB_APP_GROUP adds the timer's widget extension (its Live
// Activity, Dynamic Island clock and lock-screen widget), which needs the App
// Group `group.com.pistack.debateowl`, registered for both
// `com.pistack.debateowl` and `com.pistack.debateowl.ExpoWidgetsTarget`. On a
// device, set it only when the provisioning profile carries that group; a
// simulator build is not signed and always can.
// See docs/development.md#development-build.

type PermissionStrings = { speechRecognition: string; microphone: string };

Expand All @@ -22,8 +26,11 @@ if (appGroup && build !== 'dev') {
throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).');
}

// Kept from the DebateLab name until Adam decides on the store listing: a new id is a new app in App Store Connect.
const bundleIdentifier = 'com.adam.debatelab';
const bundleIdentifier = 'com.pistack.debateowl';
const APP_GROUP = 'group.com.pistack.debateowl';
if (appGroup && appGroup !== APP_GROUP) {
throw new Error(`DEBATELAB_APP_GROUP must be "${APP_GROUP}", not "${appGroup}".`);
}

function permissionStrings(projectRoot: string, language: 'en' | 'es' | 'hu'): PermissionStrings {
const file = path.join(projectRoot, '../../src/locales', `${language}.json`);
Expand Down Expand Up @@ -93,8 +100,11 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => {
},
],
],
// The EAS project @pi-stack/debateowl, from `eas init`.
owner: 'pi-stack',
extra: {
websiteUrl: 'https://debateowl.com',
eas: { projectId: 'd60fc378-2e30-48b2-bcee-a1b1d1632bf9' },
},
experiments: {
typedRoutes: true,
Expand Down Expand Up @@ -123,9 +133,19 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => {
'expo-widgets',
{
bundleIdentifier: `${bundleIdentifier}.ExpoWidgetsTarget`,
groupIdentifier: appGroup,
groupIdentifier: APP_GROUP,
enablePushNotifications: false,
widgets: [],
widgets: [
// The timer's lock-screen widget (src/widgets/timer-widgets.tsx). The Live
// Activity and Dynamic Island need no entry: the extension draws every
// Live Activity the app starts.
{
name: 'DebateTimerWidget',
displayName: 'DebateOwl timer',
description: 'The speech or prep clock running in the DebateOwl timer.',
ios: { supportedFamilies: ['accessoryRectangular', 'accessoryCircular', 'accessoryInline'] },
},
],
},
]);
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/assets/sounds/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Timer sounds

`beep.wav` (one minute left) and `double-beep.wav` (time up) are generated by
`beep.wav` (one minute left), `double-beep.wav` (time up) and `knock.wav`
(points of information open or close) are generated by
[scripts/render-timer-sounds.ts](../../scripts/render-timer-sounds.ts), which
copies the website's Web Audio synth in `src/pages/TimerPage.tsx`: an 880 Hz
sine at 22.05 kHz, 16-bit mono. Rerun the script to change them; don't edit
the files by hand.
copies the website's Web Audio synth in `src/lib/timer-signals.ts`: an 880 Hz
sine for the beeps and two 180 Hz triangle taps for the knock, at 22.05 kHz,
16-bit mono. Rerun the script to change them; don't edit the files by hand.
Binary file added apps/mobile/assets/sounds/knock.wav
Binary file not shown.
12 changes: 12 additions & 0 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
"env": {
"DEBATELAB_BUILD": "dev"
}
},
"development-simulator": {
"developmentClient": true,
"distribution": "internal",
"node": "22.23.2",
"ios": {
"simulator": true
},
"env": {
"DEBATELAB_BUILD": "dev",
"DEBATELAB_APP_GROUP": "group.com.pistack.debateowl"
}
}
}
}
5 changes: 4 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
"scripts": {
"start": "expo start --go",
"export:web": "expo export --platform web",
"typecheck": "tsc --noEmit && tsc --noEmit -p scripts"
"typecheck": "tsc --noEmit && tsc --noEmit -p scripts",
"eas-build-post-install": "node scripts/patch-expo-widgets-bundle.mjs"
},
"dependencies": {
"@debatelab/core": "*",
"@expo-google-fonts/fredoka": "^0.4.1",
"@expo-google-fonts/nunito": "^0.4.2",
"@expo/dom-webview": "~57.0.1",
"@expo/ui": "~57.0.23",
"@formatjs/intl-listformat": "^8.3.15",
"@formatjs/intl-pluralrules": "^6.3.15",
"@react-native-async-storage/async-storage": "2.2.0",
Expand All @@ -38,6 +40,7 @@
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-sharing": "~57.0.22",
"expo-speech": "~57.0.3",
"expo-speech-recognition": "^57.1.1",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
Expand Down
37 changes: 37 additions & 0 deletions apps/mobile/scripts/patch-expo-widgets-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* Lets expo-widgets bundle its widget runtime in this npm workspace. EAS runs
* it after installing (`eas-build-post-install` in package.json); it is safe
* to run again.
*
* During the Xcode build, expo-widgets bundles its runtime with its own
* metro.config.js, which makes the package itself the project root. That
* bundle resolves `react-native/*` and worklets imports to Metro's empty
* module, which Metro finds in the workspace root's node_modules. Expo CLI's
* resolver does not find files there from that project root, so the build
* fails with "Unable to resolve module .../metro-runtime/src/modules/empty-module.js"
* (EAS build b8e4ba30, 2026-10-28). An empty module inside the package
* resolves. Remove this once expo-widgets handles hoisted workspaces.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';

const require = createRequire(import.meta.url);
const packageDir = path.dirname(require.resolve('expo-widgets/package.json'));
const configPath = path.join(packageDir, 'metro.config.js');
const emptyModule = path.join(packageDir, 'bundle', 'empty-module.js');

writeFileSync(emptyModule, '// The widget bundle\'s empty module (apps/mobile/scripts/patch-expo-widgets-bundle.mjs).\n');

const config = readFileSync(configPath, 'utf8');
if (config.includes("'bundle/empty-module.js'")) {
console.log('expo-widgets: metro.config.js already uses its own empty module.');
} else {
const anchor = ' resolver: {\n ...config.resolver,\n';
if (!config.includes(anchor)) {
throw new Error(`expo-widgets changed ${configPath}; check whether this patch is still needed.`);
}
writeFileSync(configPath, config.replace(anchor, `${anchor} emptyModulePath: path.join(__dirname, 'bundle/empty-module.js'),\n`));
console.log('expo-widgets: metro.config.js now uses bundle/empty-module.js.');
}
22 changes: 18 additions & 4 deletions apps/mobile/scripts/render-timer-sounds.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Writes the Timer's signal sounds to assets/sounds as 16-bit mono WAV files.
* They copy the website's Web Audio synth (playSignal in src/pages/TimerPage.tsx):
* They copy the website's Web Audio synth (playSignal in src/lib/timer-signals.ts):
* an 880 Hz sine, one beep fading out over 0.5 s, and two beeps for time up
* (0.1 s on, a 50 ms gap, then a fade to 0.6 s). The website plays them at a
* tenth of full scale; a phone speaker needs more, so these peak at half.
* The knock (points of information open or close) is two short 180 Hz
* triangle taps, 0.18 s apart, each fading out over 80 ms.
* Run from the repository root after changing a sound:
*
* npx tsx apps/mobile/scripts/render-timer-sounds.ts
Expand All @@ -27,12 +29,19 @@ function envelope(t: number, from: number, to: number, holdUntil = from): number
return attack * release * fade;
}

function render(seconds: number, gain: (t: number) => number): Buffer {
const sine = (t: number, frequency: number) => Math.sin(2 * Math.PI * frequency * t);
// The Web Audio triangle: a linear ramp between -1 and 1.
const triangle = (t: number, frequency: number) => {
const phase = (t * frequency) % 1;
return phase < 0.25 ? 4 * phase : phase < 0.75 ? 2 - 4 * phase : 4 * phase - 4;
};

function render(seconds: number, gain: (t: number) => number, wave = sine, frequency = FREQUENCY): Buffer {
const samples = Math.round(seconds * RATE);
const data = Buffer.alloc(samples * 2);
for (let i = 0; i < samples; i += 1) {
const t = i / RATE;
const value = Math.sin(2 * Math.PI * FREQUENCY * t) * PEAK * gain(t);
const value = wave(t, frequency) * PEAK * gain(t);
data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 32_767), i * 2);
}
const header = Buffer.alloc(44);
Expand All @@ -59,7 +68,12 @@ async function main() {
join(outDir, 'double-beep.wav'),
render(0.6, (t) => (t < 0.1 ? envelope(t, 0, 0.1, 0.1) : envelope(t, 0.15, 0.6))),
);
console.log(`Wrote beep.wav and double-beep.wav to ${outDir}`);
const knockTap = (t: number, from: number) => envelope(t, from, from + 0.08);
await writeFile(
join(outDir, 'knock.wav'),
render(0.3, (t) => (t < 0.18 ? knockTap(t, 0) : knockTap(t, 0.18)), triangle, 180),
);
console.log(`Wrote beep.wav, double-beep.wav and knock.wav to ${outDir}`);
}

main().catch((error: unknown) => {
Expand Down
Loading
Loading