From 80c04df4fc0d13a18941452b616c667ec62bd61d Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 9 Nov 2026 09:37:36 +0100 Subject: [PATCH] feat(native): swipe rows, long-press menus, placeholders while loading, fading pictures, iOS bounce and app icon quick actions Packing items, tasks and expenses swipe sideways to their actions, as in Mail or Reminders: done or open one way, delete and edit the other, the first one run by a full swipe. They are second ways to what each row already offers; an expense still asks before it goes. A long press on a trip card lifts it and opens its menu with the card's own actions. Plain confirmations in MConfirmSheet now also ask through the system where the app can. Lists, the atlas and a journey show grey placeholders in their shape while loading instead of a spinner in an empty screen (MSkeleton); buttons keep theirs. A picture still loading fades in once it is there; one from the cache shows at once. In the iOS app the page bounces at its ends again, except on screens with pull-to-refresh, whose pull it would race; Android keeps its stretch off. On iOS a long press on the app icon offers "New trip" and the trip coming up by its name, set by the UI in its language (TrekShell.setQuickActions, feature quickActions), and a tap opens it, also from a cold start. The app's own CSS moves out of index.css into native/nativeApp.css, which had outgrown its size budget. --- client/ios/App/App/SceneDelegate.swift | 68 ++++++ client/ios/App/App/TrekShellPlugin.swift | 29 ++- client/src/App.tsx | 2 + .../Layout/NativeQuickActionsSync.tsx | 50 ++++ client/src/index.css | 48 ---- client/src/main.tsx | 1 + .../mobile/components/MContextMenu.test.tsx | 26 ++ client/src/mobile/components/MContextMenu.tsx | 53 ++++ .../src/mobile/components/MSkeleton.test.tsx | 26 ++ client/src/mobile/components/MSkeleton.tsx | 83 +++++++ .../src/mobile/components/MSwipeRow.test.tsx | 97 ++++++++ client/src/mobile/components/MSwipeRow.tsx | 226 ++++++++++++++++++ .../mobile/components/useLongPress.test.tsx | 74 ++++++ client/src/mobile/components/useLongPress.ts | 84 +++++++ client/src/mobile/mobile.css | 10 + .../screens/admin/MAdminAddonManager.tsx | 7 +- .../screens/admin/MAdminCategoryManager.tsx | 5 +- .../screens/admin/MAdminGitHubPanel.tsx | 7 +- .../screens/admin/MAdminMcpTokensPanel.tsx | 11 +- .../admin/MAdminPackingTemplateManager.tsx | 5 +- .../screens/admin/MAdminPermissionsPanel.tsx | 5 +- .../screens/admin/MAdminUsersSection.tsx | 5 +- client/src/mobile/screens/atlas/MAtlas.tsx | 5 +- .../mobile/screens/collections/MCollTours.tsx | 8 +- .../collections/MCollTripPickerSheet.tsx | 5 +- .../screens/collections/MCollections.tsx | 11 +- .../mobile/screens/dashboard/MDashboard.tsx | 31 ++- .../src/mobile/screens/journey/MJourney.tsx | 5 +- .../mobile/screens/journey/MJourneyDetail.tsx | 5 +- .../screens/notifications/MNotifications.tsx | 5 +- .../mobile/screens/settings/MConfirmSheet.tsx | 13 +- .../places/MPlacesSaveToCollectionSheet.tsx | 5 +- .../mobile/screens/trip/tabs/MCostsTab.tsx | 13 +- .../screens/trip/tabs/MPackingListTab.tsx | 23 +- .../mobile/screens/trip/tabs/MTodoListTab.tsx | 29 ++- client/src/mobile/usePullToRefresh.ts | 16 ++ client/src/native/imageFade.test.ts | 66 +++++ client/src/native/imageFade.ts | 39 +++ client/src/native/nativeApp.css | 83 +++++++ client/src/native/nativeApp.test.ts | 3 + client/src/native/nativeApp.ts | 8 + client/src/native/quickActions.test.ts | 66 +++++ client/src/native/quickActions.ts | 53 ++++ client/src/native/trekShell.ts | 21 ++ 44 files changed, 1315 insertions(+), 120 deletions(-) create mode 100644 client/src/components/Layout/NativeQuickActionsSync.tsx create mode 100644 client/src/mobile/components/MContextMenu.test.tsx create mode 100644 client/src/mobile/components/MContextMenu.tsx create mode 100644 client/src/mobile/components/MSkeleton.test.tsx create mode 100644 client/src/mobile/components/MSkeleton.tsx create mode 100644 client/src/mobile/components/MSwipeRow.test.tsx create mode 100644 client/src/mobile/components/MSwipeRow.tsx create mode 100644 client/src/mobile/components/useLongPress.test.tsx create mode 100644 client/src/mobile/components/useLongPress.ts create mode 100644 client/src/native/imageFade.test.ts create mode 100644 client/src/native/imageFade.ts create mode 100644 client/src/native/nativeApp.css create mode 100644 client/src/native/quickActions.test.ts create mode 100644 client/src/native/quickActions.ts 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`. */