diff --git a/client/android/app/capacitor.build.gradle b/client/android/app/capacitor.build.gradle index e4738501f..0fae6fad5 100644 --- a/client/android/app/capacitor.build.gradle +++ b/client/android/app/capacitor.build.gradle @@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') implementation project(':capacitor-filesystem') + implementation project(':capacitor-haptics') implementation project(':capacitor-keyboard') implementation project(':capacitor-share') implementation project(':capacitor-splash-screen') diff --git a/client/android/capacitor.settings.gradle b/client/android/capacitor.settings.gradle index 8b55e3e59..108993d73 100644 --- a/client/android/capacitor.settings.gradle +++ b/client/android/capacitor.settings.gradle @@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/a include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/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') diff --git a/client/capacitor.config.ts b/client/capacitor.config.ts index fd51389cf..f08920fe5 100644 --- a/client/capacitor.config.ts +++ b/client/capacitor.config.ts @@ -22,6 +22,10 @@ const config: CapacitorConfig = { // on iOS it first tries the stored offline copy of the server's start page. errorPath: 'index.html', }, + ios: { + // Long-pressing a link shows a Safari preview otherwise, which no app does. + allowsLinkPreview: false, + }, plugins: { SplashScreen: { // Matches in index.html, so the launch image, diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index 411670442..9236553cb 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -19,6 +19,9 @@ class TrekBridgeViewController: CAPBridgeViewController { override func capacitorDidLoad() { bridge?.registerPluginInstance(TrekShellPlugin()) + // The swipe from the left edge that goes back in every iOS app. It walks + // the SPA's history, the same entries Android's back button uses. + webView?.allowsBackForwardNavigationGestures = true } } diff --git a/client/ios/App/CapApp-SPM/Package.swift b/client/ios/App/CapApp-SPM/Package.swift index 4748cf9b2..ac9dff196 100644 --- a/client/ios/App/CapApp-SPM/Package.swift +++ b/client/ios/App/CapApp-SPM/Package.swift @@ -14,6 +14,7 @@ let package = Package( .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"), .package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"), .package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"), + .package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"), .package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"), .package(name: "CapacitorShare", path: "../../../../node_modules/@capacitor/share"), .package(name: "CapacitorSplashScreen", path: "../../../../node_modules/@capacitor/splash-screen"), @@ -27,6 +28,7 @@ let package = Package( .product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), + .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), .product(name: "CapacitorShare", package: "CapacitorShare"), .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), diff --git a/client/package.json b/client/package.json index 4d116aa1c..d5d30dbcf 100644 --- a/client/package.json +++ b/client/package.json @@ -38,6 +38,7 @@ "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.0", "@capacitor/filesystem": "^8.1.3", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.5.0", "@capacitor/keyboard": "^8.0.5", "@capacitor/share": "^8.0.1", diff --git a/client/src/components/shared/Toast.tsx b/client/src/components/shared/Toast.tsx index 9279a1e69..1a24102e0 100644 --- a/client/src/components/shared/Toast.tsx +++ b/client/src/components/shared/Toast.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useEffect, useRef } from 'react' import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react' +import { toastHaptic } from '../../native/haptics' type ToastType = 'success' | 'error' | 'warning' | 'info' @@ -39,6 +40,7 @@ export function ToastContainer() { const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => { const id = ++toastIdCounter setToasts(prev => [...prev, { id, message, type, duration, removing: false }]) + toastHaptic(type) if (duration > 0) { const t1 = setTimeout(() => { diff --git a/client/src/mobile/components/MToastHost.tsx b/client/src/mobile/components/MToastHost.tsx index 0d29b7b31..8a93eb22a 100644 --- a/client/src/mobile/components/MToastHost.tsx +++ b/client/src/mobile/components/MToastHost.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import MStatusDot, { MStatus } from './MStatusDot' +import { toastHaptic } from '../../native/haptics' type ToastType = 'success' | 'error' | 'warning' | 'info' @@ -75,6 +76,7 @@ export default function MToastHost() { (message: string, type: ToastType = 'info', duration: number = 3000) => { const id = ++toastId setToasts((prev) => [...prev, { id, message, type, sticky: duration <= 0, removing: false }]) + toastHaptic(type) if (duration > 0) { const t = setTimeout(() => dismissToast(id), duration) diff --git a/client/src/native/haptics.test.ts b/client/src/native/haptics.test.ts new file mode 100644 index 000000000..d708d611b --- /dev/null +++ b/client/src/native/haptics.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Haptics, NotificationType } from '@capacitor/haptics' +import { toastHaptic } from './haptics' +import { isNativeApp } from './platform' + +vi.mock('@capacitor/haptics', () => ({ + Haptics: { notification: vi.fn() }, + NotificationType: { Success: 'SUCCESS', Warning: 'WARNING', Error: 'ERROR' }, +})) +vi.mock('./platform', () => ({ isNativeApp: vi.fn() })) + +beforeEach(() => { + vi.mocked(Haptics.notification).mockReset().mockResolvedValue() + vi.mocked(isNativeApp).mockReturnValue(true) +}) + +describe('toastHaptic', () => { + it('taps for success, warning and error toasts in the app', () => { + toastHaptic('success') + toastHaptic('warning') + toastHaptic('error') + expect(vi.mocked(Haptics.notification).mock.calls.map(([o]) => o.type)).toEqual([ + NotificationType.Success, NotificationType.Warning, NotificationType.Error, + ]) + }) + + it('stays still for info toasts and in a browser', () => { + toastHaptic('info') + vi.mocked(isNativeApp).mockReturnValue(false) + toastHaptic('success') + expect(Haptics.notification).not.toHaveBeenCalled() + }) + + it('does not let a missing vibrator surface as an error', async () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.mocked(Haptics.notification).mockRejectedValue(new Error('not available')) + toastHaptic('error') + await vi.waitFor(() => expect(debug).toHaveBeenCalledWith('[haptics]', expect.any(Error))) + }) +}) diff --git a/client/src/native/haptics.ts b/client/src/native/haptics.ts new file mode 100644 index 000000000..64d6d117d --- /dev/null +++ b/client/src/native/haptics.ts @@ -0,0 +1,19 @@ +import { Haptics, NotificationType } from '@capacitor/haptics' +import { isNativeApp } from './platform' + +const FEEDBACK: Record = { + success: NotificationType.Success, + warning: NotificationType.Warning, + error: NotificationType.Error, +} + +/** + * The short tap a native app gives when something saved, warned or failed. + * Hung off the toasts because every save and delete already reports through + * one; plain info toasts stay silent. Does nothing in a browser. + */ +export function toastHaptic(type: string): void { + const feedback = FEEDBACK[type] + if (!feedback || !isNativeApp()) return + Haptics.notification({ type: feedback }).catch((err) => console.debug('[haptics]', err)) +} diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index 795d0e11e..e7eef2f37 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { App } from '@capacitor/app' +import { Keyboard } from '@capacitor/keyboard' import { SplashScreen } from '@capacitor/splash-screen' import { StatusBar, Style } from '@capacitor/status-bar' import { startNativeApp } from './nativeApp' @@ -8,6 +9,7 @@ import { keepOfflineShellFresh } from './offlineShell' import { isEffectivelyOffline } from '../sync/networkMode' vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(), minimizeApp: vi.fn() } })) +vi.mock('@capacitor/keyboard', () => ({ Keyboard: { setAccessoryBarVisible: vi.fn() } })) vi.mock('@capacitor/splash-screen', () => ({ SplashScreen: { hide: vi.fn() } })) vi.mock('@capacitor/status-bar', () => ({ StatusBar: { setStyle: vi.fn() }, Style: { Dark: 'DARK', Light: 'LIGHT' } })) vi.mock('./platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn() })) @@ -63,6 +65,14 @@ describe('startNativeApp', () => { await vi.waitFor(() => expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Dark })) }) + it('hides the keyboard accessory bar on iOS only', () => { + startNativeApp() + expect(Keyboard.setAccessoryBarVisible).not.toHaveBeenCalled() + vi.mocked(isNativeIos).mockReturnValue(true) + startNativeApp() + expect(Keyboard.setAccessoryBarVisible).toHaveBeenCalledWith({ isVisible: false }) + }) + it('refreshes the offline start on iOS once the first screen has settled', () => { vi.mocked(isNativeIos).mockReturnValue(true) startNativeApp() diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index efc4b4121..e66d8c9f9 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -1,4 +1,5 @@ import { App } from '@capacitor/app' +import { Keyboard } from '@capacitor/keyboard' import { SplashScreen } from '@capacitor/splash-screen' import { StatusBar, Style } from '@capacitor/status-bar' import { isNativeApp, isNativeIos } from './platform' @@ -27,6 +28,8 @@ export function startNativeApp(): void { requestAnimationFrame(() => { void SplashScreen.hide() }) if (isNativeIos()) { + // The "‹ › Done" strip above the keyboard is a web form's, not an app's. + void Keyboard.setAccessoryBarVisible({ isVisible: false }) window.setTimeout(() => { if (isEffectivelyOffline()) return keepOfflineShellFresh().catch((err) => console.warn('[native] offline start page not refreshed', err)) diff --git a/package-lock.json b/package-lock.json index 4deaa01cc..ced2e009a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.0", "@capacitor/filesystem": "^8.1.3", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.5.0", "@capacitor/keyboard": "^8.0.5", "@capacitor/share": "^8.0.1", @@ -2534,6 +2535,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/haptics": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz", + "integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/ios": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.2.tgz",