feat(native): the small things that give a WebView away

- A short haptic tap on success, warning and error toasts, which is where
  every save and delete already reports. Info toasts stay silent.
- No "‹ › Done" strip above the iOS keyboard.
- No Safari preview when a link is long-pressed.
- The swipe from the left edge goes back on iOS, through the same history
  Android's back button walks.
This commit is contained in:
Maurice
2026-09-24 13:11:25 +02:00
parent be211c22ed
commit 9a69b092e8
13 changed files with 100 additions and 0 deletions
@@ -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')
+3
View File
@@ -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')
+4
View File
@@ -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 <meta name="theme-color"> in index.html, so the launch image,
@@ -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
}
}
+2
View File
@@ -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"),
+1
View File
@@ -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",
+2
View File
@@ -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(() => {
@@ -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)
+40
View File
@@ -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)))
})
})
+19
View File
@@ -0,0 +1,19 @@
import { Haptics, NotificationType } from '@capacitor/haptics'
import { isNativeApp } from './platform'
const FEEDBACK: Record<string, NotificationType | undefined> = {
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))
}
+10
View File
@@ -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()
+3
View File
@@ -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))
+10
View File
@@ -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",