diff --git a/client/ios/App/App/SceneDelegate.swift b/client/ios/App/App/SceneDelegate.swift index 2c69a8254..2666a7df0 100644 --- a/client/ios/App/App/SceneDelegate.swift +++ b/client/ios/App/App/SceneDelegate.swift @@ -6,6 +6,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } + // Launched by a quick action on the app icon: held until the UI asks for it. + if let item = connectionOptions.shortcutItem { _ = TrekQuickActions.open(item) } window = UIWindow(windowScene: windowScene) window?.rootViewController = TrekBridgeViewController() @@ -45,6 +47,72 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { SceneDelegateProxy.shared.scene(scene, continue: userActivity) } + + // A quick action on the app icon while the app was running in the background. + func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void) { + completionHandler(TrekQuickActions.open(shortcutItem)) + } +} + +/// The quick actions on the app icon (a long press on it): "New trip" and the +/// trip coming up, set by the UI in its language (TrekShell.setQuickActions). +/// Each carries the path it opens; a tap goes to the UI as that path, at once +/// when it listens, or held for it to take when the tap launched the app. +enum TrekQuickActions { + private static let pathKey = "path" + private static var pending: String? + static var onOpen: ((String) -> Void)? + + struct Item { + let type: String + let title: String + let subtitle: String? + let icon: String + let path: String + + init?(raw: JSObject) { + guard let type = raw["type"] as? String, let title = raw["title"] as? String, + let path = raw["path"] as? String, path.hasPrefix("/") else { return nil } + self.type = type + self.title = title + self.subtitle = raw["subtitle"] as? String + self.icon = raw["icon"] as? String ?? "" + self.path = path + } + } + + static func set(_ items: [Item]) { + UIApplication.shared.shortcutItems = items.prefix(4).map { item in + UIApplicationShortcutItem( + type: "com.liketrek.trek.\(item.type)", + localizedTitle: item.title, + localizedSubtitle: item.subtitle, + icon: icon(item.icon), + userInfo: [pathKey: item.path as NSString] + ) + } + } + + private static func icon(_ name: String) -> UIApplicationShortcutIcon { + switch name { + case "add": return UIApplicationShortcutIcon(type: .add) + case "trip": return UIApplicationShortcutIcon(systemImageName: "suitcase") + default: return UIApplicationShortcutIcon(type: .compose) + } + } + + static func open(_ item: UIApplicationShortcutItem) -> Bool { + guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false } + if let onOpen { onOpen(path) } else { pending = path } + return true + } + + /// The action the app was launched with, once. + static func take() -> String? { + defer { pending = nil } + return pending + } } /// The blur over the app while it sits in the app switcher, when the app lock diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index bf664eafe..1e94bb7bd 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -49,6 +49,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setQuickActions", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "takeQuickAction", returnType: CAPPluginReturnPromise), ] /// What this binary offers the server's UI, raised with every native feature @@ -60,7 +62,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an /// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing. static var features: [String] { - var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"] + var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions"] if canAddWalletPasses { names.append("wallet") } if TrekWatchLink.isAvailable { names.append("watch") } return names @@ -85,6 +87,31 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr TrekKeyCommands.onPress = { [weak self] id in self?.notifyListeners("keyCommand", data: ["id": id]) } + TrekQuickActions.onOpen = { [weak self] path in + DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) } + } + } + + // MARK: - Quick actions on the app icon + + /// The app icon's quick actions, in the UI's language (TrekQuickActions). + @objc func setQuickActions(_ call: CAPPluginCall) { + let items = (call.getArray("items", JSObject.self) ?? []).compactMap { TrekQuickActions.Item(raw: $0) } + DispatchQueue.main.async { + TrekQuickActions.set(items) + call.resolve() + } + } + + /// The quick action the app was launched with, before the UI was there to hear of it. + @objc func takeQuickAction(_ call: CAPPluginCall) { + DispatchQueue.main.async { + if let path = TrekQuickActions.take() { + call.resolve(["path": path]) + } else { + call.resolve(["path": NSNull()]) + } + } } // MARK: - Hardware keyboard diff --git a/client/src/App.tsx b/client/src/App.tsx index 120dc8a99..59baf4af5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -38,6 +38,7 @@ import AppLockGate from './components/Layout/AppLockGate' import NativeImportSheet from './mobile/components/NativeImportSheet' import MPushPrompt from './mobile/components/MPushPrompt' import NativeWidgetsSync from './components/Layout/NativeWidgetsSync' +import NativeQuickActionsSync from './components/Layout/NativeQuickActionsSync' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' import HelpPanel from './components/Help/HelpPanel' // Notice action registrations (side-effect imports): @@ -456,6 +457,7 @@ export default function App() { {!hideAuthedWidgets && isPhone && } + {/* One boundary for all route chunks, above so it stays mounted across navigations. react-router runs location updates inside a transition, so a mounted boundary keeps the current page on screen instead of flashing diff --git a/client/src/components/Layout/NativeQuickActionsSync.tsx b/client/src/components/Layout/NativeQuickActionsSync.tsx new file mode 100644 index 000000000..a2abbffc1 --- /dev/null +++ b/client/src/components/Layout/NativeQuickActionsSync.tsx @@ -0,0 +1,50 @@ +/** + * NativeQuickActionsSync: keeps the app icon's quick actions (native/quickActions.ts) + * in step with the trips on this device and the app's language. Runs at start, + * on a change of language or account, and whenever the app comes back to the + * foreground. Signed out, the icon offers nothing that would open a trip. + * Renders nothing; outside an iOS binary with the feature it does nothing. + */ +import { App } from '@capacitor/app'; +import { useEffect } from 'react'; +import { offlineDb } from '../../db/offlineDb'; +import { useTranslation } from '../../i18n'; +import { useNativeCapabilities } from '../../native/capabilities'; +import { publishQuickActions, quickActionsActive, quickActionsFor } from '../../native/quickActions'; +import { pickTrip } from '../../native/widgets'; +import { useAuthStore } from '../../store/authStore'; +import { localDateStr } from '../../vacay/monthGrid'; + +export default function NativeQuickActionsSync() { + const { t, language } = useTranslation(); + const { caps } = useNativeCapabilities(); + const userId = useAuthStore((s) => (s.isAuthenticated ? (s.user?.id ?? null) : null)); + + useEffect(() => { + if (!quickActionsActive()) return; + let cancelled = false; + + const sync = async () => { + if (userId === null) { + await publishQuickActions([]); + return; + } + const now = new Date(); + const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate()); + const trips = await offlineDb.trips.toArray(); + if (cancelled) return; + await publishQuickActions(quickActionsFor(pickTrip(trips, today), today, t)); + }; + + void sync(); + const listener = App.addListener('appStateChange', ({ isActive }) => { + if (isActive) void sync(); + }); + return () => { + cancelled = true; + void listener.then((handle) => handle.remove()); + }; + }, [caps, t, language, userId]); + + return null; +} diff --git a/client/src/index.css b/client/src/index.css index 7b661f685..7bea87652 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -391,54 +391,6 @@ html, body { -webkit-tap-highlight-color: transparent; } -/* Inside the iOS and Android app (native/nativeApp.ts sets the attribute): a long - press on a label, card or image does nothing, as in a native app, instead of - starting a text selection, a magnifier or a "save image" callout. What the user - types into stays selectable, or editing, pasting and moving the caret would break; - text worth copying opts in with .trek-selectable. A browser keeps its default. */ -html[data-native-app] body { - -webkit-user-select: none; - user-select: none; - -webkit-touch-callout: none; -} -html[data-native-app] :is(input, textarea, select, [contenteditable]:not([contenteditable='false']), .trek-selectable), -html[data-native-app] :is([contenteditable]:not([contenteditable='false']), .trek-selectable) * { - -webkit-user-select: text; - user-select: text; - -webkit-touch-callout: default; -} -html[data-native-app] img { - -webkit-user-drag: none; -} - -/* The press a native button shows the moment a finger lands on it. Most of the - UI says nothing until the finger lifts, which reads as lag. Only what has no - press of its own (no active: class), only the innermost pressed control, not - the card around a pressed button, and after a 60 ms wait, so a finger that - lands on a row to scroll the list does not flash it. Draggable rows show - their own pick-up; data-no-press opts a control out. */ -html[data-native-app] - :is(button, a[href], [role='button'], [role='tab'], [role='menuitem'], [role='option'], summary):not(:disabled):not( - [aria-disabled='true'] - ):not([class*='active:']):not([draggable='true']):not([data-no-press]):not( - :has(:is(button, a[href], [role='button']):active) - ):active { - opacity: 0.55; - transition: opacity 0s linear 60ms; -} - -/* A tap focuses a button on Android, and a focus: ring (rather than - focus-visible:) then stays round it, which no native control shows. Focus - from a hardware keyboard is focus-visible and keeps its ring. */ -html[data-touch-input] - :is(button, a, [role='button'], [role='tab'], [role='menuitem'], [role='option'], [tabindex]):focus:not( - :focus-visible - ) { - outline: none; - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; -} - /* Tabular-nums global für Time/Date/Currency/Counter */ time, .tabular-nums, [data-tabular], input[type="number"], input[type="time"], input[type="date"], input[type="datetime-local"] { diff --git a/client/src/main.tsx b/client/src/main.tsx index 1d198abfa..b8d4b4195 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -25,6 +25,7 @@ import '@fontsource/geist-sans/600.css' // several .leaflet-* styles. import 'leaflet/dist/leaflet.css' import './index.css' +import './native/nativeApp.css' import './styles/rtl.css' import './styles/ferratas.css' import './styles/paddle.css' diff --git a/client/src/mobile/components/MContextMenu.test.tsx b/client/src/mobile/components/MContextMenu.test.tsx new file mode 100644 index 000000000..929b01fe3 --- /dev/null +++ b/client/src/mobile/components/MContextMenu.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import MContextMenu from './MContextMenu'; + +describe('MContextMenu', () => { + it('FE-MOB-CTXMENU-001: names what it is about, lists the actions, and closes before running one', () => { + const onClose = vi.fn(); + const onDelete = vi.fn(() => expect(onClose).toHaveBeenCalled()); + render( + , onClick: vi.fn() }, + { key: 'delete', label: 'Delete', icon: , onClick: onDelete }, + ]} + /> + ); + expect(screen.getByText('Lisbon')).toBeTruthy(); + const del = screen.getByText('Delete').closest('button') as HTMLElement; + expect(del.className).toContain('--m-st-danger'); + fireEvent.click(del); + expect(onDelete).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/mobile/components/MContextMenu.tsx b/client/src/mobile/components/MContextMenu.tsx new file mode 100644 index 000000000..48fd944ca --- /dev/null +++ b/client/src/mobile/components/MContextMenu.tsx @@ -0,0 +1,53 @@ +import type { ReactElement } from 'react'; +import MSheet from './MSheet'; + +/** + * The menu a long press on a card opens (useLongPress): what it is about on + * top, then its actions, the destructive one in red. The same actions the + * card's own buttons offer, so this is a second way, never the only one. + */ + +export interface ContextAction { + key: string; + label: string; + icon: ReactElement; + onClick: () => void; +} + +interface MContextMenuProps { + open: boolean; + title: string; + actions: ContextAction[]; + onClose: () => void; +} + +export default function MContextMenu({ open, title, actions, onClose }: MContextMenuProps) { + return ( + +
+
+ {title} +
+ {actions.map((action) => { + const danger = action.key === 'delete'; + return ( + + ); + })} +
+
+ ); +} diff --git a/client/src/mobile/components/MSkeleton.test.tsx b/client/src/mobile/components/MSkeleton.test.tsx new file mode 100644 index 000000000..51f86d5e4 --- /dev/null +++ b/client/src/mobile/components/MSkeleton.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import MSkeleton from './MSkeleton'; + +describe('MSkeleton', () => { + it('FE-MOB-SKELETON-001: is announced once as loading, its shapes hidden from a screen reader', () => { + render(); + const status = screen.getByRole('status'); + expect(status.getAttribute('aria-label')).toBeTruthy(); + const shapes = status.firstElementChild as HTMLElement; + expect(shapes.getAttribute('aria-hidden')).toBe('true'); + expect(shapes.children).toHaveLength(3); + }); + + it('FE-MOB-SKELETON-002: rows inside a card drop their own frame', () => { + render(); + const first = screen.getByRole('status').firstElementChild?.firstElementChild as HTMLElement; + expect(first.className).not.toContain('rounded-[14px]'); + expect(first.className).toContain('border-b'); + }); + + it('FE-MOB-SKELETON-003: does not pulse with reduced motion', () => { + render(); + expect(screen.getByRole('status').className).toContain('motion-reduce:animate-none'); + }); +}); diff --git a/client/src/mobile/components/MSkeleton.tsx b/client/src/mobile/components/MSkeleton.tsx new file mode 100644 index 000000000..830cb2d1a --- /dev/null +++ b/client/src/mobile/components/MSkeleton.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from '../../i18n'; + +/** + * Grey stand-ins in the shape of what is loading, where a native app shows the + * list before its content rather than a spinner in an empty screen: rows for a + * list, cards for a feed, a cover and lines for a detail page, a plain surface + * for a map. They pulse softly, and not at all with reduced motion. Announced + * once as "Loading" for a screen reader; the shapes themselves are hidden from it. + */ + +export type MSkeletonVariant = 'rows' | 'cards' | 'detail' | 'map'; + +interface MSkeletonProps { + variant: MSkeletonVariant; + /** How many rows or cards (rows, cards). */ + count?: number; + /** Rows as cards of their own (default), or as lines inside a card that already frames them. */ + framed?: boolean; + className?: string; +} + +const BLOCK = 'bg-[color:var(--m-ic)]'; +// Lines of different lengths read as text; equal ones read as a table. +const WIDTHS = ['w-2/3', 'w-1/2', 'w-3/4', 'w-3/5']; + +function Row({ i, framed }: { i: number; framed: boolean }) { + const frame = framed + ? 'mb-2 rounded-[14px] border border-[color:var(--m-rowbr)] px-3 py-[10px]' + : 'border-b border-[color:var(--m-rowbr)] py-3 last:border-b-0'; + return ( +
+ + + + + +
+ ); +} + +function Card({ i }: { i: number }) { + return ( +
+
+ + +
+ ); +} + +function Detail() { + return ( +
+
+
+ + + {WIDTHS.map((w) => ( + + ))} +
+
+ ); +} + +export default function MSkeleton({ variant, count = 4, framed = true, className = '' }: MSkeletonProps) { + const { t } = useTranslation(); + const items = Array.from({ length: count }, (_, i) => i); + return ( +
+ + ); +} diff --git a/client/src/mobile/components/MSwipeRow.test.tsx b/client/src/mobile/components/MSwipeRow.test.tsx new file mode 100644 index 000000000..79592b3b9 --- /dev/null +++ b/client/src/mobile/components/MSwipeRow.test.tsx @@ -0,0 +1,97 @@ +import { Check, Trash2 } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '../../../tests/helpers/render'; +import MSwipeRow from './MSwipeRow'; + +vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() })); + +/** jsdom has no Touch to build a TouchEvent from: an Event with the touches set on it, as MEdgeBack's test does. */ +function touch(el: Element, type: 'touchstart' | 'touchmove' | 'touchend', x = 0, y = 100) { + const e = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(e, 'touches', { value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }] }); + act(() => { + el.dispatchEvent(e); + }); +} + +function setup() { + const onDelete = vi.fn(); + const onDone = vi.fn(); + const onRowClick = vi.fn(); + render( + + + + ); + const row = screen.getByText('Sunscreen').closest('div')?.parentElement as HTMLElement; + return { row, onDelete, onDone, onRowClick }; +} + +describe('MSwipeRow', () => { + it('FE-MOB-SWIPE-001: shows no action buttons until the row is swiped', () => { + setup(); + expect(screen.queryByText('Delete')).toBeNull(); + expect(screen.queryByText('Done')).toBeNull(); + }); + + it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => { + const { row, onDelete } = setup(); + touch(row, 'touchstart', 200); + touch(row, 'touchmove', 160); + touch(row, 'touchmove', 120); + touch(row, 'touchend'); + fireEvent.click(screen.getByText('Delete')); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + it('FE-MOB-SWIPE-003: a mostly vertical drag is a scroll and opens nothing', () => { + const { row } = setup(); + touch(row, 'touchstart', 200, 100); + touch(row, 'touchmove', 190, 160); + touch(row, 'touchend'); + expect(screen.queryByText('Delete')).toBeNull(); + }); + + it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => { + const { row, onRowClick } = setup(); + touch(row, 'touchstart', 200); + touch(row, 'touchmove', 160); + touch(row, 'touchmove', 120); + touch(row, 'touchend'); + fireEvent.click(screen.getByText('Sunscreen')); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('FE-MOB-SWIPE-005: a swipe that starts at the screen edge is left to the back gesture', () => { + const { row } = setup(); + touch(row, 'touchstart', 10); + touch(row, 'touchmove', 120); + touch(row, 'touchend'); + expect(screen.queryByText('Done')).toBeNull(); + }); + + it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => { + const { row, onDelete } = setup(); + row.getBoundingClientRect = () => ({ + width: 360, + height: 48, + top: 0, + left: 0, + right: 360, + bottom: 48, + x: 0, + y: 0, + toJSON: () => ({}), + }); + touch(row, 'touchstart', 330); + touch(row, 'touchmove', 300); + touch(row, 'touchmove', 60); + touch(row, 'touchend'); + expect(onDelete).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/mobile/components/MSwipeRow.tsx b/client/src/mobile/components/MSwipeRow.tsx new file mode 100644 index 000000000..8a16053b9 --- /dev/null +++ b/client/src/mobile/components/MSwipeRow.tsx @@ -0,0 +1,226 @@ +import type { LucideIcon } from 'lucide-react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { tickHaptic } from '../../native/haptics'; + +/** + * A list row that swipes sideways to show its actions, as in Mail or Reminders. + * + * Swiped towards the start it shows the trailing actions (delete, in red), the + * other way the leading one (pack, done). Let go past half of them and the row + * stays open on its buttons; swipe through to the far end and the first action + * runs straight away, with a tick when the finger crosses that point. A tap + * anywhere closes an open row, and opening one closes the one open before. + * + * The gesture claims only a clearly sideways drag, so the list still scrolls, + * and none that starts at the screen's left edge, which goes back (MEdgeBack). + * Every action here is a second way to something the row already offers, so + * nothing is lost to a screen reader or a mouse: the buttons are hidden from + * both until the row is open. + */ + +export interface SwipeAction { + label: string; + icon: LucideIcon; + tone: 'danger' | 'accent' | 'neutral'; + onAction: () => void; +} + +interface MSwipeRowProps { + /** Shown by a swipe towards the end of the line (to the right in LTR). */ + leading?: SwipeAction; + /** Shown by a swipe towards the start; the first one is what a full swipe runs. */ + trailing?: SwipeAction[]; + disabled?: boolean; + className?: string; + /** The row's own background: it covers the buttons until the row moves, so it must match what is around it. */ + surface?: string; + children: ReactNode; +} + +const BUTTON_W = 76; +const EDGE_PX = 24; +const CLAIM_PX = 10; +const AXIS_BIAS = 1.5; +/** Past this share of the row's width a release runs the first action. */ +const FULL_SHARE = 0.62; + +const TONE: Record = { + danger: 'bg-danger text-white', // theme-lint-disable: white on the danger fill, as the delete buttons draw it + accent: 'bg-m-act text-m-actfg', + neutral: 'bg-[color:var(--m-faint)] text-white', // theme-lint-disable: white on the neutral action fill +}; + +// The row open now, so opening another closes it. +let closeOpenRow: (() => void) | null = null; + +export default function MSwipeRow({ + leading, + trailing = [], + disabled = false, + className = '', + surface = 'bg-[color:var(--m-card)]', + children, +}: MSwipeRowProps) { + const rowRef = useRef(null); + const contentRef = useRef(null); + const [open, setOpen] = useState<'leading' | 'trailing' | null>(null); + // Which side shows while the finger moves, so the right buttons sit under the row. + const [side, setSide] = useState<'leading' | 'trailing' | null>(null); + const latest = useRef({ leading, trailing }); + useEffect(() => { + latest.current = { leading, trailing }; + }); + + useEffect(() => { + const row = rowRef.current; + const content = contentRef.current; + if (!row || !content || disabled) return; + const rtl = () => getComputedStyle(row).direction === 'rtl'; + let start: { x: number; y: number } | null = null; + let claimed = false; + let base = 0; + let offset = 0; + let beyond = false; + + const place = (x: number, animate: boolean) => { + offset = x; + content.style.transition = animate ? 'transform 0.28s cubic-bezier(0.32, 0.72, 0, 1)' : 'none'; + content.style.transform = x ? `translateX(${x}px)` : ''; + }; + const width = () => row.getBoundingClientRect().width; + // Positive offset reveals the leading side in LTR; RTL mirrors it. + const leadSign = () => (rtl() ? -1 : 1); + const openWidth = (which: 'leading' | 'trailing') => + which === 'leading' ? (latest.current.leading ? BUTTON_W : 0) : latest.current.trailing.length * BUTTON_W; + + const close = () => { + place(0, true); + setOpen(null); + setSide(null); + if (closeOpenRow === close) closeOpenRow = null; + }; + + const onStart = (e: TouchEvent) => { + claimed = false; + start = null; + if (e.touches.length !== 1) return; + const t = e.touches[0]; + if (t.clientX < EDGE_PX) return; + start = { x: t.clientX, y: t.clientY }; + base = offset; + beyond = false; + }; + + const onMove = (e: TouchEvent) => { + if (!start) return; + const t = e.touches[0]; + const dx = t.clientX - start.x; + const dy = t.clientY - start.y; + if (!claimed) { + if (Math.abs(dx) < CLAIM_PX && Math.abs(dy) < CLAIM_PX) return; + if (Math.abs(dx) < Math.abs(dy) * AXIS_BIAS) { + start = null; + return; + } + claimed = true; + if (closeOpenRow && closeOpenRow !== close) closeOpenRow(); + } + if (e.cancelable) e.preventDefault(); + let x = base + dx; + const which = x * leadSign() > 0 ? 'leading' : 'trailing'; + const room = openWidth(which); + if (!room) x = x / 4; + setSide((s) => (s === which ? s : which)); + // Unmeasured (width 0) means no full swipe: a row never runs an action by accident. + const w = width(); + const crossed = w > 0 && room > 0 && Math.abs(x) > w * FULL_SHARE; + if (crossed !== beyond) { + beyond = crossed; + if (crossed) tickHaptic(); + } + place(x, false); + }; + + const onEnd = () => { + if (!start || !claimed) { + start = null; + return; + } + start = null; + claimed = false; + const which = offset * leadSign() > 0 ? 'leading' : 'trailing'; + const room = openWidth(which); + const action = which === 'leading' ? latest.current.leading : latest.current.trailing[0]; + if (beyond && action) { + close(); + action.onAction(); + return; + } + if (room && Math.abs(offset) > room / 2) { + place(room * (which === 'leading' ? leadSign() : -leadSign()), true); + setOpen(which); + closeOpenRow = close; + } else { + close(); + } + }; + + // A tap on an open row closes it instead of opening what is under the finger. + const onClickCapture = (e: MouseEvent) => { + if (offset === 0) return; + if (e.target instanceof Element && e.target.closest('[data-swipe-action]')) return; + e.preventDefault(); + e.stopPropagation(); + close(); + }; + + row.addEventListener('touchstart', onStart, { passive: true }); + row.addEventListener('touchmove', onMove, { passive: false }); + row.addEventListener('touchend', onEnd, { passive: true }); + row.addEventListener('touchcancel', onEnd, { passive: true }); + row.addEventListener('click', onClickCapture, true); + return () => { + row.removeEventListener('touchstart', onStart); + row.removeEventListener('touchmove', onMove); + row.removeEventListener('touchend', onEnd); + row.removeEventListener('touchcancel', onEnd); + row.removeEventListener('click', onClickCapture, true); + if (closeOpenRow === close) closeOpenRow = null; + }; + }, [disabled]); + + const run = (action: SwipeAction) => { + closeOpenRow?.(); + action.onAction(); + }; + const shown = open ?? side; + const button = (action: SwipeAction) => ( + + ); + + return ( +
+ {shown && ( +
+ {shown === 'leading' ? leading && button(leading) : trailing.map(button)} +
+ )} +
+ {children} +
+
+ ); +} diff --git a/client/src/mobile/components/useLongPress.test.tsx b/client/src/mobile/components/useLongPress.test.tsx new file mode 100644 index 000000000..1bb22c48e --- /dev/null +++ b/client/src/mobile/components/useLongPress.test.tsx @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '../../../tests/helpers/render'; +import { pressHaptic } from '../../native/haptics'; +import { useLongPress } from './useLongPress'; + +vi.mock('../../native/haptics', () => ({ pressHaptic: vi.fn() })); + +function Card({ onLongPress, onClick }: { onLongPress: () => void; onClick: () => void }) { + const press = useLongPress(onLongPress); + return ( +
+ Lisbon +
+ ); +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +/** jsdom has no Touch to build a TouchEvent from: an Event with the touches set on it, as MEdgeBack's test does. */ +function touch(el: Element, type: 'touchstart' | 'touchmove' | 'touchend', x = 50, y = 50) { + const e = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(e, 'touches', { value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }] }); + act(() => { + el.dispatchEvent(e); + }); +} + +function setup() { + const onLongPress = vi.fn(); + const onClick = vi.fn(); + render(); + return { card: screen.getByRole('button'), onLongPress, onClick }; +} + +describe('useLongPress', () => { + it('FE-MOB-LONGPRESS-001: a held finger lifts the card, bumps and opens the menu, and the tap that ends it opens nothing', () => { + const { card, onLongPress, onClick } = setup(); + touch(card, 'touchstart'); + act(() => vi.advanceTimersByTime(200)); + expect(card.hasAttribute('data-pressing')).toBe(true); + act(() => vi.advanceTimersByTime(300)); + expect(onLongPress).toHaveBeenCalledOnce(); + expect(pressHaptic).toHaveBeenCalled(); + expect(card.hasAttribute('data-pressing')).toBe(false); + touch(card, 'touchend'); + fireEvent.click(card); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('FE-MOB-LONGPRESS-002: a finger that moves is scrolling and cancels it', () => { + const { card, onLongPress } = setup(); + touch(card, 'touchstart'); + touch(card, 'touchmove', 50, 80); + act(() => vi.advanceTimersByTime(600)); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it('FE-MOB-LONGPRESS-003: a short tap is a tap', () => { + const { card, onLongPress, onClick } = setup(); + touch(card, 'touchstart'); + act(() => vi.advanceTimersByTime(100)); + touch(card, 'touchend'); + fireEvent.click(card); + expect(onLongPress).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("FE-MOB-LONGPRESS-004: Android's contextmenu counts as the long press", () => { + const { card, onLongPress } = setup(); + fireEvent.contextMenu(card); + expect(onLongPress).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/mobile/components/useLongPress.ts b/client/src/mobile/components/useLongPress.ts new file mode 100644 index 000000000..69da17567 --- /dev/null +++ b/client/src/mobile/components/useLongPress.ts @@ -0,0 +1,84 @@ +import { type MouseEvent, type TouchEvent, useEffect, useRef } from 'react'; +import { pressHaptic } from '../../native/haptics'; + +/** + * A long press on a card, as the phone knows it: hold still for a moment and + * the card lifts, the phone bumps, and `onLongPress` opens its menu. A finger + * that moves is scrolling and cancels it; the tap that ends a long press opens + * nothing else. Android also reports the press as a contextmenu event, which + * counts as the same press. + * + * Spread the returned props on the card. For the first moments it presses like + * any tap; once the hold is clearly a hold the card gets data-pressing and lifts + * (index.css), and the press dimming lets go of it. + */ + +const LIFT_MS = 180; +const HOLD_MS = 450; +const SLOP_PX = 8; + +export function useLongPress(onLongPress: (() => void) | undefined) { + const timer = useRef(null); + const lift = useRef(null); + const start = useRef<{ x: number; y: number } | null>(null); + // The click that ends a long press, swallowed. + const fired = useRef(false); + const target = useRef(null); + const handler = useRef(onLongPress); + useEffect(() => { + handler.current = onLongPress; + }); + useEffect( + () => () => { + if (timer.current) window.clearTimeout(timer.current); + if (lift.current) window.clearTimeout(lift.current); + }, + [] + ); + + const settle = () => { + if (timer.current) window.clearTimeout(timer.current); + if (lift.current) window.clearTimeout(lift.current); + timer.current = null; + lift.current = null; + start.current = null; + target.current?.removeAttribute('data-pressing'); + }; + const fire = () => { + settle(); + fired.current = true; + pressHaptic(); + handler.current?.(); + }; + + if (!onLongPress) return {}; + return { + 'data-long-press': '', + onTouchStart: (e: TouchEvent) => { + fired.current = false; + if (e.touches.length !== 1) return; + start.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + const card = e.currentTarget; + target.current = card; + lift.current = window.setTimeout(() => card.setAttribute('data-pressing', ''), LIFT_MS); + timer.current = window.setTimeout(fire, HOLD_MS); + }, + onTouchMove: (e: TouchEvent) => { + if (!start.current) return; + const t = e.touches[0]; + if (Math.abs(t.clientX - start.current.x) > SLOP_PX || Math.abs(t.clientY - start.current.y) > SLOP_PX) settle(); + }, + onTouchEnd: settle, + onTouchCancel: settle, + onContextMenu: (e: MouseEvent) => { + e.preventDefault(); + if (!fired.current) fire(); + }, + onClickCapture: (e: MouseEvent) => { + if (!fired.current) return; + fired.current = false; + e.preventDefault(); + e.stopPropagation(); + }, + }; +} diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index cd0f32146..a98d481f8 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -637,6 +637,16 @@ html:has(.m-shell) { overflow: visible; overscroll-behavior: none; } +/* In the iOS app the page bounces at its top and bottom, as every iOS list + does; the fixed bars stay where they are. The rule above is for the browser, + where a pull at the top reloads an installed PWA, which the app cannot do. + Not on Android, whose stretch scales the bars too (MainActivity turns it + off), not sideways, and not on a screen with pull-to-refresh, whose pull + WebKit's bounce would race (mobile/usePullToRefresh.ts). */ +html[data-native-ios]:not([data-pull-refresh]), +html[data-native-ios]:not([data-pull-refresh]):has(.m-shell) { + overscroll-behavior-y: auto; +} html:has(.m-shell) body { height: auto; overflow: visible; diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 0f498bbcc..4711471a1 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -3,7 +3,7 @@ import { useTranslation } from '../../../i18n' import { useIsDark } from '../../../hooks/useIsDark' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, - Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Settings2, ChevronRight, + Sparkles, Luggage, Plane, Bookmark, Users, Mountain, CarFront, Settings2, ChevronRight, } from 'lucide-react' import DawarichIcon from '../../../components/shared/DawarichIcon' import AirTrailIcon from '../../../components/shared/AirTrailIcon' @@ -23,6 +23,7 @@ import { useAiAddonTile } from '../../../components/Admin/addons/useAiAddonTile' import { ImmichIcon, SynologyIcon } from '../../../components/Admin/addons/PhotoProviderIcons' import { MAdminCard, MAdminClampedText } from './MAdminUi' import WandererMark from '../../../components/Tours/wanderer/WandererMark' +import MSkeleton from '../../components/MSkeleton' // Keys are the `icon` column from the addons table; anything unknown falls back to // Puzzle. Users/Sparkles cover collab and llm_parsing, as on the desktop. @@ -70,9 +71,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac if (loading) { return ( -
- -
+ ) } diff --git a/client/src/mobile/screens/admin/MAdminCategoryManager.tsx b/client/src/mobile/screens/admin/MAdminCategoryManager.tsx index e01e315e5..993bc95a1 100644 --- a/client/src/mobile/screens/admin/MAdminCategoryManager.tsx +++ b/client/src/mobile/screens/admin/MAdminCategoryManager.tsx @@ -4,6 +4,7 @@ import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../../../compon import { useTranslation } from '../../../i18n' import MConfirmSheet from '../settings/MConfirmSheet' import { MAdminButton, MAdminCard, MAdminCardHead } from './MAdminUi' +import MSkeleton from '../../components/MSkeleton' export default function MAdminCategoryManager() { const { @@ -126,9 +127,7 @@ export default function MAdminCategoryManager() { {showForm &&
{categoryForm}
} {isLoading ? ( -
-
-
+ ) : categories.length === 0 ? (

{t('categories.empty')}

diff --git a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx index 3c508b78b..ed883ae3e 100644 --- a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx @@ -1,10 +1,11 @@ import { type ReactNode } from 'react' -import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react' +import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react' import { useTranslation } from '../../../i18n' import { useGithubReleases } from '../../../components/Admin/useGithubReleases' import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../../utils/releaseNotes' import { MAdminButton, MAdminCard } from './MAdminUi' import { withoutDonationsInApp } from '../../../utils/donations' +import MSkeleton from '../../components/MSkeleton' const REPO = 'liketrek/TREK' @@ -156,9 +157,7 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea {/* Loading / Error / Releases */} {loading ? ( -
- -
+
) : error && releases.length === 0 ? ( diff --git a/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx b/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx index 80feb3efe..dbbcb75a9 100644 --- a/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx @@ -1,9 +1,10 @@ -import { Key, Trash2, User, Loader2, Shield } from 'lucide-react' +import { Key, Trash2, User, Shield } from 'lucide-react' import { SCOPES_PREVIEW, useMcpTokensAdmin } from '../../../components/Admin/useMcpTokensAdmin' import { useTranslation } from '../../../i18n' import { useFormatDate } from '../../../hooks/useDateFormat' import { MAdminCard } from './MAdminUi' import MConfirmSheet from '../settings/MConfirmSheet' +import MSkeleton from '../../components/MSkeleton' // Mobile MCP tokens admin section: OAuth sessions + long-lived MCP tokens, each // as a card list with loading/empty states, expandable scope chips and a @@ -34,9 +35,7 @@ export default function MAdminMcpTokensPanel() {
{sessionsLoading ? ( -
- -
+ ) : sessions.length === 0 ? (
@@ -114,9 +113,7 @@ export default function MAdminMcpTokensPanel() {
{tokensLoading ? ( -
- -
+ ) : tokens.length === 0 ? (
diff --git a/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx index 177c088d5..7375f38f2 100644 --- a/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx +++ b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx @@ -4,6 +4,7 @@ import { useTranslation } from '../../../i18n' import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react' import { MAdminButton, MAdminCard, MAdminCardHead, MAdminInput } from './MAdminUi' import MConfirmSheet from '../settings/MConfirmSheet' +import MSkeleton from '../../components/MSkeleton' // Small round icon action button in the mobile admin idiom (flat --m-ic circle). function PkIconBtn({ @@ -96,9 +97,7 @@ export default function MAdminPackingTemplateManager() { {/* Template list */} {isLoading ? ( -
- -
+ ) : templates.length === 0 ? (
{t('admin.packingTemplates.empty')}
) : ( diff --git a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx index 94ce9066c..1259cde16 100644 --- a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx @@ -4,6 +4,7 @@ import { PermissionLevel } from '../../../store/permissionsStore' import { CATEGORIES, LEVEL_LABELS, usePermissionsAdmin } from '../../../components/Admin/usePermissionsAdmin' import { Save, RotateCcw } from 'lucide-react' import { MAdminButton, MAdminCard } from './MAdminUi' +import MSkeleton from '../../components/MSkeleton' export default function MAdminPermissionsPanel(): React.ReactElement { const { t } = useTranslation() @@ -12,9 +13,7 @@ export default function MAdminPermissionsPanel(): React.ReactElement { if (loading) { return ( -
-
-
+ ) } diff --git a/client/src/mobile/screens/admin/MAdminUsersSection.tsx b/client/src/mobile/screens/admin/MAdminUsersSection.tsx index 8f799970f..44f2a09bd 100644 --- a/client/src/mobile/screens/admin/MAdminUsersSection.tsx +++ b/client/src/mobile/screens/admin/MAdminUsersSection.tsx @@ -7,6 +7,7 @@ import MSheet from '../../components/MSheet' import MChip from '../../components/MChip' import MConfirmSheet from '../settings/MConfirmSheet' import { MAdminButton, MAdminCard, MAdminField, MAdminSheetFrame } from './MAdminUi' +import MSkeleton from '../../components/MSkeleton' interface MAdminUsersSectionProps { admin: ReturnType @@ -52,9 +53,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
{isLoading ? ( -
-
-
+ ) : ( users.map((u) => (
@@ -291,6 +298,7 @@ export default function MDashboard(): React.ReactElement { t={t} onOpen={() => navigate(`/trips/${spotlight.id}`)} actions={actionsFor(spotlight, 'list')} + onLongPress={() => holdTrip(spotlight)} /> )}
{renderBlock('trips')}
@@ -308,6 +316,12 @@ export default function MDashboard(): React.ReactElement {
+ setHeldTrip(h => (h ? { ...h, open: false } : h))} + /> ) => string onOpen: () => void actions: CardAction[] + onLongPress?: () => void }): React.ReactElement { + const press = useLongPress(onLongPress) const status = getTripStatus(trip) const ongoing = status === 'ongoing' const until = daysUntil(trip.start_date) @@ -390,6 +406,7 @@ function MSpotlightCard({ trip, t, onOpen, actions }: { return (
void + actions: CardAction[]; onOpen: () => void; onLongPress?: () => void }): React.ReactElement { + const press = useLongPress(onLongPress) return (
) => string - badge: string; pluginBadges: TripCardBadge[]; actions: CardAction[]; onOpen: () => void + badge: string; pluginBadges: TripCardBadge[]; actions: CardAction[]; onOpen: () => void; onLongPress?: () => void }): React.ReactElement { + const press = useLongPress(onLongPress) return (
{loading && list.length === 0 ? ( -
-
-
+ ) : list.length === 0 ? (
diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index 9d33de44e..deaf49895 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -34,6 +34,7 @@ import MJourneyDayImportSheet from './MJourneyDayImportSheet' import MJourneyRecordingSheet from './MJourneyRecordingSheet' import { createPortal } from 'react-dom' import { useTabHeadSlot } from '../trip/tabHeadSlot' +import MSkeleton from '../../components/MSkeleton' /** * Journey detail — integrated map with the horizontal 280px card timeline @@ -315,9 +316,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD if (loading || !current) { return ( -
-
-
+ ) } diff --git a/client/src/mobile/screens/notifications/MNotifications.tsx b/client/src/mobile/screens/notifications/MNotifications.tsx index 60dc0db15..40d57f8b6 100644 --- a/client/src/mobile/screens/notifications/MNotifications.tsx +++ b/client/src/mobile/screens/notifications/MNotifications.tsx @@ -11,6 +11,7 @@ import MSegmented from '../../components/MSegmented' import MNotificationRow from './MNotificationRow' import { usePullToRefresh } from '../../usePullToRefresh' import MPullIndicator from '../../components/MPullIndicator' +import MSkeleton from '../../components/MSkeleton' function Spinner({ size = 20 }: { size?: number }) { return ( @@ -81,9 +82,7 @@ export default function MNotifications() {
{isLoading && displayed.length === 0 ? ( -
- -
+ ) : displayed.length === 0 ? (
diff --git a/client/src/mobile/screens/settings/MConfirmSheet.tsx b/client/src/mobile/screens/settings/MConfirmSheet.tsx index 0b2149849..52886c6c3 100644 --- a/client/src/mobile/screens/settings/MConfirmSheet.tsx +++ b/client/src/mobile/screens/settings/MConfirmSheet.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' import { AlertTriangle, CircleHelp, Info } from 'lucide-react' import MSheet from '../../components/MSheet' +import { useNativeQuestion } from '../../../native/useNativeQuestion' import { SHEET_DANGER, SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi' interface MConfirmSheetProps { @@ -37,8 +38,18 @@ export default function MConfirmSheet({ children, }: MConfirmSheetProps) { const Icon = danger ? AlertTriangle : onConfirm ? CircleHelp : Info + // In the app a plain question goes to the system (native/nativeDialogs.ts); a + // notice, or one with a field or a list in it, stays this card. + const question = + onConfirm && confirmLabel && !children && typeof message === 'string' + ? { title, message, confirmLabel, cancelLabel, danger } + : null + const html = useNativeQuestion(open, question, (yes) => { + if (yes) onConfirm?.() + onClose() + }) return ( - +
)} {loading ? ( -
- -
+ ) : filtered.length === 0 ? (

{t('collections.noOwnLists')}

) : ( diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 2002611be..deaa063a2 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -15,6 +15,7 @@ import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks' import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import MConfirmSheet from '../../settings/MConfirmSheet' +import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow' import { CountPill, TabPanes } from './tabChrome' import { AddPaymentSheet, MemberAvatar } from './MCostsPayment' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' @@ -594,8 +595,17 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT const members = item.members || [] const note = readUserNote(item) + // A swipe offers what the buttons beside the card do; delete still asks first. + const swipe: SwipeAction[] = canEdit + ? [ + { label: t('common.delete'), icon: Trash2, tone: 'danger', onAction: onDelete }, + { label: t('common.edit'), icon: Pencil, tone: 'neutral', onAction: onEdit }, + ] + : [] + return ( -
+ +
)}
+ ) } diff --git a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx index db9acfa7a..dd5d431f4 100644 --- a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx @@ -7,6 +7,7 @@ import { Trash2, UserPlus, UserRound, } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' +import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow' import { useAuthStore } from '../../../../store/authStore' import { useAddonStore } from '../../../../store/addonStore' import type { PackingBag, PackingItem, TripMember } from '../../../../types' @@ -694,9 +695,28 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr setCreatingBag(false) } + // A swipe does what the row's own controls do: tick it off, or edit and delete it. + const swipeTrailing: SwipeAction[] = canEdit && !isPlaceholder + ? [ + { label: t('common.delete'), icon: Trash2, tone: 'danger', onAction: onDelete }, + { label: t('common.edit'), icon: Pencil, tone: 'neutral', onAction: onEdit }, + ] + : [] + const swipeLeading: SwipeAction | undefined = isPlaceholder + ? undefined + : item.checked + ? { label: t('packing.filterOpen'), icon: RotateCcw, tone: 'neutral', onAction: () => void toggle() } + : { label: t('packing.filterDone'), icon: Check, tone: 'accent', onAction: () => void toggle() } + return ( <> -
+ +
+
{menuOpen && editMode && canEdit && (
diff --git a/client/src/mobile/screens/trip/tabs/MTodoListTab.tsx b/client/src/mobile/screens/trip/tabs/MTodoListTab.tsx index e93cf8796..43daeefdc 100644 --- a/client/src/mobile/screens/trip/tabs/MTodoListTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MTodoListTab.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' import { selectionHaptic } from '../../../../native/haptics' -import { Calendar, Check, ChevronRight, Flag, Plus } from 'lucide-react' +import { Calendar, Check, ChevronRight, Flag, Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' import MChipStrip from '../../../components/MChipStrip' +import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow' import { useAuthStore } from '../../../../store/authStore' import { useTranslation } from '../../../../i18n' import { avatarSrc } from '../../../../utils/avatarSrc' @@ -26,7 +27,7 @@ import MarkdownText from '../../../../components/shared/MarkdownText' * Add/Edit lives in `MTaskSheet`. */ export default function MTodoListTab({ planner }: { planner: TripPlanner }) { - const { t, tripId, todoItems: items, tripActions } = planner + const { t, toast, tripId, todoItems: items, tripActions } = planner const canEdit = planner.can('packing_edit', planner.trip) const currentUserId = useAuthStore(s => s.user?.id) ?? null const tripMembers = planner.tripMembers @@ -50,6 +51,10 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) { selectionHaptic() return tripActions.toggleTodoItem(tripId, id, checked) } + // The task sheet deletes the same way, without asking first. + const deleteItem = (id: number) => { + tripActions.deleteTodoItem(tripId, id).catch(() => toast.error(t('common.error'))) + } const filterPill = (active: boolean) => `flex flex-none items-center gap-[5px] whitespace-nowrap rounded-full px-[11px] py-[6px] text-[0.71875rem] font-semibold ${ @@ -189,6 +194,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) { today={today} onToggle={toggleItem} onOpen={openEdit} + onDelete={canEdit ? deleteItem : undefined} /> ))} @@ -208,14 +214,16 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) { ) } -function TaskCard({ item, members, today, onToggle, onOpen }: { +function TaskCard({ item, members, today, onToggle, onOpen, onDelete }: { item: TodoItem members: TripMember[] today: string onToggle: (id: number, checked: boolean) => void onOpen: (id: number) => void + /** Only for those who may edit the list. */ + onDelete?: (id: number) => void }) { - const { locale } = useTranslation() + const { t, locale } = useTranslation() const done = !!item.checked const overdue = isTodoOverdue(item, today) const assignee = members.find(m => m.id === item.assigned_user_id) @@ -227,8 +235,18 @@ function TaskCard({ item, members, today, onToggle, onOpen }: { ? (Number.isNaN(Date.parse(`${item.due_date}T00:00:00Z`)) ? item.due_date : formatDate(item.due_date, locale)) : null + // A swipe does what the card offers: tick it off, open it, or delete it. + const leading: SwipeAction = done + ? { label: t('todo.filter.open'), icon: RotateCcw, tone: 'neutral', onAction: () => onToggle(item.id, false) } + : { label: t('todo.filter.done'), icon: Check, tone: 'accent', onAction: () => onToggle(item.id, true) } + const trailing: SwipeAction[] = [ + ...(onDelete ? [{ label: t('common.delete'), icon: Trash2, tone: 'danger' as const, onAction: () => onDelete(item.id) }] : []), + { label: t('common.edit'), icon: Pencil, tone: 'neutral', onAction: () => onOpen(item.id) }, + ] + return ( -
+ +
+
) } diff --git a/client/src/mobile/usePullToRefresh.ts b/client/src/mobile/usePullToRefresh.ts index 2d964165e..a89849865 100644 --- a/client/src/mobile/usePullToRefresh.ts +++ b/client/src/mobile/usePullToRefresh.ts @@ -23,6 +23,20 @@ function refreshFailed(): void {} * Writes the content's transform directly during the drag, so a pull costs no * React render per frame; `state` only changes at the edges (armed, refreshing). */ +/* + * The iOS app lets the page bounce at its ends (mobile.css). A pull is only told + * from a scroll after 8 px, and by then WebKit's own bounce has started and can no + * longer be stopped, so the two would pull at once. While a screen offers + * pull-to-refresh, carries data-pull-refresh and the page does not bounce. + */ +let bounceHolds = 0; +function holdBounce(): void { + if (bounceHolds++ === 0) document.documentElement.setAttribute('data-pull-refresh', ''); +} +function releaseBounce(): void { + if (--bounceHolds === 0) document.documentElement.removeAttribute('data-pull-refresh'); +} + export function usePullToRefresh( contentRef: RefObject, onRefresh: () => Promise, @@ -113,6 +127,7 @@ export function usePullToRefresh( }); }; + holdBounce(); window.addEventListener('touchstart', onStart, { passive: true }); // Not passive: once a pull has started, the page must not also scroll or bounce. window.addEventListener('touchmove', onMove, { passive: false }); @@ -124,6 +139,7 @@ export function usePullToRefresh( window.removeEventListener('touchend', onEnd); window.removeEventListener('touchcancel', onEnd); place(0, false); + releaseBounce(); }; }, [contentRef, enabled, scrollerRef]); diff --git a/client/src/native/imageFade.test.ts b/client/src/native/imageFade.test.ts new file mode 100644 index 000000000..17ca290ae --- /dev/null +++ b/client/src/native/imageFade.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { fadeInImages } from './imageFade'; + +let stop: (() => void) | null = null; + +afterEach(() => { + stop?.(); + stop = null; + document.body.innerHTML = ''; +}); + +/** Lets the MutationObserver deliver what was just added. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function loadingImage(): HTMLImageElement { + const img = document.createElement('img'); + Object.defineProperty(img, 'complete', { value: false, configurable: true }); + return img; +} + +describe('fadeInImages', () => { + it('holds a loading picture back and fades it in once it has loaded', async () => { + stop = fadeInImages(); + const img = loadingImage(); + document.body.appendChild(img); + await settle(); + expect(img.hasAttribute('data-fading')).toBe(true); + img.dispatchEvent(new Event('load')); + expect(img.hasAttribute('data-fading')).toBe(false); + expect(img.hasAttribute('data-faded')).toBe(true); + }); + + it('shows a picture that failed, so it never stays invisible', async () => { + stop = fadeInImages(); + const img = loadingImage(); + document.body.appendChild(img); + await settle(); + img.dispatchEvent(new Event('error')); + expect(img.hasAttribute('data-fading')).toBe(false); + }); + + it('shows a picture from the cache at once, and finds pictures inside what was added', async () => { + stop = fadeInImages(); + const wrap = document.createElement('div'); + const cached = document.createElement('img'); + Object.defineProperty(cached, 'complete', { value: true }); + const fresh = loadingImage(); + wrap.append(cached, fresh); + document.body.appendChild(wrap); + await settle(); + expect(cached.hasAttribute('data-fading')).toBe(false); + expect(fresh.hasAttribute('data-fading')).toBe(true); + }); + + it('leaves map tiles and opted-out pictures alone', async () => { + stop = fadeInImages(); + document.body.innerHTML = '
'; + const tile = loadingImage(); + const own = loadingImage(); + document.body.children[0]?.appendChild(tile); + document.body.children[1]?.appendChild(own); + await settle(); + expect(tile.hasAttribute('data-fading')).toBe(false); + expect(own.hasAttribute('data-fading')).toBe(false); + }); +}); diff --git a/client/src/native/imageFade.ts b/client/src/native/imageFade.ts new file mode 100644 index 000000000..51fde8f3f --- /dev/null +++ b/client/src/native/imageFade.ts @@ -0,0 +1,39 @@ +/** + * Pictures that fade in once they are there, instead of building up line by + * line or popping in late, the way a native app shows a cover or a photo. + * + * Only an image that is still loading when it reaches the page fades: it waits + * at opacity 0 (data-fading), and as it loads or fails it is shown with a short + * fade (data-faded, index.css). One that is already in the cache shows at once, + * so nothing on a screen opened a second time flickers. A failed image is shown + * too, so a picture never stays invisible. Map tiles fade by themselves and are + * left alone, as is anything inside [data-no-fade]. Only the app calls this. + */ + +const SKIP = '.leaflet-container, .maplibregl-map, [data-no-fade]'; + +function watch(img: HTMLImageElement): void { + if (img.complete || img.hasAttribute('data-fading') || img.closest(SKIP)) return; + img.setAttribute('data-fading', ''); + const show = () => { + img.removeEventListener('load', show); + img.removeEventListener('error', show); + img.removeAttribute('data-fading'); + img.setAttribute('data-faded', ''); + }; + img.addEventListener('load', show); + img.addEventListener('error', show); +} + +function scan(node: Node): void { + if (node instanceof HTMLImageElement) watch(node); + else if (node instanceof Element) node.querySelectorAll('img').forEach(watch); +} + +export function fadeInImages(root: Element = document.body): () => void { + const observer = new MutationObserver((records) => { + for (const record of records) record.addedNodes.forEach(scan); + }); + observer.observe(root, { childList: true, subtree: true }); + return () => observer.disconnect(); +} diff --git a/client/src/native/nativeApp.css b/client/src/native/nativeApp.css new file mode 100644 index 000000000..750655104 --- /dev/null +++ b/client/src/native/nativeApp.css @@ -0,0 +1,83 @@ +/* + * What the UI does differently inside the iOS and Android app, keyed on the + * attributes native/nativeApp.ts puts on there (data-native-app, and + * data-touch-input while a finger drives it). A browser never has them, so + * none of this reaches it. Loaded after index.css (main.tsx). + */ + +/* Inside the iOS and Android app (native/nativeApp.ts sets the attribute): a long + press on a label, card or image does nothing, as in a native app, instead of + starting a text selection, a magnifier or a "save image" callout. What the user + types into stays selectable, or editing, pasting and moving the caret would break; + text worth copying opts in with .trek-selectable. A browser keeps its default. */ +html[data-native-app] body { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} +html[data-native-app] :is(input, textarea, select, [contenteditable]:not([contenteditable='false']), .trek-selectable), +html[data-native-app] :is([contenteditable]:not([contenteditable='false']), .trek-selectable) * { + -webkit-user-select: text; + user-select: text; + -webkit-touch-callout: default; +} +html[data-native-app] img { + -webkit-user-drag: none; +} + +/* A picture still loading waits unseen and fades in once it is there + (native/imageFade.ts). An animation rather than a transition, so an image's + own transitions (a hover zoom, a crossfade) stay as they are. */ +html[data-native-app] img[data-fading] { + opacity: 0; +} +@keyframes trek-image-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@media (prefers-reduced-motion: no-preference) { + html[data-native-app]:not([data-reduce-motion]) img[data-faded] { + animation: trek-image-in 0.24s ease-out; + } +} + +/* The press a native button shows the moment a finger lands on it. Most of the + UI says nothing until the finger lifts, which reads as lag. Only what has no + press of its own (no active: class), only the innermost pressed control, not + the card around a pressed button, and after a 60 ms wait, so a finger that + lands on a row to scroll the list does not flash it. Draggable rows show + their own pick-up; data-no-press opts a control out. */ +html[data-native-app] + :is(button, a[href], [role='button'], [role='tab'], [role='menuitem'], [role='option'], summary):not(:disabled):not( + [aria-disabled='true'] + ):not([class*='active:']):not([draggable='true']):not([data-no-press]):not([data-pressing]):not( + :has(:is(button, a[href], [role='button']):active) + ):active { + opacity: 0.55; + transition: opacity 0s linear 60ms; +} + +/* A card held for its menu lifts a little under the finger + (mobile/components/useLongPress.ts), as a native context menu does. */ +html[data-native-app] [data-long-press] { + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} +html[data-native-app] [data-long-press][data-pressing] { + transform: scale(0.97); +} + +/* A tap focuses a button on Android, and a focus: ring (rather than + focus-visible:) then stays round it, which no native control shows. Focus + from a hardware keyboard is focus-visible and keeps its ring. */ +html[data-touch-input] + :is(button, a, [role='button'], [role='tab'], [role='menuitem'], [role='option'], [tabindex]):focus:not( + :focus-visible + ) { + outline: none; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; +} diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index de73e5751..3284e5c4f 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -31,6 +31,8 @@ vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) })); vi.mock('./push', () => ({ startNativePush: vi.fn(async () => {}), openPushTarget: vi.fn() })); vi.mock('../mobile/overlayStack', () => ({ closeTopOverlay: vi.fn(() => false) })); vi.mock('./touchInput', () => ({ trackTouchInput: vi.fn() })); +vi.mock('./quickActions', () => ({ startQuickActions: vi.fn() })); +vi.mock('./imageFade', () => ({ fadeInImages: vi.fn() })); vi.mock('./keyboardFields', () => ({ prepareKeyboardFields: vi.fn() })); function backButtonHandler(): (event: { canGoBack: boolean }) => void { @@ -48,6 +50,7 @@ beforeEach(() => { vi.mocked(keepOfflineShellFresh).mockResolvedValue(); document.documentElement.classList.remove('dark'); document.documentElement.removeAttribute('data-native-app'); + document.documentElement.removeAttribute('data-native-ios'); }); afterEach(() => { diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index db4818ca5..ea1237383 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -7,11 +7,13 @@ import { useSettingsStore } from '../store/settingsStore'; import { isEffectivelyOffline } from '../sync/networkMode'; import { checkAppUpdate, loadNativeCapabilities } from './capabilities'; import { installNativeGeolocation } from './geolocation'; +import { fadeInImages } from './imageFade'; import { keepFieldInView } from './keepFieldInView'; import { prepareKeyboardFields } from './keyboardFields'; import { keepOfflineShellFresh } from './offlineShell'; import { isNativeApp, isNativeIos } from './platform'; import { openPushTarget, startNativePush } from './push'; +import { startQuickActions } from './quickActions'; import { reportNativeFailure } from './reportFailure'; import { startSharedFiles } from './sharedFiles'; import { trackTouchInput } from './touchInput'; @@ -30,10 +32,14 @@ export function startNativeApp(): void { // Marks the app for index.css: no text selection or long-press callout on the // UI itself, as in any native app. Fields stay selectable there. document.documentElement.setAttribute('data-native-app', ''); + // iOS only: the page may bounce at its ends there (mobile.css). + if (isNativeIos()) document.documentElement.setAttribute('data-native-ios', ''); // No hover left behind by a tap, a press that shows, no focus ring after a tap. trackTouchInput(); // The keyboard's return key, capitalisation and autocorrect fit the field. prepareKeyboardFields(); + // Pictures fade in once loaded instead of building up. + fadeInImages(); followThemeInStatusBar(); @@ -45,6 +51,8 @@ export function startNativeApp(): void { void startSharedFiles(); // Taps on TREK notifications open the place they point at. void loadNativeCapabilities().then(() => startNativePush()); + // A quick action on the app icon opens its page, also the one the app was launched with. + void loadNativeCapabilities().then(() => startQuickActions()); // A widget or Live Activity tap opens com.liketrek.trek://navigate/. void App.addListener('appUrlOpen', ({ url }) => { diff --git a/client/src/native/quickActions.test.ts b/client/src/native/quickActions.test.ts new file mode 100644 index 000000000..ec6287e2f --- /dev/null +++ b/client/src/native/quickActions.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Trip } from '../types'; +import { hasNativeFeature } from './capabilities'; +import { isNativeApp } from './platform'; +import { openPushTarget } from './push'; +import { publishQuickActions, quickActionsFor, startQuickActions } from './quickActions'; +import { TrekShell } from './trekShell'; + +vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() })); +vi.mock('./platform', () => ({ isNativeApp: vi.fn() })); +vi.mock('./push', () => ({ openPushTarget: vi.fn() })); +vi.mock('./reportFailure', () => ({ reportNativeFailure: vi.fn() })); +vi.mock('./trekShell', () => ({ + TrekShell: { setQuickActions: vi.fn(async () => {}), takeQuickAction: vi.fn(), addListener: vi.fn() }, +})); + +const t = ((key: string) => + ({ 'dashboard.newTrip': 'New Trip', 'dashboard.nextTrip': 'Next trip', 'native.widget.today': 'Today' })[key] ?? + key) as never; +const trip = (start: string, end: string) => + ({ id: 7, title: 'Lisbon', start_date: start, end_date: end }) as unknown as Trip; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isNativeApp).mockReturnValue(true); + vi.mocked(hasNativeFeature).mockReturnValue(true); +}); + +describe('quickActionsFor', () => { + it('offers "New trip" alone without a trip', () => { + expect(quickActionsFor(null, '2026-11-09', t)).toEqual([ + { type: 'newTrip', title: 'New Trip', icon: 'add', path: '/dashboard?create=1' }, + ]); + }); + + it('adds the trip coming up by its name', () => { + expect(quickActionsFor(trip('2026-12-01', '2026-12-05'), '2026-11-09', t)[1]).toEqual({ + type: 'trip', + title: 'Lisbon', + subtitle: 'Next trip', + icon: 'trip', + path: '/trips/7', + }); + }); + + it('calls a trip that runs today "Today"', () => { + expect(quickActionsFor(trip('2026-11-08', '2026-11-10'), '2026-11-09', t)[1]?.subtitle).toBe('Today'); + }); +}); + +describe('the binary', () => { + it('gets the actions only where it has the feature', async () => { + await publishQuickActions([]); + expect(TrekShell.setQuickActions).toHaveBeenCalledWith({ items: [] }); + vi.mocked(hasNativeFeature).mockReturnValue(false); + await publishQuickActions([]); + expect(TrekShell.setQuickActions).toHaveBeenCalledTimes(1); + }); + + it('opens the action the app was launched with, and listens for the next ones', async () => { + vi.mocked(TrekShell.takeQuickAction).mockResolvedValue({ path: '/trips/7' }); + await startQuickActions(); + expect(openPushTarget).toHaveBeenCalledWith('/trips/7'); + expect(TrekShell.addListener).toHaveBeenCalledWith('quickAction', expect.any(Function)); + }); +}); diff --git a/client/src/native/quickActions.ts b/client/src/native/quickActions.ts new file mode 100644 index 000000000..cd86cd3f1 --- /dev/null +++ b/client/src/native/quickActions.ts @@ -0,0 +1,53 @@ +import type { TranslationFn, Trip } from '../types'; +import { hasNativeFeature } from './capabilities'; +import { isNativeApp } from './platform'; +import { openPushTarget } from './push'; +import { reportNativeFailure } from './reportFailure'; +import { type NativeQuickAction, TrekShell } from './trekShell'; + +/** + * The quick actions a long press on the app icon shows, as in every iOS app: + * "New trip", and the trip coming up (or running today) by its name. Set by + * the UI, so they speak the app's language and follow the trips, and a tap + * opens the path each carries, the way a tap on a notification does. Only + * where the binary reports the feature `quickActions`. + */ + +export function quickActionsActive(): boolean { + return isNativeApp() && hasNativeFeature('quickActions'); +} + +/** The actions for the trip the dashboard leads with (`hero`), or "New trip" alone without one. */ +export function quickActionsFor(hero: Trip | null, today: string, t: TranslationFn): NativeQuickAction[] { + const items: NativeQuickAction[] = [ + { type: 'newTrip', title: t('dashboard.newTrip'), icon: 'add', path: '/dashboard?create=1' }, + ]; + if (hero) { + const running = !!hero.start_date && hero.start_date <= today && (hero.end_date ?? hero.start_date) >= today; + items.push({ + type: 'trip', + title: hero.title, + subtitle: running ? t('native.widget.today') : t('dashboard.nextTrip'), + icon: 'trip', + path: `/trips/${hero.id}`, + }); + } + return items; +} + +export async function publishQuickActions(items: NativeQuickAction[]): Promise { + if (!quickActionsActive()) return; + await TrekShell.setQuickActions({ items }).catch(reportNativeFailure); +} + +/** Opens the action the app was launched with, and every one chosen while it runs. */ +export async function startQuickActions(): Promise { + if (!quickActionsActive()) return; + void TrekShell.addListener('quickAction', ({ path }) => openPushTarget(path)); + try { + const { path } = await TrekShell.takeQuickAction(); + if (path) openPushTarget(path); + } catch (err) { + reportNativeFailure(err); + } +} diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 07da4b9fb..9835442a0 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -142,6 +142,27 @@ export interface TrekShellPlugin { event: 'keyCommand', listener: (payload: { id: string }) => void ): Promise<{ remove: () => Promise }>; + /** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */ + setQuickActions(options: { items: NativeQuickAction[] }): Promise; + /** Feature quickActions: the action the app was launched with, before the UI ran. */ + takeQuickAction(): Promise<{ path: string | null }>; + /** Feature quickActions: an action chosen while the app was running. */ + addListener( + event: 'quickAction', + listener: (payload: { path: string }) => void + ): Promise<{ remove: () => Promise }>; +} + +/** One quick action on the app icon: what it says, and the path in the UI it opens. */ +export interface NativeQuickAction { + /** Stable name, e.g. "newTrip". */ + type: string; + title: string; + subtitle?: string; + /** 'add' or 'trip'. */ + icon: 'add' | 'trip'; + /** A path of this server's UI, starting with "/". */ + path: string; } /** What Google Play Billing answers a restore with (Android, feature playBilling), beside iOS's `jws`. */