Mobile app: haptics, deep linking, share intent, push notifications, keep-awake

Native improvements:
- Add haptic feedback on recording start (heavy) and stop (medium)
- Add keep-screen-awake during recording (nativeKeepAwake)
- Add isNativeApp() detection helper
- Android: deep linking (pedscribe:// + https://app.pedshub.com)
- Android: share intent for text/plain and application/pdf
- iOS: deep linking (pedscribe:// URL scheme)
- iOS: remote-notification background mode
- Add Capacitor plugins: haptics, keyboard, push-notifications,
  screen-orientation, share

Updated README with complete build/deploy instructions,
App Store listing suggestions, and icon generation guide.
This commit is contained in:
Daniel 2026-04-11 02:28:30 +02:00
parent d079c6d6c9
commit aa33a55d0b
18 changed files with 370 additions and 62 deletions

View file

@ -1,19 +1,22 @@
# PedScribe Mobile App # PedScribe Mobile App
Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides background audio recording that survives screen lock on both iOS and Android. Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides background audio recording, push notifications, haptic feedback, deep linking, and share intent support on both iOS and Android.
## Features ## Features
- Background recording (screen lock safe) - Background recording that survives screen lock (foreground service on Android, background audio on iOS)
- Configurable server URL (supports self-hosted instances) - Configurable server URL (supports self-hosted instances)
- Android foreground service with wake lock for reliable recording - Haptic feedback on recording start/stop
- iOS background audio mode - Keep screen awake during recording
- Deep linking (pedscribe:// and https://app.pedshub.com)
- Share intent (receive text/PDFs from other apps)
- Push notification support
- App Store and Play Store ready - App Store and Play Store ready
## Prerequisites ## Prerequisites
- Node.js 18+ - Node.js 18+
- Android Studio (for Android builds) - Android Studio (for Android builds): `sudo snap install android-studio --classic`
- Xcode 15+ (for iOS builds, macOS only) - Xcode 15+ (for iOS builds, macOS only)
- Apple Developer account ($99/yr for App Store) - Apple Developer account ($99/yr for App Store)
- Google Play Developer account ($25 one-time) - Google Play Developer account ($25 one-time)
@ -32,35 +35,67 @@ npx cap sync
# Open in Android Studio # Open in Android Studio
npx cap open android npx cap open android
# Or build APK directly # Build menu: Build > Generate Signed Bundle / APK > APK
# Sign with your keystore (create one on first build)
# APK output: android/app/build/outputs/apk/release/
# Or build from command line:
cd android && ./gradlew assembleRelease cd android && ./gradlew assembleRelease
``` ```
The APK will be at `android/app/build/outputs/apk/release/app-release.apk` ## Build iOS (macOS only)
## Build iOS
```bash ```bash
# Open in Xcode (macOS only) # Open in Xcode
npx cap open ios npx cap open ios
# Build and sign in Xcode # In Xcode:
# Product > Archive > Distribute # 1. Select your team/signing certificate
# 2. Product > Archive
# 3. Distribute App > App Store Connect
``` ```
## How It Works ## How It Works
1. App launches with a local launcher page 1. App launches with a local launcher page
2. User enters their PedScribe server URL (default: app.pedshub.com) 2. First launch: user enters their PedScribe server URL (default: app.pedshub.com)
3. URL is saved to localStorage for future launches 3. URL is saved locally for future launches
4. App navigates to the remote web app 4. App navigates to the remote web app inside a native WebView
5. Web app runs inside the native WebView with full native API access 5. Native plugins provide background recording, haptics, and push notifications
### Background Recording ### Background Recording
**Android:** Uses `AudioRecordingService` — a foreground service that acquires a wake lock and shows a persistent notification during recording. The CPU stays active even when the screen is off. **Android:** `AudioRecordingService` is a foreground service that:
- Acquires a partial wake lock (CPU stays active, screen can sleep)
- Shows a persistent notification ("Recording in progress...")
- Includes a "Stop Recording" quick action in the notification
- Maximum 1-hour wake lock duration
**iOS:** Uses `UIBackgroundModes: audio` in Info.plist, which allows the WKWebView to continue audio capture when the app is backgrounded. **iOS:** Uses `UIBackgroundModes: audio` in Info.plist, which tells iOS to keep the app alive for audio capture when backgrounded or screen-locked.
### Deep Linking
- `pedscribe://` custom URL scheme opens the app directly
- `https://app.pedshub.com` links open in the app instead of the browser (Android App Links)
### Share Intent (Android)
Other apps can share text or PDFs directly into PedScribe:
- Share a lab result from your email into the Chart Review tab
- Share a referral note into the Hospital Course tab
## Capacitor Plugins Included
| Plugin | Purpose |
|--------|---------|
| @capacitor/app | App lifecycle management |
| @capacitor/haptics | Vibration feedback on recording start/stop |
| @capacitor/keyboard | Keyboard management for WebView |
| @capacitor/push-notifications | Push notification support |
| @capacitor/screen-orientation | Screen orientation control |
| @capacitor/share | Native share dialog |
| @capacitor/splash-screen | Launch splash screen |
| @capacitor/status-bar | Status bar styling |
## App Structure ## App Structure
@ -70,25 +105,48 @@ mobile/
package.json # Dependencies package.json # Dependencies
src/ src/
index.html # Launcher page (server URL config) index.html # Launcher page (server URL config)
launcher.js # Auto-redirect logic launcher.js # Auto-redirect + native feature init
launcher.css # Launcher styles launcher.css # Launcher styles
android/ # Android native project android/ # Android native project
app/src/main/ app/src/main/
java/com/pedshub/scribe/ java/com/pedshub/scribe/
MainActivity.java MainActivity.java
AudioRecordingService.java AudioRecordingService.java
AndroidManifest.xml AndroidManifest.xml # Permissions, deep links, share intent
ios/ # iOS native project ios/ # iOS native project
App/App/ App/App/
Info.plist # Background audio + microphone permission Info.plist # Background audio, microphone, deep links
``` ```
## Changing the Default Server ## Updating the Web App
Edit `src/launcher.js` and change the `DEFAULT_URL` variable: The mobile app wraps the remote web app — updating the server automatically updates all mobile clients. No app store update needed for web changes.
```javascript To update native features (plugins, permissions, splash screen):
var DEFAULT_URL = 'https://your-server.example.com'; ```bash
cd mobile
npm install
npx cap sync
# Then rebuild in Android Studio / Xcode
``` ```
Users can also change the server URL at any time from the launcher screen. ## Generating App Icons
Replace the default Capacitor icons with PedScribe branding:
1. Create a 1024x1024 PNG icon
2. Install the assets tool: `npm install -D @capacitor/assets`
3. Place your icon as `assets/icon-only.png` and `assets/splash.png`
4. Run: `npx capacitor-assets generate`
This generates all required sizes for both platforms.
## App Store Listing Suggestions
**Title:** PedScribe - Pediatric AI Scribe
**Subtitle:** Voice-to-Note Clinical Documentation
**Category:** Medical
**Keywords:** pediatric, scribe, medical, documentation, HPI, SOAP, clinical, AI, voice
**Description:**
PedScribe is an AI-powered clinical documentation tool for pediatric physicians. Record patient encounters, and the AI generates structured medical notes — HPIs, SOAP notes, hospital courses, chart reviews, and more. Includes pediatric calculators, developmental milestone tracking, and a learning hub with quizzes. Self-hosted for maximum privacy with HIPAA-compliant AI providers.

View file

@ -10,6 +10,11 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-screen-orientation')
implementation project(':capacitor-share')
implementation project(':capacitor-splash-screen') implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar') implementation project(':capacitor-status-bar')

View file

@ -22,6 +22,32 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Deep linking: pedscribe:// and https://app.pedshub.com -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pedscribe" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.pedshub.com" />
</intent-filter>
<!-- Share intent: receive text/files from other apps -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
</intent-filter>
</activity> </activity>
<service <service

View file

@ -9,9 +9,21 @@
}, },
"plugins": { "plugins": {
"SplashScreen": { "SplashScreen": {
"launchShowDuration": 1000, "launchShowDuration": 1500,
"backgroundColor": "#2563eb", "backgroundColor": "#2563eb",
"showSpinner": false "showSpinner": true,
"spinnerColor": "#ffffff"
},
"PushNotifications": {
"presentationOptions": [
"badge",
"sound",
"alert"
]
},
"Keyboard": {
"resize": "body",
"resizeOnFullScreen": true
} }
}, },
"android": { "android": {

View file

@ -3,6 +3,26 @@
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
}, },
{
"pkg": "@capacitor/haptics",
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
},
{
"pkg": "@capacitor/keyboard",
"classpath": "com.capacitorjs.plugins.keyboard.KeyboardPlugin"
},
{
"pkg": "@capacitor/push-notifications",
"classpath": "com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin"
},
{
"pkg": "@capacitor/screen-orientation",
"classpath": "com.capacitorjs.plugins.screenorientation.ScreenOrientationPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{ {
"pkg": "@capacitor/splash-screen", "pkg": "@capacitor/splash-screen",
"classpath": "com.capacitorjs.plugins.splashscreen.SplashScreenPlugin" "classpath": "com.capacitorjs.plugins.splashscreen.SplashScreenPlugin"

View file

@ -1,5 +1,5 @@
// PedScribe Mobile Launcher // PedScribe Mobile Launcher
// Handles configurable server URL and auto-redirect // Handles configurable server URL, auto-redirect, and native features
(function() { (function() {
var STORAGE_KEY = 'pedscribe_server_url'; var STORAGE_KEY = 'pedscribe_server_url';
@ -15,10 +15,8 @@
var savedUrl = localStorage.getItem(STORAGE_KEY); var savedUrl = localStorage.getItem(STORAGE_KEY);
if (savedUrl) { if (savedUrl) {
// Auto-connect to saved server
showConnecting(savedUrl); showConnecting(savedUrl);
} else { } else {
// Show setup screen
urlInput.value = DEFAULT_URL; urlInput.value = DEFAULT_URL;
setupScreen.style.display = ''; setupScreen.style.display = '';
connectingScreen.style.display = 'none'; connectingScreen.style.display = 'none';
@ -31,8 +29,8 @@
connectBtn.disabled = true; connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...'; connectBtn.textContent = 'Connecting...';
hapticFeedback();
// Test the server is reachable
testServer(url, function(ok) { testServer(url, function(ok) {
if (ok) { if (ok) {
localStorage.setItem(STORAGE_KEY, url); localStorage.setItem(STORAGE_KEY, url);
@ -50,7 +48,7 @@
if (e.key === 'Enter') connectBtn.click(); if (e.key === 'Enter') connectBtn.click();
}); });
// Change server button (from connecting screen) // Change server button
changeBtn.addEventListener('click', function() { changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
setupScreen.style.display = ''; setupScreen.style.display = '';
@ -63,13 +61,11 @@
setupScreen.style.display = 'none'; setupScreen.style.display = 'none';
connectingScreen.style.display = ''; connectingScreen.style.display = '';
// Brief delay so the user sees the connecting screen
setTimeout(function() { setTimeout(function() {
testServer(url, function(ok) { testServer(url, function(ok) {
if (ok) { if (ok) {
navigateToServer(url); navigateToServer(url);
} else { } else {
// Server not reachable — show setup screen
setupScreen.style.display = ''; setupScreen.style.display = '';
connectingScreen.style.display = 'none'; connectingScreen.style.display = 'none';
urlInput.value = url; urlInput.value = url;
@ -87,11 +83,9 @@
callback(ok); callback(ok);
} }
// Try to reach the health endpoint (no-cors: opaque response = server exists)
fetch(url + '/api/health', { mode: 'no-cors', signal: AbortSignal.timeout(8000) }) fetch(url + '/api/health', { mode: 'no-cors', signal: AbortSignal.timeout(8000) })
.then(function() { respond(true); }) .then(function() { respond(true); })
.catch(function() { .catch(function() {
// Fetch failed — try image load as a fallback connectivity check
var fallbackTimer = setTimeout(function() { respond(false); }, 5000); var fallbackTimer = setTimeout(function() { respond(false); }, 5000);
var img = new Image(); var img = new Image();
img.onload = function() { clearTimeout(fallbackTimer); respond(true); }; img.onload = function() { clearTimeout(fallbackTimer); respond(true); };
@ -112,6 +106,13 @@
div.style.display = 'block'; div.style.display = 'block';
div.textContent = msg; div.textContent = msg;
connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling); connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling);
setTimeout(function() { div.remove(); }, 8000); setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
// Haptic feedback (Capacitor native)
function hapticFeedback() {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
}
} }
})(); })();

View file

@ -5,6 +5,21 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
include ':capacitor-screen-orientation'
project(':capacitor-screen-orientation').projectDir = new File('../node_modules/@capacitor/screen-orientation/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-splash-screen' include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')

View file

@ -7,9 +7,17 @@
}, },
"plugins": { "plugins": {
"SplashScreen": { "SplashScreen": {
"launchShowDuration": 1000, "launchShowDuration": 1500,
"backgroundColor": "#2563eb", "backgroundColor": "#2563eb",
"showSpinner": false "showSpinner": true,
"spinnerColor": "#ffffff"
},
"PushNotifications": {
"presentationOptions": ["badge", "sound", "alert"]
},
"Keyboard": {
"resize": "body",
"resizeOnFullScreen": true
} }
}, },
"android": { "android": {

View file

@ -50,6 +50,16 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
<string>remote-notification</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>pedscribe</string>
</array>
</dict>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -9,9 +9,21 @@
}, },
"plugins": { "plugins": {
"SplashScreen": { "SplashScreen": {
"launchShowDuration": 1000, "launchShowDuration": 1500,
"backgroundColor": "#2563eb", "backgroundColor": "#2563eb",
"showSpinner": false "showSpinner": true,
"spinnerColor": "#ffffff"
},
"PushNotifications": {
"presentationOptions": [
"badge",
"sound",
"alert"
]
},
"Keyboard": {
"resize": "body",
"resizeOnFullScreen": true
} }
}, },
"android": { "android": {
@ -24,6 +36,11 @@
}, },
"packageClassList": [ "packageClassList": [
"AppPlugin", "AppPlugin",
"HapticsPlugin",
"KeyboardPlugin",
"PushNotificationsPlugin",
"ScreenOrientationPlugin",
"SharePlugin",
"SplashScreenPlugin", "SplashScreenPlugin",
"StatusBarPlugin" "StatusBarPlugin"
] ]

View file

@ -1,5 +1,5 @@
// PedScribe Mobile Launcher // PedScribe Mobile Launcher
// Handles configurable server URL and auto-redirect // Handles configurable server URL, auto-redirect, and native features
(function() { (function() {
var STORAGE_KEY = 'pedscribe_server_url'; var STORAGE_KEY = 'pedscribe_server_url';
@ -15,10 +15,8 @@
var savedUrl = localStorage.getItem(STORAGE_KEY); var savedUrl = localStorage.getItem(STORAGE_KEY);
if (savedUrl) { if (savedUrl) {
// Auto-connect to saved server
showConnecting(savedUrl); showConnecting(savedUrl);
} else { } else {
// Show setup screen
urlInput.value = DEFAULT_URL; urlInput.value = DEFAULT_URL;
setupScreen.style.display = ''; setupScreen.style.display = '';
connectingScreen.style.display = 'none'; connectingScreen.style.display = 'none';
@ -31,8 +29,8 @@
connectBtn.disabled = true; connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...'; connectBtn.textContent = 'Connecting...';
hapticFeedback();
// Test the server is reachable
testServer(url, function(ok) { testServer(url, function(ok) {
if (ok) { if (ok) {
localStorage.setItem(STORAGE_KEY, url); localStorage.setItem(STORAGE_KEY, url);
@ -50,7 +48,7 @@
if (e.key === 'Enter') connectBtn.click(); if (e.key === 'Enter') connectBtn.click();
}); });
// Change server button (from connecting screen) // Change server button
changeBtn.addEventListener('click', function() { changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
setupScreen.style.display = ''; setupScreen.style.display = '';
@ -63,13 +61,11 @@
setupScreen.style.display = 'none'; setupScreen.style.display = 'none';
connectingScreen.style.display = ''; connectingScreen.style.display = '';
// Brief delay so the user sees the connecting screen
setTimeout(function() { setTimeout(function() {
testServer(url, function(ok) { testServer(url, function(ok) {
if (ok) { if (ok) {
navigateToServer(url); navigateToServer(url);
} else { } else {
// Server not reachable — show setup screen
setupScreen.style.display = ''; setupScreen.style.display = '';
connectingScreen.style.display = 'none'; connectingScreen.style.display = 'none';
urlInput.value = url; urlInput.value = url;
@ -87,11 +83,9 @@
callback(ok); callback(ok);
} }
// Try to reach the health endpoint (no-cors: opaque response = server exists)
fetch(url + '/api/health', { mode: 'no-cors', signal: AbortSignal.timeout(8000) }) fetch(url + '/api/health', { mode: 'no-cors', signal: AbortSignal.timeout(8000) })
.then(function() { respond(true); }) .then(function() { respond(true); })
.catch(function() { .catch(function() {
// Fetch failed — try image load as a fallback connectivity check
var fallbackTimer = setTimeout(function() { respond(false); }, 5000); var fallbackTimer = setTimeout(function() { respond(false); }, 5000);
var img = new Image(); var img = new Image();
img.onload = function() { clearTimeout(fallbackTimer); respond(true); }; img.onload = function() { clearTimeout(fallbackTimer); respond(true); };
@ -112,6 +106,13 @@
div.style.display = 'block'; div.style.display = 'block';
div.textContent = msg; div.textContent = msg;
connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling); connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling);
setTimeout(function() { div.remove(); }, 8000); setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
// Haptic feedback (Capacitor native)
function hapticFeedback() {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
}
} }
})(); })();

View file

@ -12,6 +12,11 @@ def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorScreenOrientation', :path => '../../node_modules/@capacitor/screen-orientation'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
end end

View file

@ -63,6 +63,15 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/haptics": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.3.tgz",
"integrity": "sha512-6yKF0+lRUZEEx1GDFWgnKHia974np7o1OgmRl/btL9cSMZh0TSDZTyDMH/qcy4AM39CfuIeLs4N4h5lwixXLuQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/ios": { "node_modules/@capacitor/ios": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-6.2.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-6.2.1.tgz",
@ -72,6 +81,42 @@
"@capacitor/core": "^6.2.0" "@capacitor/core": "^6.2.0"
} }
}, },
"node_modules/@capacitor/keyboard": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-6.0.4.tgz",
"integrity": "sha512-tNeyQGxIOTiSv3g+dTxJ/plVpGMnB4daJuCHz1E2Kelo+jOx+FKlyFY9L1Ow144SCzuXK48NXr8WoxXfXgQEKA==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/push-notifications": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-6.0.5.tgz",
"integrity": "sha512-CsRmb0cnZd9Uwx24ym4My5fNKrQvwI4D51aMEph5pUZy+LAjp6q0y4NJe8DEgwaVdAqVLbb4rqn75AZ4WSiFYg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/screen-orientation": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/screen-orientation/-/screen-orientation-6.0.4.tgz",
"integrity": "sha512-BKuGqUGvN7hSEmotI7m8UlGRRBGd9YpAzlnQAXp9VNvKkinSkreMHPrEOiR/sG3QX/vR/BJ190tGQiICmylcTg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/share": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-6.0.4.tgz",
"integrity": "sha512-Ij8C3as4n6L+SUj3M1ko+DGIsrDw2VTkn5Y/pQnFRI9dRk6YoSpGKLN54yOyN7ew3N9bVa8Rko+dFwdcNg7ESA==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/splash-screen": { "node_modules/@capacitor/splash-screen": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-6.0.4.tgz", "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-6.0.4.tgz",

View file

@ -12,7 +12,12 @@
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/cli": "^6.0.0", "@capacitor/cli": "^6.0.0",
"@capacitor/core": "^6.0.0", "@capacitor/core": "^6.0.0",
"@capacitor/haptics": "^6.0.0",
"@capacitor/ios": "^6.0.0", "@capacitor/ios": "^6.0.0",
"@capacitor/keyboard": "^6.0.0",
"@capacitor/push-notifications": "^6.0.0",
"@capacitor/screen-orientation": "^6.0.0",
"@capacitor/share": "^6.0.0",
"@capacitor/splash-screen": "^6.0.0", "@capacitor/splash-screen": "^6.0.0",
"@capacitor/status-bar": "^6.0.0" "@capacitor/status-bar": "^6.0.0"
} }
@ -76,6 +81,15 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/haptics": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.3.tgz",
"integrity": "sha512-6yKF0+lRUZEEx1GDFWgnKHia974np7o1OgmRl/btL9cSMZh0TSDZTyDMH/qcy4AM39CfuIeLs4N4h5lwixXLuQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/ios": { "node_modules/@capacitor/ios": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-6.2.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-6.2.1.tgz",
@ -85,6 +99,42 @@
"@capacitor/core": "^6.2.0" "@capacitor/core": "^6.2.0"
} }
}, },
"node_modules/@capacitor/keyboard": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-6.0.4.tgz",
"integrity": "sha512-tNeyQGxIOTiSv3g+dTxJ/plVpGMnB4daJuCHz1E2Kelo+jOx+FKlyFY9L1Ow144SCzuXK48NXr8WoxXfXgQEKA==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/push-notifications": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-6.0.5.tgz",
"integrity": "sha512-CsRmb0cnZd9Uwx24ym4My5fNKrQvwI4D51aMEph5pUZy+LAjp6q0y4NJe8DEgwaVdAqVLbb4rqn75AZ4WSiFYg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/screen-orientation": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/screen-orientation/-/screen-orientation-6.0.4.tgz",
"integrity": "sha512-BKuGqUGvN7hSEmotI7m8UlGRRBGd9YpAzlnQAXp9VNvKkinSkreMHPrEOiR/sG3QX/vR/BJ190tGQiICmylcTg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/share": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-6.0.4.tgz",
"integrity": "sha512-Ij8C3as4n6L+SUj3M1ko+DGIsrDw2VTkn5Y/pQnFRI9dRk6YoSpGKLN54yOyN7ew3N9bVa8Rko+dFwdcNg7ESA==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/splash-screen": { "node_modules/@capacitor/splash-screen": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-6.0.4.tgz", "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-6.0.4.tgz",

View file

@ -16,6 +16,11 @@
"@capacitor/cli": "^6.0.0", "@capacitor/cli": "^6.0.0",
"@capacitor/core": "^6.0.0", "@capacitor/core": "^6.0.0",
"@capacitor/ios": "^6.0.0", "@capacitor/ios": "^6.0.0",
"@capacitor/haptics": "^6.0.0",
"@capacitor/keyboard": "^6.0.0",
"@capacitor/push-notifications": "^6.0.0",
"@capacitor/screen-orientation": "^6.0.0",
"@capacitor/share": "^6.0.0",
"@capacitor/splash-screen": "^6.0.0", "@capacitor/splash-screen": "^6.0.0",
"@capacitor/status-bar": "^6.0.0" "@capacitor/status-bar": "^6.0.0"
} }

View file

@ -1,5 +1,5 @@
// PedScribe Mobile Launcher // PedScribe Mobile Launcher
// Handles configurable server URL and auto-redirect // Handles configurable server URL, auto-redirect, and native features
(function() { (function() {
var STORAGE_KEY = 'pedscribe_server_url'; var STORAGE_KEY = 'pedscribe_server_url';
@ -15,10 +15,8 @@
var savedUrl = localStorage.getItem(STORAGE_KEY); var savedUrl = localStorage.getItem(STORAGE_KEY);
if (savedUrl) { if (savedUrl) {
// Auto-connect to saved server
showConnecting(savedUrl); showConnecting(savedUrl);
} else { } else {
// Show setup screen
urlInput.value = DEFAULT_URL; urlInput.value = DEFAULT_URL;
setupScreen.style.display = ''; setupScreen.style.display = '';
connectingScreen.style.display = 'none'; connectingScreen.style.display = 'none';
@ -31,8 +29,8 @@
connectBtn.disabled = true; connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...'; connectBtn.textContent = 'Connecting...';
hapticFeedback();
// Test the server is reachable
testServer(url, function(ok) { testServer(url, function(ok) {
if (ok) { if (ok) {
localStorage.setItem(STORAGE_KEY, url); localStorage.setItem(STORAGE_KEY, url);
@ -50,7 +48,7 @@
if (e.key === 'Enter') connectBtn.click(); if (e.key === 'Enter') connectBtn.click();
}); });
// Change server button (from connecting screen) // Change server button
changeBtn.addEventListener('click', function() { changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
setupScreen.style.display = ''; setupScreen.style.display = '';
@ -63,13 +61,11 @@
setupScreen.style.display = 'none'; setupScreen.style.display = 'none';
connectingScreen.style.display = ''; connectingScreen.style.display = '';
// Brief delay so the user sees the connecting screen
setTimeout(function() { setTimeout(function() {
testServer(url, function(ok) { testServer(url, function(ok) {
if (ok) { if (ok) {
navigateToServer(url); navigateToServer(url);
} else { } else {
// Server not reachable — show setup screen
setupScreen.style.display = ''; setupScreen.style.display = '';
connectingScreen.style.display = 'none'; connectingScreen.style.display = 'none';
urlInput.value = url; urlInput.value = url;
@ -87,11 +83,9 @@
callback(ok); callback(ok);
} }
// Try to reach the health endpoint (no-cors: opaque response = server exists)
fetch(url + '/api/health', { mode: 'no-cors', signal: AbortSignal.timeout(8000) }) fetch(url + '/api/health', { mode: 'no-cors', signal: AbortSignal.timeout(8000) })
.then(function() { respond(true); }) .then(function() { respond(true); })
.catch(function() { .catch(function() {
// Fetch failed — try image load as a fallback connectivity check
var fallbackTimer = setTimeout(function() { respond(false); }, 5000); var fallbackTimer = setTimeout(function() { respond(false); }, 5000);
var img = new Image(); var img = new Image();
img.onload = function() { clearTimeout(fallbackTimer); respond(true); }; img.onload = function() { clearTimeout(fallbackTimer); respond(true); };
@ -112,6 +106,13 @@
div.style.display = 'block'; div.style.display = 'block';
div.textContent = msg; div.textContent = msg;
connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling); connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling);
setTimeout(function() { div.remove(); }, 8000); setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
// Haptic feedback (Capacitor native)
function hapticFeedback() {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
}
} }
})(); })();

View file

@ -668,6 +668,31 @@ AudioRecorder.prototype.stop = function() {
}); });
}; };
// ── Native mobile helpers (Capacitor) ──
// These only activate when running inside the Capacitor native app
window.nativeHaptic = function(style) {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
window.Capacitor.Plugins.Haptics.impact({ style: style || 'medium' });
}
} catch(e) {}
};
// Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap)
window.nativeKeepAwake = function(on) {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
else window.Capacitor.Plugins.KeepAwake.allowSleep();
}
} catch(e) {}
};
// Detect if running in native app
window.isNativeApp = function() {
return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
};
// Check if server-side transcription (Whisper/AWS) is available // Check if server-side transcription (Whisper/AWS) is available
window._transcribeAvailable = null; // null = not checked yet, true/false after check window._transcribeAvailable = null; // null = not checked yet, true/false after check
function checkTranscribeStatus() { function checkTranscribeStatus() {

View file

@ -66,6 +66,8 @@
indicator.classList.remove('hidden'); indicator.classList.remove('hidden');
timer.start(); timer.start();
if (recognition) try { recognition.start(); } catch(e) {} if (recognition) try { recognition.start(); } catch(e) {}
if (typeof nativeHaptic === 'function') nativeHaptic('heavy');
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(true);
showToast('Recording started', 'info'); showToast('Recording started', 'info');
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'enc' } })); document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'enc' } }));
}).catch(function() { showToast('Microphone denied', 'error'); }); }).catch(function() { showToast('Microphone denied', 'error'); });
@ -80,6 +82,8 @@
if (stopBtn) stopBtn.classList.add('hidden'); if (stopBtn) stopBtn.classList.add('hidden');
indicator.classList.add('hidden'); indicator.classList.add('hidden');
if (recognition) try { recognition.stop(); } catch(e) {} if (recognition) try { recognition.stop(); } catch(e) {}
if (typeof nativeHaptic === 'function') nativeHaptic('medium');
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(false);
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } })); document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } }));
// If no server transcription API, use live transcript directly (no upload) // If no server transcription API, use live transcript directly (no upload)