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 (
+
+
+
+ );
+}
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 (
+