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",