Adds a presentation-only enter animation for mobile task-detail surfaces (modal and board main-panel), layered on top of the existing FN-7583/FN-7586 dismissal routing, without altering close/back timing. - Gate a new `.task-detail-modal--mobile-transition` class in TaskDetailModal.tsx via a local resize listener at the 768px breakpoint, mirroring the existing OVERSIGHT_MENU_MOBILE_BREAKPOINT pattern - Add matching `.task-detail-main-panel--mobile-transition` modifier in MainContent.tsx gated by the existing isMobile prop - Add slide/fade keyframe animations in TaskDetailModal.css and styles.css, both honoring prefers-reduced-motion - Add regression tests covering the modal and board-panel mobile transition behavior - Document the Capacitor WebView limitation preventing a true interactive predictive-back in packages/mobile/README.md Files changed: .../dashboard/app/components/TaskDetailModal.css | 33 ++ .../dashboard/app/components/TaskDetailModal.tsx | 31 +- ...skDetail.mobile-transition.board-panel.test.tsx | 333 +++++++++++++++++++++ .../TaskDetail.mobile-transition.test.tsx | 156 ++++++++++ .../app/components/dashboard/MainContent.tsx | 10 +- packages/dashboard/app/styles.css | 35 +++ packages/mobile/README.md | 30 ++ 7 files changed, 626 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7587 Fusion-Task-Lineage: cc5f08df-4aaf-447d-9c30-237b32191d3f Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
281 lines
14 KiB
Markdown
281 lines
14 KiB
Markdown
# @fusion/mobile
|
|
|
|
## Native Shell Onboarding & Remote Connections
|
|
|
|
Mobile uses a shell-level onboarding flow for first-run connection setup before dashboard onboarding.
|
|
|
|
- **Remote-first flow:** mobile onboarding goes directly to remote server connection.
|
|
- **Connection setup options:** QR scan (`startQrScan`) or manual server URL entry, with optional auth token.
|
|
- **Saved profiles:** multiple remote profiles are persisted in shell-local storage and can be added via QR/manual entry, edited, switched, and deleted later from dashboard connection management.
|
|
- **Active-profile fallback:** deleting the active profile automatically promotes the first remaining profile; deleting the last profile resets to an empty state (`activeProfileId: null`, `profiles: []`) so onboarding/manager recovery can reopen cleanly.
|
|
- **Storage boundary:** profile/mode state is stored only in mobile shell-local storage (via native plugin wrappers), not in Fusion project settings/local dashboard project storage.
|
|
- **Bridge contract:** mobile exposes `window.fusionShell` (`getState`, `listProfiles`, `saveProfile`, `deleteProfile`, `setActiveProfile`, `startQrScan`, `openConnectionManager`, `subscribe`) so shared dashboard code can run host-neutrally.
|
|
- **Dashboard-safe capability contract:** shared dashboard helpers should consume the typed `MobileShellDashboardBridge` subset (`getState?`, `openConnectionManager?`). If either function is missing at runtime, treat connection-management as unsupported instead of throwing.
|
|
|
|
Native wrappers are isolated under `src/plugins/native-shell.ts`, `src/plugins/connection-profiles.ts`, and `src/plugins/qr-scanner.ts` so dashboard code never calls vendor-specific APIs directly.
|
|
|
|
## Android Back: hardware button and predictive-back GESTURE both dismiss task detail
|
|
|
|
`AndroidBackButtonManager` (`src/plugins/native-shell.ts`) subscribes to `@capacitor/app`'s
|
|
`backButton` event and dispatches a single cancelable `fusion:native-back` browser event.
|
|
The dashboard's shared nav-history stack (`useNavigationHistory`) consumes that event to
|
|
dismiss the top task-detail surface (board main-panel, list-mobile, modal, and nested
|
|
detail) and return to the board/previous detail — the exact same invariant the browser
|
|
`popstate`/swipe-back path already uses. If nothing handles the event (`!defaultPrevented`),
|
|
Capacitor's own fallback runs unchanged: `history.back()` when `canGoBack`, else `exitApp()`.
|
|
|
|
**FN-7583:** the Android system back **gesture** (predictive back / edge swipe, Android
|
|
13+, default-prominent at this project's `targetSdk 35`) previously did not reach that
|
|
listener, even though the hardware/legacy Back button did — the app never opted the
|
|
generated manifest into Android's predictive-back framework
|
|
(`android:enableOnBackInvokedCallback="true"`), so `OnBackPressedDispatcher` silently
|
|
dropped the gesture-completion callback while still delivering button presses via the
|
|
ordinary `Window`/`Activity` path. Because the native `android/` project is generated by
|
|
`cap sync` and is git-ignored, the fix lives in tracked source:
|
|
`scripts/patch-android-manifest.ts` idempotently adds that manifest attribute to the
|
|
generated `AndroidManifest.xml`, and is wired into Capacitor's own `capacitor:sync:after`
|
|
npm-script hook (see `package.json`) so it runs automatically on every `cap sync` —
|
|
including `build:mobile`, `dev:android`, and `cap run android` (which syncs first). No
|
|
dashboard-side change was needed: once the gesture reaches the native `backButton`
|
|
listener, gesture and button converge on the identical `fusion:native-back` dispatch.
|
|
|
|
Manual invocation (e.g. after a bare `npx cap sync` outside `build:mobile`):
|
|
|
|
```bash
|
|
pnpm --filter @fusion/mobile patch:android-manifest
|
|
```
|
|
|
|
### Mobile task-detail predictive-back transition (FN-7587)
|
|
|
|
FN-7583 (Android back-gesture parity) and FN-7586 (iOS edge-swipe-back parity) made native
|
|
"back" gestures **functionally** dismiss Fusion's mobile task-detail surfaces (board
|
|
main-panel, list-mobile, modal, and nested detail) through the dashboard's shared
|
|
nav-history invariant (`useNavigationHistory` / `popstate` / `fusion:native-back`). FN-7587
|
|
layers a **presentation-only** slide/fade transition on top of that unchanged routing:
|
|
|
|
- Mobile/native-only — the transition is gated to the mobile viewport (`<= 768px`, matching
|
|
the existing `isMobile`/`OVERSIGHT_MENU_MOBILE_BREAKPOINT` convention in the dashboard);
|
|
desktop task-detail never receives the animation class.
|
|
- Non-interactive — the transition is a short CSS `@keyframes` slide/fade (~200ms) triggered
|
|
by mount/prop-state change, not by gesture progress. It does **not** intercept, delay, or
|
|
reorder when the `useNavigationHistory` pop / `fusion:native-back` / empty-stack fallback
|
|
fires; the animation is purely a CSS class applied to the already-real DOM node.
|
|
- Honors `prefers-reduced-motion: reduce` (neutralizes to an instant, transform-free show),
|
|
mirroring the dashboard's existing reduced-motion convention (`WorkflowSwitcher.css`,
|
|
`TopProgressBar.css`).
|
|
- **Interactive predictive-back is not implemented** and is not feasible today from a
|
|
Capacitor single-page WebView on either platform: iOS's `allowsBackForwardNavigationGestures`
|
|
gesture (used by FN-7586) exposes only a discrete `popstate` on commit, with no
|
|
interactive-progress callback reachable from JS; Android's OS-owned predictive-back preview
|
|
animates outside the single-Activity WebView and is not driveable from in-page DOM. A
|
|
follow-up task is filed to revisit this if/when platform APIs expose gesture-progress
|
|
callbacks to JS.
|
|
|
|
Implementation lives entirely in the dashboard package (`packages/dashboard/app/styles.css`,
|
|
`packages/dashboard/app/components/TaskDetailModal.css`, `MainContent.tsx`,
|
|
`TaskDetailModal.tsx`) — no mobile-shell-native code changes were required for this task.
|
|
|
|
### Regression coverage locked by tests
|
|
|
|
`packages/mobile/src/__tests__/connection-profiles.test.ts`, `native-shell.test.ts`, and `qr-scanner.test.ts` now lock these contracts:
|
|
- first-run remote setup via QR/manual payloads (including optional auth token handling)
|
|
- saved-profile edit, active-profile switching, and persisted-state restore across module reinit/relaunch
|
|
- bridge reads (`getState`, `listProfiles`) plus connection-manager event dispatch
|
|
- malformed/empty QR payload handling and unavailable-scanner fallback behavior
|
|
|
|
## Native Back Handling (Android Back + iOS Edge-Swipe-Back)
|
|
|
|
Mobile task-detail dismissal converges on a SINGLE shared invariant regardless of how the
|
|
user triggers "back": the dashboard's `useNavigationHistory` nav-history stack
|
|
(`packages/dashboard/app/hooks/useNavigationHistory.ts`), which board main-panel,
|
|
list-mobile, modal, and nested task-detail surfaces already consume for desktop/browser
|
|
swipe-back via ordinary `popstate`.
|
|
|
|
- **Android hardware Back button + gesture (FN-7583):** `AndroidBackButtonManager` in
|
|
`src/plugins/native-shell.ts` listens for `@capacitor/app`'s `backButton` event and
|
|
dispatches a cancelable `fusion:native-back` custom event; `useNavigationHistory` consumes
|
|
it to dismiss the top task-detail surface (falling back to native browser-history `back()`
|
|
or app exit when Fusion owns no nav entry). The Android 13+ predictive-back **gesture**
|
|
additionally requires the generated `AndroidManifest.xml` to opt into
|
|
`android:enableOnBackInvokedCallback="true"` for AndroidX's dispatcher to route the
|
|
gesture-completion callback to the same `backButton` listener — since `android/` is
|
|
generated by `cap sync` and git-ignored, this opt-in lives in a tracked post-`cap sync`
|
|
patch (`scripts/patch-android-manifest.ts`), wired into the `capacitor:sync:after` npm hook.
|
|
- **iOS edge-swipe-back (FN-7586):** iOS has **no** Capacitor `backButton` event, so it does
|
|
NOT go through `fusion:native-back`. Instead, the WKWebView edge-swipe-back gesture (once
|
|
enabled) drives ordinary back-forward navigation, which the browser delivers as a
|
|
`popstate` — the exact same seam desktop/browser swipe-back already uses, and that
|
|
`useNavigationHistory.handlePopState` already consumes correctly. The gesture itself is OFF
|
|
by default: `WKWebView.allowsBackForwardNavigationGestures` defaults to `false`, Capacitor's
|
|
`CAPBridgeViewController` never sets it, and no `capacitor.config.ts` `ios` toggle exists
|
|
for it. Since `ios/` is also generated by `cap sync` and git-ignored, this is enabled via a
|
|
tracked post-`cap sync` patch (`scripts/patch-ios-webview.ts`) that idempotently patches the
|
|
generated `AppDelegate.swift` to set `webView?.allowsBackForwardNavigationGestures = true`
|
|
once the storyboard's root `CAPBridgeViewController` has loaded — also wired into the same
|
|
`capacitor:sync:after` npm hook. Empty-stack behavior (no Fusion nav entry) is unaffected:
|
|
the gesture just performs ordinary WKWebView history navigation with no forced trap/exit.
|
|
|
|
Both patch scripts are safe no-ops when their respective native platform directory
|
|
(`android/` / `ios/`) hasn't been added yet, and are idempotent across repeated `cap sync`
|
|
runs (see `src/__tests__/native-shell.test.ts`).
|
|
|
|
## Push Notifications
|
|
|
|
`PushNotificationManager` supports two complementary notification channels:
|
|
|
|
1. **Native push notifications** via Capacitor Push Notifications (`@capacitor/push-notifications`) for FCM/APNs token registration and notification tap handling.
|
|
2. **ntfy.sh streaming subscription** via polling-driven topic management, so the app can receive in-app notifications without server-side FCM/APNs setup.
|
|
|
|
### Initialization
|
|
|
|
```ts
|
|
import { PushNotificationManager } from "@fusion/mobile";
|
|
|
|
const manager = new PushNotificationManager({
|
|
settingsFetcher: fetchGlobalSettings,
|
|
});
|
|
|
|
await manager.start();
|
|
```
|
|
|
|
You can also initialize through `initializePlugins({ pushNotifications: { ... } })` if you want plugin bootstrapping from a single entrypoint.
|
|
|
|
### Event API
|
|
|
|
```ts
|
|
manager.on("notification:tapped", ({ taskId }) => {
|
|
if (taskId) {
|
|
navigateToTask(taskId);
|
|
}
|
|
});
|
|
|
|
manager.on("notification:received", ({ title, body }) => {
|
|
console.log("Foreground notification", title, body);
|
|
});
|
|
|
|
manager.on("ntfy:message", ({ taskId, message }) => {
|
|
console.log("ntfy message", taskId, message);
|
|
});
|
|
```
|
|
|
|
### ntfy.sh Integration Behavior
|
|
|
|
When `settingsFetcher()` returns:
|
|
|
|
- `ntfyEnabled: true`
|
|
- `ntfyTopic: "<topic>"`
|
|
|
|
…the manager starts (or switches) a live subscription to `{ntfyBaseUrl}/{topic}/json`.
|
|
|
|
If settings disable ntfy or clear the topic, the subscription is automatically stopped.
|
|
|
|
### Device Token Access
|
|
|
|
Use `manager.getDeviceToken()` after registration to retrieve the native device token for future server-side FCM/APNs integration work.
|
|
|
|
### Out of Scope
|
|
|
|
This package currently handles **receiving** push notifications and in-app routing events only.
|
|
|
|
Server-side FCM/APNs delivery infrastructure (token storage, provider credentials, push sending services) is intentionally out of scope for this feature.
|
|
|
|
## Native Sharing & Deep Links
|
|
|
|
### ShareManager
|
|
|
|
`ShareManager` opens platform-native sharing when available and always includes a Fusion deep link in the shared payload.
|
|
|
|
```ts
|
|
import { ShareManager } from "@fusion/mobile";
|
|
|
|
const manager = new ShareManager();
|
|
await manager.initialize();
|
|
|
|
await manager.shareTask({
|
|
id: "FN-1118",
|
|
title: "Mobile Plugins - Native Sharing & Deep Links",
|
|
description: "Implements native share sheet support and deep link parsing.",
|
|
});
|
|
```
|
|
|
|
#### Share behavior + fallbacks
|
|
|
|
- Builds a payload with:
|
|
- `title`: `task.title` or fallback `Task {id}`
|
|
- `text`: task description (truncated to 200 chars with `...` when needed)
|
|
- `url`: `${deepLinkBaseUrl}{task.id}` (default base: `fusion://task/`)
|
|
- **Native (Capacitor)**: uses `@capacitor/share`
|
|
- **Web fallback**: uses `navigator.share(...)` when available
|
|
- **Final fallback**: copies the deep-link URL to `navigator.clipboard.writeText(...)`
|
|
|
|
#### Share events
|
|
|
|
- `share:success` → `{ taskId }`
|
|
- `share:cancelled` → `{ taskId }`
|
|
- `share:error` → `{ taskId, error }`
|
|
|
|
### DeepLinkManager
|
|
|
|
`DeepLinkManager` handles incoming links and emits parsed payloads for app-level navigation.
|
|
|
|
```ts
|
|
import { DeepLinkManager } from "@fusion/mobile";
|
|
|
|
const deepLinks = new DeepLinkManager({
|
|
scheme: "fusion://",
|
|
universalLinkHosts: ["app.fusion.dev"],
|
|
});
|
|
|
|
await deepLinks.initialize();
|
|
|
|
deepLinks.on("deeplink:received", (payload) => {
|
|
// route to screen/task/project in app UI
|
|
console.log(payload);
|
|
});
|
|
```
|
|
|
|
#### Supported URL patterns
|
|
|
|
- `fusion://task/{taskId}`
|
|
- `fusion://project/{projectId}`
|
|
- `fusion://project/{projectId}/task/{taskId}`
|
|
- `fusion://settings`
|
|
- `fusion://agents`
|
|
- Query params are preserved in `payload.params` for custom-scheme links
|
|
|
|
Universal links are supported when the host is allowed in `universalLinkHosts`, e.g.:
|
|
|
|
- `https://app.fusion.dev/?task=FN-123`
|
|
- `https://app.fusion.dev/?project=my-project&task=FN-123&target=task`
|
|
|
|
#### Deep link events
|
|
|
|
- `deeplink:received` → parsed `DeepLinkPayload`
|
|
- `deeplink:error` → `{ url, error }`
|
|
|
|
Use `handleUrl(url)` for programmatic handling (for example, push-notification tap flows that already provide a URL string).
|
|
|
|
### Integration flow: share -> open -> navigate
|
|
|
|
A common flow is:
|
|
|
|
1. Use `ShareManager.shareTask(...)` to share a task link like `fusion://task/FN-123`
|
|
2. Recipient opens that link on mobile
|
|
3. `DeepLinkManager` receives/parses the URL
|
|
4. Your UI listens to `deeplink:received` and navigates to the matching task view
|
|
|
|
### Capacitor deep-link scheme registration
|
|
|
|
The Fusion mobile app registers the custom URL scheme in `packages/dashboard/capacitor.config.ts`:
|
|
|
|
- `server.iosScheme = "fusion"`
|
|
- `server.androidScheme = "fusion"`
|
|
|
|
### Browser hash listener (development/testing)
|
|
|
|
On non-native platforms, `DeepLinkManager` listens for hash changes in the form:
|
|
|
|
- `#deeplink=<encoded-url>`
|
|
|
|
This hash-based behavior is intended for development/testing only and is not a production universal-link replacement.
|