From 87f9854635226b2ae10f8cee410de8a1f857b54b Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Wed, 24 Jun 2026 18:19:29 +0700 Subject: [PATCH 01/96] docs(spec): Android mobile app v1 design --- .../2026-06-24-android-mobile-app-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-android-mobile-app-design.md diff --git a/docs/superpowers/specs/2026-06-24-android-mobile-app-design.md b/docs/superpowers/specs/2026-06-24-android-mobile-app-design.md new file mode 100644 index 00000000..0053d9be --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-android-mobile-app-design.md @@ -0,0 +1,203 @@ +# Android Mobile App (v1) — Design Spec + +**Date:** 2026-06-24 +**Status:** Approved (architecture); ready to plan + +## Overview + +Add **Android** as a target of the existing `app/` Flutter project, shipping a +focused **mobile SSH client** (v1). The app reuses the existing +platform-agnostic core (models, providers, SSH/SFTP/sync/storage services) and +adds a new mobile UI layer plus a small set of platform abstractions so the +four desktop-only concerns (window control, global hotkeys, desktop +notifications, local PTY) no longer leak into the Android build. iOS is +explicitly deferred but the structure must not preclude it. + +Direction: a **companion mobile SSH client** — pull hosts from the desktop via +sync, connect, work in the terminal, browse files. Not a full port. + +## Scope + +**In scope (v1):** +- New `android/` target (`flutter create --platforms=android .`) +- Multi-session SSH terminal (xterm fork) with a mobile **accessory key bar** +- Host management (CRUD, groups, tags, search) — reuses `HostProvider` +- Key management + 3 of the 4 auth methods that make sense on mobile: password, + private key, certificate. **SSH agent auth is desktop-only** (no + `SSH_AUTH_SOCK` / Windows pipe on Android) — hidden on mobile, not removed + from the model. +- Known-hosts TOFU — reuses `KnownHostsProvider` + trust dialog +- Secure storage on **Android Keystore** (`flutter_secure_storage` already + supports it) +- **Sync (import hosts from desktop):** Cloud Supabase sync + P2P QR sync + (`mobile_scanner` already a dependency) +- **SFTP browser** — single-panel mobile layout (remote only); browse, upload + (SAF picker), download (SAF save), open/view, rename, delete, permissions +- **Snippets + command history** — insert into the active session +- **Biometric app-lock** (`local_auth`) — gate app open + resume from + background; mobile-only re-introduction of `local_auth` +- Terminal appearance settings (44 themes, font, size) — reuses + `resolveTerminalAppearance` / `TerminalAppearanceControls` + +**Out of scope (v1):** +- RDP / VNC (Rust FFI `.so` cross-compile via Android NDK) — large, deferred +- Port forwarding (Android background limits make this its own design) +- Local shell / PTY tabs (`flutter_pty`) — no local shell on mobile +- DevOps / WebTools / Script-engine plugins, MCP gateway, Cloudflare tunnel, + mail catcher, S3, server monitor, network discovery +- Monaco code editor (`webview_flutter` works on Android, but defer) +- Split view, broadcast, global hotkeys, session recording, audit-log UI, + AI chat sidebar +- iOS target + +## Architecture + +### Codebase layering (single `app/`, runtime branch) + +Most of the existing tree is platform-agnostic and reused **as-is**. We +introduce a clear split between shared code, the existing desktop UI, and the +new mobile UI, and branch at the app root by platform. + +``` +app/lib/ + main.dart # branch: Platform.isAndroid ? MobileApp : DesktopApp + models/ # shared, unchanged + providers/ # shared, unchanged (ChangeNotifier) + services/ # shared, unchanged where platform-agnostic + platform/ # NEW — abstractions for desktop-only concerns + window_chrome.dart # interface + desktop (window_manager) + mobile (no-op) + global_hotkeys.dart # interface + desktop (hotkey_manager) + mobile (no-op) + app_notifier.dart # interface + desktop (local_notifier) + mobile (flutter_local_notifications | no-op) + local_shell_gate.dart # local PTY only constructed on desktop + desktop/ # existing screens/ + widgets/ moved here (or kept as-is initially) + mobile/ # NEW — mobile screens + widgets + mobile_app.dart # MaterialApp + bottom-nav shell + MultiProvider reuse + screens/ # hosts, sessions, sftp, settings + widgets/ # terminal accessory bar, mobile session view, sftp list, ... + theme/ # shared (dark-only AppColors) +``` + +`main.dart` builds the same `MultiProvider` (shared providers) and the same +service instances, then mounts `MobileApp` on Android and the existing +`MainScreen` host on desktop. Service wiring callbacks (key lookup, host-key +verifier, sync-on-mutation) are shared; only the desktop-only abstractions get +a mobile implementation. + +**Why runtime branch, not a second entrypoint:** keeps one `MultiProvider` +graph and one set of provider wiring, avoids duplicating `main.dart`'s +callback plumbing. + +### Desktop-only dependency isolation + +The four desktop-only plugins are addressed as follows: + +| Concern | Plugin | Build impact on Android | Mobile handling | +|---|---|---|---| +| Window control | `window_manager` | Declares desktop platforms only → Android build skips its native; safe | `WindowChrome.noop` | +| Global hotkeys | `hotkey_manager` | Desktop-only → skipped | `GlobalHotkeys.noop` (in-app shortcuts only) | +| Desktop notifications | `local_notifier` | Desktop-only → skipped | `AppNotifier` via `flutter_local_notifications` (or no-op for v1) | +| Local PTY | `flutter_pty` (fork) | Fork **declares android/ios** (ffiPlugin) → builds fine; just never invoked on mobile | `LocalShell` not constructed on Android | + +All direct calls to these plugins currently in `main.dart`/widgets get routed +through the `platform/` abstractions so the mobile tree never imports the +desktop plugin directly. **Spike 1 (below) must confirm the Android Gradle +build succeeds** with these dependencies present. + +## Mobile UI + +### Navigation + +Bottom navigation with four destinations: + +- **Hosts** — searchable list (compact rows), group/tag chips, FAB to add a + host, host detail editor (mobile form), tap-to-connect. +- **Sessions** — the live terminals; a horizontally scrollable session tab + strip + the active `TerminalView`. +- **SFTP** — single remote panel (pick a connected host), breadcrumb, list, + transfers. +- **Settings** — terminal appearance, sync, app-lock, updates, about. + +### Terminal (the core UX) + +- Renders the **xterm fork** `TerminalView` (works on Android). +- **Accessory key bar** docked above the soft keyboard: `Esc`, `Tab`, `Ctrl`, + `Alt`, arrow keys, `/ - | ~`, `Ctrl+C`, and a Snippets/history launcher. + `Ctrl`/`Alt` are sticky modifiers (tap to arm, applies to next key). +- **Pinch-to-zoom** adjusts font size (persisted on release, like desktop). +- Mobile-native text selection → copy; paste from the accessory bar / context + menu. +- Session lifecycle reuses `SessionProvider`; auto-reconnect on resume. + +### SFTP (single panel) + +Reuses `SftpPanelProvider`, `SftpFileOpsService`, `SftpTransferService`. +Upload uses the SAF file picker (`file_selector`); download writes via SAF. +Context menu: Open / View / Rename / Delete / Permissions / New folder. The +dual-panel "copy to target" matrix is desktop-only and omitted. + +## Security + +- **Secure storage:** `flutter_secure_storage` → Android Keystore. Keys + unchanged (`pw_`, `pp_`, `sync_passphrase`). +- **TOFU:** unchanged — `KnownHostsProvider` + trust dialog adapted to mobile. +- **App-lock:** `local_auth` (fingerprint/face). Gate on app launch and on + return from background after a configurable timeout (default: immediate). + Lifecycle hook via `WidgetsBindingObserver`. Fallback to device PIN where + biometrics are unavailable; setting to disable. + **Known tradeoff:** desktop dropped `local_auth` in 0.1.35 to shrink bundles; + with a single shared `pubspec.yaml`, re-adding it for mobile reintroduces its + native plugins to the desktop bundles too. v1 decision: accept the small + re-add (and only invoke `local_auth` on Android); revisit per-platform + dependency isolation only if the desktop bundle-size delta proves material. +- **Sync:** existing client-side AES-256-GCM cloud sync and P2P QR + (`P2PSyncEncryption`) reused verbatim. + +## Milestones + +1. **M1 — Android target + build green.** `flutter create --platforms=android`, + `platform/` abstractions, route desktop-plugin calls through them, branch in + `main.dart`, empty `MobileApp` shell. **Exit:** debug APK builds and runs; + desktop builds unchanged; `flutter analyze` clean. +2. **M2 — Terminal MVP.** Hosts list + detail + connect, mobile `TerminalView`, + accessory key bar, multi-session strip, pinch-zoom. **Exit:** connect to a + real SSH host and work interactively. +3. **M3 — Sync import.** Cloud sync + P2P QR scan import hosts/keys from + desktop. **Exit:** scan desktop QR → hosts appear → connect. +4. **M4 — SFTP + snippets/history.** Single-panel SFTP with SAF transfers; + snippets + command-history insert. +5. **M5 — Security + polish.** Biometric app-lock, TOFU dialog, appearance + settings, in-app update check (or hide), release APK signing. + +## Risks & spikes (do first) + +- **Spike 1 (M1, blocking):** add the Android target and build a debug APK with + all current dependencies present — confirm `window_manager` / + `hotkey_manager` / `local_notifier` / `flutter_pty` / `sqlite3_flutter_libs` + / `webview_flutter` don't break the Gradle build. Expectation: green (desktop + plugins skip Android native), but verify before building UI. +- **Spike 2 (M2):** validate xterm-fork rendering + soft-keyboard input + the + accessory key bar against a real SSH session early — this is the highest UX + risk. +- **Background execution:** Android may kill SSH sockets when the app is + backgrounded. v1 accepts this and auto-reconnects on resume; a foreground + service is a later consideration, not v1. +- **Min SDK / NDK:** no native cross-compile needed in v1 (RDP/VNC deferred). + Pick a sane `minSdkVersion` (Flutter default) and document it. +- **Regression guard:** every change to shared code must keep desktop + (macOS/Windows/Linux) building and tests passing. + +## Testing + +- Reuse existing unit tests for shared services/providers (must stay green). +- New widget tests for: accessory key bar (modifier/sticky behavior, key + emission), mobile session view, single-panel SFTP list. +- Manual: a documented checklist per milestone exit criterion (real SSH host, + real desktop→mobile QR sync). +- `flutter analyze` clean across the whole repo. + +## Success criteria (v1) + +Install the APK on Android → import hosts from desktop via sync → connect over +SSH → type fluently using the accessory key bar → browse and transfer files +over SFTP → insert a snippet → app re-locks behind biometrics on resume. +`flutter analyze` is clean and the desktop builds are unaffected. From 5636ef73c0a0b595cc23e20998df2804bf9fe5d9 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Wed, 24 Jun 2026 20:25:33 +0700 Subject: [PATCH 02/96] docs(plan): Android mobile app M1 implementation plan --- .../plans/2026-06-24-android-mobile-app-m1.md | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-android-mobile-app-m1.md diff --git a/docs/superpowers/plans/2026-06-24-android-mobile-app-m1.md b/docs/superpowers/plans/2026-06-24-android-mobile-app-m1.md new file mode 100644 index 00000000..8833f233 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-android-mobile-app-m1.md @@ -0,0 +1,485 @@ +# Android Mobile App — Milestone 1 (Target + Platform Branch) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an Android target to the existing `app/` Flutter project and make a debug APK launch into an empty mobile shell, without regressing the desktop builds. + +**Architecture:** The whole widget tree branches at the app root: `main()` mounts the existing desktop `YourSSHApp` on desktop and a new `YourSSHMobileApp` on Android. Because the trees are fully separate, desktop-only plugin calls inside desktop widgets (`window_manager`, `hotkey_manager` in `main_screen.dart`, `terminal_view.dart`, etc.) never execute on mobile — so M1 only needs to gate the *shared* bootstrap in `main()` and provide a minimal mobile root. The fuller `platform/` abstraction classes from the spec (`WindowChrome`, `GlobalHotkeys`, `AppNotifier`, `LocalShell`) are introduced later, when a mobile screen actually needs that capability; M1 ships only `runtime_platform.dart` (platform detection). + +**Tech Stack:** Flutter, Dart, `provider`, existing `AppColors` theme. No new dependencies in M1. + +## Global Constraints + +- Project name (Dart package): `yourssh` — imports are `package:yourssh/...`. +- Android `applicationId` / `--org`: `com.thangnm` → `com.thangnm.yourssh` (matches macOS `com.thangnm.yourssh`). +- App is **dark-only** (`ThemeMode.dark`); colors come from `app/lib/theme/app_theme.dart` (`AppColors`). Never introduce a light theme. +- Desktop builds (macOS/Windows/Linux) MUST keep building; `flutter analyze` MUST stay clean across the repo. +- No Claude attribution anywhere (commits, comments, docs). +- Commit after each task. Work on branch `feat/android-mobile-app` (already checked out). +- **Prerequisite (one-time, user-run):** Android SDK licenses must be accepted before an APK can build — `flutter doctor --android-licenses`. If unaccepted, code/test tasks still complete; only the final build/run step is blocked. + +--- + +### Task 1: Generate the Android target + +**Files:** +- Create: `app/android/**` (generated) +- Modify: none by hand in this task + +**Interfaces:** +- Consumes: nothing +- Produces: an `app/android/` Gradle project with `applicationId "com.thangnm.yourssh"` + +- [ ] **Step 1: Generate the android platform files** + +Run (from repo root): +```bash +cd app && flutter create --platforms=android --org com.thangnm --project-name yourssh . +``` +Expected: creates `app/android/` and prints "All done!". Existing `lib/`, `macos/`, `windows/`, `linux/`, `pubspec.yaml` are left intact (flutter create only adds the missing platform). + +- [ ] **Step 2: Verify the target was added and desktop dirs untouched** + +Run: +```bash +cd app && ls android/app/src/main/AndroidManifest.xml && grep -r 'applicationId' android/app/build.gradle* +``` +Expected: the manifest path prints, and `applicationId = "com.thangnm.yourssh"` (or `applicationId "com.thangnm.yourssh"`) appears. + +- [ ] **Step 3: Verify desktop still analyzes clean** + +Run: +```bash +cd app && flutter pub get && flutter analyze +``` +Expected: `No issues found!` (or unchanged from before — no NEW issues). + +- [ ] **Step 4: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/android app/pubspec.lock app/.metadata 2>/dev/null; git add -A app/android +git commit -m "build(android): add Android target via flutter create" +``` + +--- + +### Task 2: Platform detection helper (`runtime_platform.dart`) + +**Files:** +- Create: `app/lib/platform/runtime_platform.dart` +- Test: `app/test/platform/runtime_platform_test.dart` + +**Interfaces:** +- Consumes: `package:flutter/foundation.dart` (`defaultTargetPlatform`, `TargetPlatform`) +- Produces: `bool get isMobilePlatform` — true on Android/iOS, false on desktop. Used by `main()` (Task 4). + +- [ ] **Step 1: Write the failing test** + +Create `app/test/platform/runtime_platform_test.dart`: +```dart +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/platform/runtime_platform.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + test('isMobilePlatform is true on Android', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(isMobilePlatform, isTrue); + }); + + test('isMobilePlatform is true on iOS', () { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect(isMobilePlatform, isTrue); + }); + + test('isMobilePlatform is false on macOS', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + expect(isMobilePlatform, isFalse); + }); + + test('isMobilePlatform is false on Windows and Linux', () { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + expect(isMobilePlatform, isFalse); + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + expect(isMobilePlatform, isFalse); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd app && flutter test test/platform/runtime_platform_test.dart +``` +Expected: FAIL — `Error: Couldn't resolve the package 'yourssh' ... runtime_platform.dart` / target of URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/platform/runtime_platform.dart`: +```dart +import 'package:flutter/foundation.dart'; + +/// True when running on a mobile platform (Android/iOS). +/// +/// Drives the desktop-vs-mobile branch in `main()` and gates the desktop-only +/// bootstrap (window_manager / hotkey_manager / local_notifier). Uses +/// [defaultTargetPlatform] (not `dart:io` `Platform`) so it is overridable in +/// widget/unit tests via `debugDefaultTargetPlatformOverride`. +bool get isMobilePlatform => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: +```bash +cd app && flutter test test/platform/runtime_platform_test.dart +``` +Expected: PASS — all 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/platform/runtime_platform.dart app/test/platform/runtime_platform_test.dart +git commit -m "feat(mobile): add isMobilePlatform runtime detection" +``` + +--- + +### Task 3: Minimal mobile root (`YourSSHMobileApp` + bottom-nav shell) + +**Files:** +- Create: `app/lib/mobile/mobile_app.dart` +- Create: `app/lib/mobile/screens/mobile_home_shell.dart` +- Test: `app/test/mobile/mobile_home_shell_test.dart` + +**Interfaces:** +- Consumes: `AppColors` from `package:yourssh/theme/app_theme.dart` +- Produces: + - `class YourSSHMobileApp extends StatelessWidget` — the mobile `MaterialApp` root, mounted by `main()` (Task 4). + - `class MobileHomeShell extends StatefulWidget` — a `Scaffold` with a 4-destination `NavigationBar` (Hosts / Sessions / SFTP / Settings). Each destination shows a centered placeholder for M1. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_home_shell_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; + +void main() { + testWidgets('shows four destinations and Hosts first', (tester) async { + await tester.pumpWidget(const MaterialApp(home: MobileHomeShell())); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text('Hosts'), findsWidgets); + expect(find.text('Sessions'), findsWidgets); + expect(find.text('SFTP'), findsWidgets); + expect(find.text('Settings'), findsWidgets); + + // Hosts tab body is shown first. + expect(find.text('Hosts — coming soon'), findsOneWidget); + }); + + testWidgets('tapping a destination switches the body', (tester) async { + await tester.pumpWidget(const MaterialApp(home: MobileHomeShell())); + + await tester.tap(find.text('Settings').last); + await tester.pumpAndSettle(); + + expect(find.text('Settings — coming soon'), findsOneWidget); + expect(find.text('Hosts — coming soon'), findsNothing); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd app && flutter test test/mobile/mobile_home_shell_test.dart +``` +Expected: FAIL — target of URI doesn't exist (`mobile_home_shell.dart`). + +- [ ] **Step 3: Write `MobileHomeShell`** + +Create `app/lib/mobile/screens/mobile_home_shell.dart`: +```dart +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; + +/// Bottom-navigation shell for the Android app. M1 renders placeholder bodies; +/// each destination is filled in over later milestones (M2 terminal, M3 sync, +/// M4 SFTP/snippets, M5 settings/app-lock). +class MobileHomeShell extends StatefulWidget { + const MobileHomeShell({super.key}); + + @override + State createState() => _MobileHomeShellState(); +} + +class _MobileHomeShellState extends State { + int _index = 0; + + static const _labels = ['Hosts', 'Sessions', 'SFTP', 'Settings']; + static const _icons = [ + Icons.dns_outlined, + Icons.terminal_outlined, + Icons.folder_outlined, + Icons.settings_outlined, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + child: Center( + child: Text( + '${_labels[_index]} — coming soon', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), + ), + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: [ + for (var i = 0; i < _labels.length; i++) + NavigationDestination(icon: Icon(_icons[i]), label: _labels[i]), + ], + ), + ); + } +} +``` + +- [ ] **Step 4: Write `YourSSHMobileApp`** + +Create `app/lib/mobile/mobile_app.dart`: +```dart +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; +import 'screens/mobile_home_shell.dart'; + +/// Root widget for the Android build. Dark-only, mirrors the desktop theme +/// surface. Providers/services are wired here starting in M2. +class YourSSHMobileApp extends StatelessWidget { + const YourSSHMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'YourSSH', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: AppColors.bg, + colorScheme: const ColorScheme.dark( + surface: AppColors.bg, + primary: AppColors.green, + ), + ), + home: const MobileHomeShell(), + ); + } +} +``` + +> NOTE: confirm the exact `AppColors` member names (`bg`, `green`, `textSecondary`) by opening `app/lib/theme/app_theme.dart`; if a name differs (e.g. `accent` vs `green`), use the real one. The test in Step 1 does not depend on colors. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: +```bash +cd app && flutter test test/mobile/mobile_home_shell_test.dart +``` +Expected: PASS — both tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/mobile app/test/mobile +git commit -m "feat(mobile): minimal mobile app shell with bottom nav" +``` + +--- + +### Task 4: Branch `main()` by platform and gate desktop-only bootstrap + +**Files:** +- Modify: `app/lib/main.dart` (the `main()` function, lines ~110–127; and the `runApp` call) + +**Interfaces:** +- Consumes: `isMobilePlatform` (Task 2), `YourSSHMobileApp` (Task 3) +- Produces: a `main()` that runs the desktop bootstrap only on desktop and mounts the right root per platform. + +- [ ] **Step 1: Add imports to `main.dart`** + +At the top of `app/lib/main.dart`, add (next to the existing imports): +```dart +import 'platform/runtime_platform.dart'; +import 'mobile/mobile_app.dart'; +``` + +- [ ] **Step 2: Gate the desktop-only init and branch `runApp`** + +Replace the body of `main()` (currently): +```dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + kAppVersion = (await PackageInfo.fromPlatform()).version; + await windowManager.ensureInitialized(); + const windowOptions = WindowOptions( + size: Size(1280, 800), + minimumSize: Size(800, 600), + center: true, + title: 'YourSSH', + ); + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); + await hotKeyManager.unregisterAll(); + await NotificationService.init(); + runApp(const YourSSHApp()); +} +``` +with: +```dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + kAppVersion = (await PackageInfo.fromPlatform()).version; + + if (isMobilePlatform) { + // Mobile: skip all desktop-only bootstrap (window_manager, hotkey_manager, + // local_notifier have no Android implementation and would throw). + runApp(const YourSSHMobileApp()); + return; + } + + await windowManager.ensureInitialized(); + const windowOptions = WindowOptions( + size: Size(1280, 800), + minimumSize: Size(800, 600), + center: true, + title: 'YourSSH', + ); + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); + await hotKeyManager.unregisterAll(); + await NotificationService.init(); + runApp(const YourSSHApp()); +} +``` + +- [ ] **Step 3: Verify analyze is clean** + +Run: +```bash +cd app && flutter analyze +``` +Expected: `No issues found!` (no unused-import warnings; both new imports are referenced). + +- [ ] **Step 4: Verify the full test suite still passes (desktop regression guard)** + +Run: +```bash +cd app && flutter test +``` +Expected: all tests pass (same as before the branch — `main.dart` changes don't touch desktop widget behavior). + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/main.dart +git commit -m "feat(mobile): branch main() to mobile app on Android" +``` + +--- + +### Task 5: Build & run the Android APK (spike — confirms desktop plugins don't break Gradle) + +**Files:** none (verification only) + +**Interfaces:** +- Consumes: everything above +- Produces: a launchable debug APK; confirmation that `window_manager`, `hotkey_manager`, `local_notifier`, `flutter_pty`, `sqlite3_flutter_libs`, `webview_flutter` do not break the Android Gradle build. + +- [ ] **Step 1: (User, one-time) accept Android SDK licenses** + +If not already done, the user runs in their session: +``` +! flutter doctor --android-licenses +``` +(Accept each prompt.) Then confirm `flutter doctor` shows the Android toolchain without `[!]` for licenses. + +- [ ] **Step 2: Build the debug APK** + +Run: +```bash +cd app && flutter build apk --debug +``` +Expected: `✓ Built build/app/outputs/flutter-apk/app-debug.apk`. If Gradle fails on a desktop-only plugin, that is the spike's finding — capture the error and address it (most likely none, since those plugins declare no Android platform; `flutter_pty` fork declares Android and should build). + +- [ ] **Step 3: Run on a device/emulator (if available)** + +Run: +```bash +cd app && flutter devices +``` +If an Android device/emulator is listed: +```bash +cd app && flutter run -d +``` +Expected: the app launches and shows the bottom nav with "Hosts — coming soon"; tapping destinations switches the placeholder. No crash on launch (confirms the desktop-only init was correctly skipped). + +- [ ] **Step 4: Desktop regression check** + +Run: +```bash +cd app && flutter build macos --debug +``` +Expected: builds successfully (desktop bootstrap unchanged). + +- [ ] **Step 5: Commit any Gradle/config fixes discovered** + +Only if Step 2/3 required changes (e.g. `minSdkVersion` bump, NDK setting). Otherwise nothing to commit. + +```bash +git add -A app/android && git commit -m "build(android): " +``` + +--- + +## Self-Review + +**Spec coverage (M1 portion of the spec):** +- "New `android/` target (`flutter create --platforms=android .`)" → Task 1. ✓ +- "branch at the app root by platform" (`main.dart`) → Task 4. ✓ +- "`platform/` … platform detection" → Task 2 (`runtime_platform.dart`); fuller abstraction classes deliberately deferred (documented in Architecture). ✓ +- "empty `MobileApp` shell" + bottom nav (Hosts/Sessions/SFTP/Settings) → Task 3. ✓ +- Spike 1 ("build a debug APK … confirm desktop plugins don't break Gradle") → Task 5. ✓ +- "desktop builds unchanged; `flutter analyze` clean" → Tasks 1/4 steps + Task 5 Step 4. ✓ + +**Placeholder scan:** No "TBD/TODO/handle edge cases" steps; every code step shows full code. The one NOTE (AppColors member names) is a verify-the-real-name instruction with a concrete fallback, not a placeholder. + +**Type consistency:** `isMobilePlatform` (Task 2) used verbatim in Task 4. `YourSSHMobileApp` (Task 3 `mobile_app.dart`) imported and constructed in Task 4. `MobileHomeShell` (Task 3) referenced only within `mobile_app.dart`. Names consistent. + +## Next milestones (separate plans, written after M1 lands) + +- **M2** — Hosts list + detail + connect; mobile `TerminalView`; accessory key bar (sticky Ctrl/Alt); multi-session strip; pinch-zoom. Introduces the shared provider bootstrap (extract from `_YourSSHAppState`). +- **M3** — Cloud + P2P QR sync import. +- **M4** — Single-panel SFTP (SAF transfers) + snippets/history insert. +- **M5** — Biometric app-lock (`local_auth`), TOFU dialog, appearance settings, release APK signing. From 0fbd6306c8f6535e55181547cf637ee44ff0011b Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Wed, 24 Jun 2026 20:26:53 +0700 Subject: [PATCH 03/96] build(android): add Android target via flutter create --- app/.metadata | 2 +- app/android/.gitignore | 14 ++ app/android/app/build.gradle.kts | 45 +++++ app/android/app/src/debug/AndroidManifest.xml | 7 + app/android/app/src/main/AndroidManifest.xml | 45 +++++ .../com/thangnm/yourssh/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 ++ .../main/res/drawable/launch_background.xml | 12 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 ++ .../app/src/main/res/values/styles.xml | 18 ++ .../app/src/profile/AndroidManifest.xml | 7 + app/android/build.gradle.kts | 24 +++ app/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + app/android/settings.gradle.kts | 26 +++ app/pubspec.lock | 180 +++++++++++++----- 21 files changed, 379 insertions(+), 47 deletions(-) create mode 100644 app/android/.gitignore create mode 100644 app/android/app/build.gradle.kts create mode 100644 app/android/app/src/debug/AndroidManifest.xml create mode 100644 app/android/app/src/main/AndroidManifest.xml create mode 100644 app/android/app/src/main/kotlin/com/thangnm/yourssh/MainActivity.kt create mode 100644 app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/values-night/styles.xml create mode 100644 app/android/app/src/main/res/values/styles.xml create mode 100644 app/android/app/src/profile/AndroidManifest.xml create mode 100644 app/android/build.gradle.kts create mode 100644 app/android/gradle.properties create mode 100644 app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 app/android/settings.gradle.kts diff --git a/app/.metadata b/app/.metadata index 7a36f9ba..cda28f33 100644 --- a/app/.metadata +++ b/app/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: linux + - platform: android create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 00000000..f6417530 --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.thangnm.yourssh" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.thangnm.yourssh" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..569c7681 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/thangnm/yourssh/MainActivity.kt b/app/android/app/src/main/kotlin/com/thangnm/yourssh/MainActivity.kt new file mode 100644 index 00000000..64268f1c --- /dev/null +++ b/app/android/app/src/main/kotlin/com/thangnm/yourssh/MainActivity.kt @@ -0,0 +1,5 @@ +package com.thangnm.yourssh + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 00000000..e96108cf --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2d428bfb --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 00000000..c21f0c5b --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/app/pubspec.lock b/app/pubspec.lock index 5d971df6..c51238c2 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: app_links - sha256: a350a5b37579b7227aaf9a59c07114617cd4283852e193f743b2b3d2d7483c18 + sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe" url: "https://pub.dev" source: hosted - version: "7.1.1" + version: "7.2.0" app_links_linux: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: app_links_platform_interface - sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" app_links_web: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" collection: dependency: transitive description: @@ -156,10 +156,26 @@ packages: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" equatable: dependency: transitive description: @@ -302,10 +318,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.34" + version: "2.0.35" flutter_pty: dependency: "direct main" description: @@ -404,18 +420,18 @@ packages: dependency: transitive description: name: functions_client - sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + sha256: f3304ca860169f2e8acc5eceb0cd5d788cdac9551497b8326313a8130c14f24e url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.1" gotrue: dependency: transitive description: name: gotrue - sha256: "7a4172601553e61716f5c3dd243aa3297e13308e07eb85b7853c941ba585dcf5" + sha256: a6120dee1e77d0e31a788d3db761c4ae514be8a9662beec0b7c883e74e391091 url: "https://pub.dev" source: hosted - version: "2.20.0" + version: "2.22.0" gtk: dependency: transitive description: @@ -428,10 +444,10 @@ packages: dependency: transitive description: name: hooks - sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.2" hotkey_manager: dependency: "direct main" description: @@ -625,10 +641,10 @@ packages: dependency: "direct main" description: name: multicast_dns - sha256: de72ada5c3db6fdd6ad4ae99452fe05fb403c4bb37c67ceb255ddd37d2b5b1eb + sha256: "134668a9605c747322d8c3eb0e89a4c0cbdedb29f8d2d5ae29d14daef724987b" url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.3+1" nested: dependency: transitive description: @@ -693,6 +709,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + passkeys: + dependency: transitive + description: + name: passkeys + sha256: "09e55fed6ff40499e6bb901da13ab58985e966b05fb91da8323ac081683e97ba" + url: "https://pub.dev" + source: hosted + version: "2.20.0" + passkeys_android: + dependency: transitive + description: + name: passkeys_android + sha256: "4286322398df7cc74b7503e9d638fab7885e56986689a2f683731da473f23f85" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + passkeys_darwin: + dependency: transitive + description: + name: passkeys_darwin + sha256: "3a9008714d392af79d93f5a657fa93b76edea515e2cf71e25da3567aec663ae7" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + passkeys_doctor: + dependency: transitive + description: + name: passkeys_doctor + sha256: "83cbed79bff4b63b54a3f3b5d80aa8f68c9009b6a85fb432b5bb7918bb47c681" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + passkeys_platform_interface: + dependency: transitive + description: + name: passkeys_platform_interface + sha256: "10fd4dd6779f6ca928adc2289e05ce0877250e68f2cc584abd2e3fcd6952e454" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + passkeys_web: + dependency: transitive + description: + name: passkeys_web + sha256: aa32224b289601f6894f243b24d21e56f90d632711bb4db1dedf4ea29f562de4 + url: "https://pub.dev" + source: hosted + version: "2.9.0" + passkeys_windows: + dependency: transitive + description: + name: passkeys_windows + sha256: acea5ff790a8c6de0366d1b87dd591c0d6e6770bf17d62efbd9fac7747724cc7 + url: "https://pub.dev" + source: hosted + version: "0.1.1" path: dependency: "direct main" description: @@ -713,10 +785,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -745,10 +817,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -801,10 +873,10 @@ packages: dependency: transitive description: name: postgrest - sha256: "9d61b3d4a88fcf9424d400127c54d49ed1b56ec30838fc0a33a64f31d4e694cc" + sha256: "83bfb9cb4a8a15a01f087bf472e14760f722bf531498a9c428df100c649c028d" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.7.2" process: dependency: transitive description: @@ -857,10 +929,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: "7dfccf372d2f55aacfeefb6186f65a06f3ffae383fe042dbeef9d85d33487576" + sha256: ac96ac76522de7c7ac30eb99f50899186ff4ac7d426f6bc7997a002e24f0e3a2 url: "https://pub.dev" source: hosted - version: "2.7.3" + version: "2.8.0" record_use: dependency: transitive description: @@ -905,10 +977,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" url: "https://pub.dev" source: hosted - version: "2.4.23" + version: "2.4.26" shared_preferences_foundation: dependency: transitive description: @@ -998,10 +1070,10 @@ packages: dependency: transitive description: name: storage_client - sha256: "4801e8ca219a35e51cbb30589aba5306667ae8935b792504595a45273cef0b18" + sha256: "2079d182a3806a9a7be746fee6603bbea207adbc3cc6f11c670f5d1f3eb5263e" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.7" stream_channel: dependency: transitive description: @@ -1022,18 +1094,18 @@ packages: dependency: transitive description: name: supabase - sha256: "40e5a8833c8834e140ef53b60a6181849667eba9ca125acb7f8e24c6a769d418" + sha256: "04babf8ece84d9ca528a9d9187b37899ce38ffabb6cd4ef28c6db0e679b0aca9" url: "https://pub.dev" source: hosted - version: "2.10.6" + version: "2.13.0" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: c02ce58abcaf86cb8055ad40bfd98bbf5b93fed3b5b56b8220d88ed03842818b + sha256: c5f9cc95162fd057be851ce38a0d54a0391cecad3a6ddfce1314e47f89a382f9 url: "https://pub.dev" source: hosted - version: "2.12.4" + version: "2.15.0" sync_http: dependency: transitive description: @@ -1066,6 +1138,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + ua_client_hints: + dependency: transitive + description: + name: ua_client_hints + sha256: e6e302bb7c21d6a90023db61e0ec413f0810ce341bb7e2ab447ff5d61ec3d87e + url: "https://pub.dev" + source: hosted + version: "1.7.0" uni_platform: dependency: transitive description: @@ -1094,10 +1174,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 url: "https://pub.dev" source: hosted - version: "6.3.30" + version: "6.3.32" url_launcher_ios: dependency: transitive description: @@ -1174,10 +1254,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" url: "https://pub.dev" source: hosted - version: "1.2.5" + version: "1.2.6" vector_math: dependency: transitive description: @@ -1238,18 +1318,18 @@ packages: dependency: "direct main" description: name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4 url: "https://pub.dev" source: hosted - version: "4.13.1" + version: "4.14.0" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688 + sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490 url: "https://pub.dev" source: hosted - version: "4.12.0" + version: "4.13.0" webview_flutter_platform_interface: dependency: transitive description: @@ -1262,10 +1342,10 @@ packages: dependency: transitive description: name: webview_flutter_wkwebview - sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2" + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d url: "https://pub.dev" source: hosted - version: "3.25.1" + version: "3.26.0" win32: dependency: transitive description: @@ -1274,6 +1354,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" window_manager: dependency: "direct main" description: @@ -1317,10 +1405,10 @@ packages: dependency: transitive description: name: yet_another_json_isolate - sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" yourssh_devops: dependency: "direct main" description: From 44fcc1c828dfc7cba0b95e746a25ae1f0c65a5cd Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Wed, 24 Jun 2026 20:28:02 +0700 Subject: [PATCH 04/96] feat(mobile): add isMobilePlatform runtime detection --- app/lib/platform/runtime_platform.dart | 11 ++++++++ app/test/platform/runtime_platform_test.dart | 29 ++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 app/lib/platform/runtime_platform.dart create mode 100644 app/test/platform/runtime_platform_test.dart diff --git a/app/lib/platform/runtime_platform.dart b/app/lib/platform/runtime_platform.dart new file mode 100644 index 00000000..02981903 --- /dev/null +++ b/app/lib/platform/runtime_platform.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; + +/// True when running on a mobile platform (Android/iOS). +/// +/// Drives the desktop-vs-mobile branch in `main()` and gates the desktop-only +/// bootstrap (window_manager / hotkey_manager / local_notifier). Uses +/// [defaultTargetPlatform] (not `dart:io` `Platform`) so it is overridable in +/// widget/unit tests via `debugDefaultTargetPlatformOverride`. +bool get isMobilePlatform => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; diff --git a/app/test/platform/runtime_platform_test.dart b/app/test/platform/runtime_platform_test.dart new file mode 100644 index 00000000..95fa2557 --- /dev/null +++ b/app/test/platform/runtime_platform_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/platform/runtime_platform.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + test('isMobilePlatform is true on Android', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(isMobilePlatform, isTrue); + }); + + test('isMobilePlatform is true on iOS', () { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect(isMobilePlatform, isTrue); + }); + + test('isMobilePlatform is false on macOS', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + expect(isMobilePlatform, isFalse); + }); + + test('isMobilePlatform is false on Windows and Linux', () { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + expect(isMobilePlatform, isFalse); + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + expect(isMobilePlatform, isFalse); + }); +} From 5ee4e1037765f3f8ad300cd4c8bd9630047934a4 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Wed, 24 Jun 2026 20:29:08 +0700 Subject: [PATCH 05/96] feat(mobile): minimal mobile app shell with bottom nav --- app/lib/mobile/mobile_app.dart | 22 +++++++++ app/lib/mobile/screens/mobile_home_shell.dart | 48 +++++++++++++++++++ app/test/mobile/mobile_home_shell_test.dart | 28 +++++++++++ 3 files changed, 98 insertions(+) create mode 100644 app/lib/mobile/mobile_app.dart create mode 100644 app/lib/mobile/screens/mobile_home_shell.dart create mode 100644 app/test/mobile/mobile_home_shell_test.dart diff --git a/app/lib/mobile/mobile_app.dart b/app/lib/mobile/mobile_app.dart new file mode 100644 index 00000000..8e0980f7 --- /dev/null +++ b/app/lib/mobile/mobile_app.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; +import 'screens/mobile_home_shell.dart'; + +/// Root widget for the Android build. Dark-only; reuses the shared +/// [buildAppTheme] so the mobile surface matches desktop. Providers/services +/// are wired here starting in M2. +class YourSSHMobileApp extends StatelessWidget { + const YourSSHMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'YourSSH', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: buildAppTheme(), + home: const MobileHomeShell(), + ); + } +} diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart new file mode 100644 index 00000000..98111b9a --- /dev/null +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; + +/// Bottom-navigation shell for the Android app. M1 renders placeholder bodies; +/// each destination is filled in over later milestones (M2 terminal, M3 sync, +/// M4 SFTP/snippets, M5 settings/app-lock). +class MobileHomeShell extends StatefulWidget { + const MobileHomeShell({super.key}); + + @override + State createState() => _MobileHomeShellState(); +} + +class _MobileHomeShellState extends State { + int _index = 0; + + static const _labels = ['Hosts', 'Sessions', 'SFTP', 'Settings']; + static const _icons = [ + Icons.dns_outlined, + Icons.terminal_outlined, + Icons.folder_outlined, + Icons.settings_outlined, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + child: Center( + child: Text( + '${_labels[_index]} — coming soon', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), + ), + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: [ + for (var i = 0; i < _labels.length; i++) + NavigationDestination(icon: Icon(_icons[i]), label: _labels[i]), + ], + ), + ); + } +} diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart new file mode 100644 index 00000000..9cca9e2d --- /dev/null +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; + +void main() { + testWidgets('shows four destinations and Hosts first', (tester) async { + await tester.pumpWidget(const MaterialApp(home: MobileHomeShell())); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text('Hosts'), findsWidgets); + expect(find.text('Sessions'), findsWidgets); + expect(find.text('SFTP'), findsWidgets); + expect(find.text('Settings'), findsWidgets); + + // Hosts tab body is shown first. + expect(find.text('Hosts — coming soon'), findsOneWidget); + }); + + testWidgets('tapping a destination switches the body', (tester) async { + await tester.pumpWidget(const MaterialApp(home: MobileHomeShell())); + + await tester.tap(find.text('Settings').last); + await tester.pumpAndSettle(); + + expect(find.text('Settings — coming soon'), findsOneWidget); + expect(find.text('Hosts — coming soon'), findsNothing); + }); +} From 967a6c9b5419d37764b5c8bac0d84a845bd1ba50 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Wed, 24 Jun 2026 20:30:29 +0700 Subject: [PATCH 06/96] feat(mobile): branch main() to mobile app on Android --- app/lib/main.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/lib/main.dart b/app/lib/main.dart index 0660fc8b..068a3a8b 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -6,6 +6,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import 'package:yourssh_script_engine/yourssh_script_engine.dart'; +import 'platform/runtime_platform.dart'; +import 'mobile/mobile_app.dart'; import 'utils/bundled_plugin_installer.dart'; import 'providers/ai_chat_provider.dart'; import 'providers/command_history_provider.dart'; @@ -110,6 +112,14 @@ class _SshBridgeAdapter implements SshBridgeDelegate { void main() async { WidgetsFlutterBinding.ensureInitialized(); kAppVersion = (await PackageInfo.fromPlatform()).version; + + if (isMobilePlatform) { + // Mobile: skip all desktop-only bootstrap (window_manager, hotkey_manager, + // local_notifier have no Android implementation and would throw). + runApp(const YourSSHMobileApp()); + return; + } + await windowManager.ensureInitialized(); const windowOptions = WindowOptions( size: Size(1280, 800), From 27dd87dbd173c7d77e4e174f933799f5267c5181 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Wed, 24 Jun 2026 20:43:14 +0700 Subject: [PATCH 07/96] build(android): bump compileSdk to 36 for transitive plugins + ignore build artifacts --- app/android/.gitignore | 1 + app/android/app/build.gradle.kts | 4 +++- app/android/build.gradle.kts | 15 +++++++++++++++ packages/flutter_pty/android/.gitignore | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/flutter_pty/android/.gitignore diff --git a/app/android/.gitignore b/app/android/.gitignore index be3943c9..48a0036f 100644 --- a/app/android/.gitignore +++ b/app/android/.gitignore @@ -6,6 +6,7 @@ gradle-wrapper.jar /local.properties GeneratedPluginRegistrant.java .cxx/ +/build/ # Remember to never publicly share your keystore. # See https://flutter.dev/to/reference-keystore diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index f6417530..ae750c9f 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -6,7 +6,9 @@ plugins { android { namespace = "com.thangnm.yourssh" - compileSdk = flutter.compileSdkVersion + // Transitive plugins (flutter_plugin_android_lifecycle via file_picker) + // require compiling against API 36; the Flutter default is still 34. + compileSdk = 36 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index dbee657b..de7fd1d7 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -15,6 +15,21 @@ subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } +// Force every Android subproject (app + pub plugin modules such as file_picker) +// to compile against API 36. Some transitive plugins +// (flutter_plugin_android_lifecycle) now require compileSdk >= 36, while the +// Flutter SDK still defaults plugin modules to 34. Registered BEFORE the +// evaluationDependsOn(":app") block below so the afterEvaluate hook is in place +// before any subproject (including :app) is forced to evaluate. +subprojects { + afterEvaluate { + val androidExt = project.extensions.findByName("android") + if (androidExt is com.android.build.gradle.BaseExtension) { + androidExt.compileSdkVersion(36) + } + } +} + subprojects { project.evaluationDependsOn(":app") } diff --git a/packages/flutter_pty/android/.gitignore b/packages/flutter_pty/android/.gitignore new file mode 100644 index 00000000..23dbc9e4 --- /dev/null +++ b/packages/flutter_pty/android/.gitignore @@ -0,0 +1,3 @@ +.cxx/ +/build/ +/.gradle/ From cf9889e237c5beabf873d87cf1fa2774b159545e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:27:47 +0700 Subject: [PATCH 08/96] docs(plan): Android mobile app M2 implementation plan --- .../plans/2026-06-25-android-mobile-app-m2.md | 1335 +++++++++++++++++ 1 file changed, 1335 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-android-mobile-app-m2.md diff --git a/docs/superpowers/plans/2026-06-25-android-mobile-app-m2.md b/docs/superpowers/plans/2026-06-25-android-mobile-app-m2.md new file mode 100644 index 00000000..657ef352 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-android-mobile-app-m2.md @@ -0,0 +1,1335 @@ +# Android Mobile App — Milestone 2 (Hosts + Terminal) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On Android, list/add hosts, connect over SSH, and use a working terminal with a mobile accessory key bar and pinch-to-zoom. + +**Architecture:** A mobile-specific bootstrap (`lib/mobile/mobile_bootstrap.dart`) constructs the *existing* platform-agnostic services/providers (`StorageService`, `SshService`, `HostProvider`, `KeyProvider`, `SettingsProvider`, `KnownHostsProvider`, `SessionProvider`) and wires the minimal callbacks needed to connect — deliberately NOT touching the fragile desktop `_YourSSHAppState` bootstrap (zero desktop regression risk; the *classes* are reused, only the wiring is mobile-scoped). Mobile screens consume these via `provider`. The terminal renders the same xterm fork `Terminal` that `SshSession` already owns. + +**Tech Stack:** Flutter, `provider`, `xterm` (local fork), existing `SshService`/`SessionProvider`. + +## Global Constraints + +- Dart package `yourssh`; imports `package:yourssh/...` (tests) and relative (lib). +- App is dark-only; colors from `app/lib/theme/app_theme.dart` (`AppColors`: `bg`, `card`, `accent`, `textPrimary`, `textSecondary`, `border`). +- Reuse existing classes; do NOT modify desktop `main.dart` bootstrap or desktop widgets. +- TOFU: first-connect auto-trusts (`KnownHostsProvider.verifyHostKey` adds + returns true); the *mismatch* challenge UI is deferred to M5 (a fresh mobile install has empty known-hosts, so M2 connects never block). +- Desktop builds + full `flutter test` + `flutter analyze` MUST stay green. +- No Claude attribution. Commit after each task. Branch: `feat/android-mobile-app`. + +## Key existing APIs (verified) + +- `SshService(StorageService storage, {hookBus, shellIntegration})` — extras optional. +- `HostProvider(storage)` auto-loads; `allHosts` (List), `addHost(Host, {String? password})`, `groups`, `updateDetectedOs(id, os)`. +- `KeyProvider()` auto-loads; `keys` (List), `findById(id)`, `savePassphrase` settable. +- `SettingsProvider()` auto-loads; `autoReconnect`, `reconnectAttempts`, `tmuxEnabled`, `terminalType`. +- `KnownHostsProvider(storage)`; `load()`, `verifyHostKey(host, port, keyType, fp)`. +- `SessionProvider(SshService, TabMetadataService)`; `sessions` (List), `sshSessions`, `activeSession`, `setActive(id)`, `connectAny(Host)`, callbacks: `keyLookup`, `jumpHostLookup`, `autoReconnectEnabled`, `reconnectAttempts`, `tmuxEnabled`, `terminalType`, `hostKeyVerifier`, `onOsDetected`. +- `SshSession` (implements AppSession): `terminal` (xterm `Terminal`), `status` (`SessionStatus.{connecting,connected,disconnected,error}`), `errorMessage`, `host`, `tabLabel`, `statusLabel`. +- `Host({required label, required host, port=22, required username, authType=AuthType.password, keyId, ...})`. +- xterm `Terminal.keyInput(TerminalKey key, {shift, alt, ctrl})` and `Terminal.textInput(String)`. + +--- + +## Phase M2.1 — Bootstrap + Hosts + Connect + +### Task 1: Mobile bootstrap (`mobile_bootstrap.dart`) + +**Files:** +- Create: `app/lib/mobile/mobile_bootstrap.dart` +- Modify: `app/lib/mobile/mobile_app.dart` (wrap shell in `MultiProvider`) +- Test: `app/test/mobile/mobile_bootstrap_test.dart` + +**Interfaces:** +- Produces: `class MobileBootstrap` with public fields `storage`, `ssh`, `hostProvider`, `keyProvider`, `settings`, `knownHosts`, `sessions`, and `List get providers`. Constructed once in `YourSSHMobileApp`. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_bootstrap_test.dart`: +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/mobile_bootstrap.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('constructs services and wires SessionProvider callbacks', () { + final b = MobileBootstrap(); + + // Connect-critical callbacks must be wired. + expect(b.sessions.keyLookup, isNotNull); + expect(b.sessions.hostKeyVerifier, isNotNull); + expect(b.sessions.autoReconnectEnabled, isNotNull); + expect(b.sessions.terminalType, isNotNull); + expect(b.ssh.defaultHostKeyVerifier, isNotNull); + expect(b.ssh.defaultKeyLookup, isNotNull); + + // Exposes a provider list for the widget tree. + expect(b.providers, isNotEmpty); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_bootstrap_test.dart` +Expected: FAIL — `mobile_bootstrap.dart` URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/mobile_bootstrap.dart`: +```dart +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +import '../providers/host_provider.dart'; +import '../providers/key_provider.dart'; +import '../providers/known_hosts_provider.dart'; +import '../providers/session_provider.dart'; +import '../providers/settings_provider.dart'; +import '../services/ssh_service.dart'; +import '../services/storage_service.dart'; +import '../services/tab_metadata_service.dart'; + +/// Constructs the platform-agnostic services/providers the Android app needs +/// and wires the minimal callbacks required to connect over SSH. Mirrors the +/// desktop wiring in `main.dart` but only for the mobile-relevant subset — +/// kept separate so the desktop bootstrap stays untouched. +class MobileBootstrap { + late final StorageService storage; + late final SshService ssh; + late final HostProvider hostProvider; + late final KeyProvider keyProvider; + late final SettingsProvider settings; + late final KnownHostsProvider knownHosts; + late final SessionProvider sessions; + + MobileBootstrap() { + storage = StorageService(); + ssh = SshService(storage); + hostProvider = HostProvider(storage); + keyProvider = KeyProvider()..savePassphrase = storage.savePassphrase; + settings = SettingsProvider(); + knownHosts = KnownHostsProvider(storage)..load(); + sessions = SessionProvider(ssh, TabMetadataService()); + _wire(); + } + + void _wire() { + sessions.keyLookup = (id) => keyProvider.findById(id); + sessions.jumpHostLookup = + (id) => hostProvider.allHosts.where((h) => h.id == id).firstOrNull; + sessions.autoReconnectEnabled = () => settings.autoReconnect; + sessions.reconnectAttempts = () => settings.reconnectAttempts; + sessions.tmuxEnabled = () => settings.tmuxEnabled; + sessions.terminalType = () => settings.terminalType; + sessions.hostKeyVerifier = knownHosts.verifyHostKey; + sessions.onOsDetected = (id, os) => hostProvider.updateDetectedOs(id, os); + + ssh.defaultHostKeyVerifier = knownHosts.verifyHostKey; + ssh.defaultKeyLookup = (id) => keyProvider.findById(id); + ssh.defaultJumpHostLookup = + (id) => hostProvider.allHosts.where((h) => h.id == id).firstOrNull; + } + + List get providers => [ + Provider.value(value: storage), + Provider.value(value: ssh), + ChangeNotifierProvider.value(value: hostProvider), + ChangeNotifierProvider.value(value: keyProvider), + ChangeNotifierProvider.value(value: settings), + ChangeNotifierProvider.value(value: knownHosts), + ChangeNotifierProvider.value(value: sessions), + ]; +} +``` + +> If `provider/single_child_widget.dart` import fails, use `package:nested/nested.dart` or type the getter as `List`; verify the real export at execution. + +- [ ] **Step 4: Wire into `YourSSHMobileApp`** + +Modify `app/lib/mobile/mobile_app.dart` — make it stateful, hold a `MobileBootstrap`, wrap the home in `MultiProvider`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../theme/app_theme.dart'; +import 'mobile_bootstrap.dart'; +import 'screens/mobile_home_shell.dart'; + +class YourSSHMobileApp extends StatefulWidget { + const YourSSHMobileApp({super.key}); + + @override + State createState() => _YourSSHMobileAppState(); +} + +class _YourSSHMobileAppState extends State { + final _bootstrap = MobileBootstrap(); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: _bootstrap.providers, + child: MaterialApp( + title: 'YourSSH', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: buildAppTheme(), + home: const MobileHomeShell(), + ), + ); + } +} +``` + +- [ ] **Step 5: Run tests + analyze** + +Run: `cd app && flutter test test/mobile/mobile_bootstrap_test.dart && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/mobile/mobile_bootstrap.dart app/lib/mobile/mobile_app.dart app/test/mobile/mobile_bootstrap_test.dart +git commit -m "feat(mobile): bootstrap providers + SSH wiring" +``` + +--- + +### Task 2: Hosts screen (list + search + connect + add FAB) + +**Files:** +- Create: `app/lib/mobile/screens/mobile_hosts_screen.dart` +- Test: `app/test/mobile/mobile_hosts_screen_test.dart` + +**Interfaces:** +- Consumes: `HostProvider.allHosts`, `SessionProvider.connectAny`. +- Produces: `class MobileHostsScreen extends StatefulWidget` — searchable host list; tapping a row calls `context.read().connectAny(host)` then invokes an injected `onConnected` callback (the shell switches to the Sessions tab). A FAB opens the add-host form via injected `onAddHost`. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_hosts_screen_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/mobile/screens/mobile_hosts_screen.dart'; +import 'package:yourssh/services/storage_service.dart'; + +Future _pump(WidgetTester tester, HostProvider hosts) async { + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: hosts, + child: MobileHostsScreen(onConnected: () {}, onAddHost: () {}), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('lists hosts and filters by search', (tester) async { + final hosts = HostProvider(StorageService()); + await hosts.addHost(Host(label: 'prod-web', host: '10.0.0.1', username: 'root')); + await hosts.addHost(Host(label: 'db-1', host: '10.0.0.2', username: 'admin')); + + await _pump(tester, hosts); + expect(find.text('prod-web'), findsOneWidget); + expect(find.text('db-1'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'prod'); + await tester.pumpAndSettle(); + expect(find.text('prod-web'), findsOneWidget); + expect(find.text('db-1'), findsNothing); + }); + + testWidgets('shows empty state with no hosts', (tester) async { + await _pump(tester, HostProvider(StorageService())); + expect(find.textContaining('No hosts'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_hosts_screen_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/screens/mobile_hosts_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../providers/host_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Hosts tab: searchable list of saved hosts; tap to connect, FAB to add. +class MobileHostsScreen extends StatefulWidget { + final VoidCallback onConnected; + final VoidCallback onAddHost; + + const MobileHostsScreen({ + super.key, + required this.onConnected, + required this.onAddHost, + }); + + @override + State createState() => _MobileHostsScreenState(); +} + +class _MobileHostsScreenState extends State { + String _query = ''; + + @override + Widget build(BuildContext context) { + final all = context.watch().allHosts; + final q = _query.trim().toLowerCase(); + final hosts = q.isEmpty + ? all + : all + .where((h) => + h.label.toLowerCase().contains(q) || + h.host.toLowerCase().contains(q) || + h.username.toLowerCase().contains(q)) + .toList(); + + return Scaffold( + backgroundColor: AppColors.bg, + floatingActionButton: FloatingActionButton( + onPressed: widget.onAddHost, + backgroundColor: AppColors.accent, + child: const Icon(Icons.add, color: Colors.black), + ), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Search hosts', + prefixIcon: const Icon(Icons.search, color: AppColors.textSecondary), + filled: true, + fillColor: AppColors.card, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + Expanded( + child: hosts.isEmpty + ? const Center( + child: Text('No hosts yet — tap + to add one', + style: TextStyle(color: AppColors.textSecondary))) + : ListView.builder( + itemCount: hosts.length, + itemBuilder: (_, i) => _HostRow( + host: hosts[i], + onTap: () => _connect(hosts[i]), + ), + ), + ), + ], + ), + ), + ); + } + + Future _connect(Host host) async { + // Fire the connect (async) and immediately switch to the Sessions tab so + // the user watches it come up live. + context.read().connectAny(host); + widget.onConnected(); + } +} + +class _HostRow extends StatelessWidget { + final Host host; + final VoidCallback onTap; + const _HostRow({required this.host, required this.onTap}); + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: onTap, + leading: const Icon(Icons.dns_outlined, color: AppColors.textSecondary), + title: Text(host.label, style: const TextStyle(color: AppColors.textPrimary)), + subtitle: Text('${host.username}@${host.host}:${host.port}', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), + trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/mobile_hosts_screen_test.dart` +Expected: PASS (both tests). + +> The connect test path isn't exercised here (needs SessionProvider); covered in Task 5's integration via the shell. The list/search/empty behavior is what this task guarantees. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/screens/mobile_hosts_screen.dart app/test/mobile/mobile_hosts_screen_test.dart +git commit -m "feat(mobile): hosts screen with search and connect" +``` + +--- + +### Task 3: Minimal add-host form + +**Files:** +- Create: `app/lib/mobile/screens/mobile_add_host_screen.dart` +- Test: `app/test/mobile/mobile_add_host_screen_test.dart` + +**Interfaces:** +- Consumes: `HostProvider.addHost(Host, {password})`, `KeyProvider.keys`. +- Produces: `class MobileAddHostScreen extends StatefulWidget` — label/host/port/username + auth (password text OR pick a saved key) + Save. On save, builds a `Host` and calls `HostProvider.addHost`, then pops. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_add_host_screen_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/mobile/screens/mobile_add_host_screen.dart'; +import 'package:yourssh/services/storage_service.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('saving creates a host in the provider', (tester) async { + final hosts = HostProvider(StorageService()); + await tester.pumpWidget(MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: hosts), + ChangeNotifierProvider(create: (_) => KeyProvider()), + ], + child: const MobileAddHostScreen(), + ), + )); + + await tester.enterText(find.byKey(const Key('host-label')), 'edge-1'); + await tester.enterText(find.byKey(const Key('host-address')), '192.168.1.9'); + await tester.enterText(find.byKey(const Key('host-username')), 'pi'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(hosts.allHosts.any((h) => h.label == 'edge-1' && h.host == '192.168.1.9'), + isTrue); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_add_host_screen_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/screens/mobile_add_host_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../models/ssh_key.dart'; +import '../../providers/host_provider.dart'; +import '../../providers/key_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Minimal add-host form for mobile: label/host/port/username + password or a +/// saved key. Full editor (tags, proxy, jump chain, RDP/VNC) is desktop-only. +class MobileAddHostScreen extends StatefulWidget { + const MobileAddHostScreen({super.key}); + + @override + State createState() => _MobileAddHostScreenState(); +} + +class _MobileAddHostScreenState extends State { + final _label = TextEditingController(); + final _address = TextEditingController(); + final _port = TextEditingController(text: '22'); + final _username = TextEditingController(); + final _password = TextEditingController(); + bool _useKey = false; + String? _keyId; + + @override + void dispose() { + for (final c in [_label, _address, _port, _username, _password]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + final label = _label.text.trim(); + final address = _address.text.trim(); + final username = _username.text.trim(); + if (label.isEmpty || address.isEmpty || username.isEmpty) return; + + final host = Host( + label: label, + host: address, + port: int.tryParse(_port.text.trim()) ?? 22, + username: username, + authType: _useKey ? AuthType.privateKey : AuthType.password, + keyId: _useKey ? _keyId : null, + ); + await context.read().addHost( + host, + password: _useKey ? null : _password.text, + ); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final keys = context.watch().keys; + return Scaffold( + backgroundColor: AppColors.bg, + appBar: AppBar( + backgroundColor: AppColors.bg, + title: const Text('Add host'), + actions: [TextButton(onPressed: _save, child: const Text('Save'))], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _field(_label, 'Label', key: 'host-label'), + _field(_address, 'Host / IP', key: 'host-address'), + _field(_port, 'Port', key: 'host-port', + keyboard: TextInputType.number, + formatters: [FilteringTextInputFormatter.digitsOnly]), + _field(_username, 'Username', key: 'host-username'), + const SizedBox(height: 8), + SwitchListTile( + value: _useKey, + onChanged: keys.isEmpty ? null : (v) => setState(() => _useKey = v), + title: const Text('Use SSH key', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: keys.isEmpty + ? const Text('No keys imported — using password', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)) + : null, + ), + if (_useKey) + DropdownButtonFormField( + initialValue: _keyId, + items: [ + for (final SshKeyEntry k in keys) + DropdownMenuItem(value: k.id, child: Text(k.name)), + ], + onChanged: (v) => setState(() => _keyId = v), + decoration: const InputDecoration(labelText: 'Key'), + ) + else + _field(_password, 'Password', key: 'host-password', obscure: true), + ], + ), + ), + ); + } + + Widget _field( + TextEditingController c, + String label, { + required String key, + bool obscure = false, + TextInputType? keyboard, + List? formatters, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + key: Key(key), + controller: c, + obscureText: obscure, + keyboardType: keyboard, + inputFormatters: formatters, + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration(labelText: label), + ), + ); + } +} +``` + +> Verify at execution: `SshKeyEntry` has `id` and `name` (or `label`) fields — adjust the dropdown accordingly. `AuthType.privateKey` is the enum value used by `Host`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/mobile_add_host_screen_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/screens/mobile_add_host_screen.dart app/test/mobile/mobile_add_host_screen_test.dart +git commit -m "feat(mobile): minimal add-host form" +``` + +--- + +### Task 4: Sessions screen (strip + terminal/status) + +**Files:** +- Create: `app/lib/mobile/screens/mobile_sessions_screen.dart` +- Test: `app/test/mobile/mobile_sessions_screen_test.dart` + +**Interfaces:** +- Consumes: `SessionProvider.sshSessions`, `activeSession`, `setActive(id)`; `SshSession.terminal/status/tabLabel/statusLabel`. +- Produces: `class MobileSessionsScreen extends StatelessWidget` — a horizontally scrollable session strip (chips) + a body that, for the active SSH session, shows `TerminalView(session.terminal)` when `connected`, else a centered status (`statusLabel` + spinner while connecting + error text). Empty state when no sessions. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_sessions_screen_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh/mobile/screens/mobile_sessions_screen.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('empty state with no sessions', (tester) async { + final sp = SessionProvider(SshService(StorageService()), TabMetadataService()); + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: sp, + child: const MobileSessionsScreen(), + ), + )); + expect(find.textContaining('No active sessions'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_sessions_screen_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/screens/mobile_sessions_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:xterm/xterm.dart'; + +import '../../models/ssh_session.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Sessions tab: a session strip + the active SSH session's terminal (when +/// connected) or its connection status. The accessory key bar (M2.2) docks +/// below the terminal. +class MobileSessionsScreen extends StatelessWidget { + const MobileSessionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final sp = context.watch(); + final ssh = sp.sshSessions; + + if (ssh.isEmpty) { + return const Scaffold( + backgroundColor: AppColors.bg, + body: Center( + child: Text('No active sessions — connect from Hosts', + style: TextStyle(color: AppColors.textSecondary)), + ), + ); + } + + final active = sp.activeSession is SshSession + ? sp.activeSession as SshSession + : ssh.last; + + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + child: Column( + children: [ + _SessionStrip(sessions: ssh, activeId: active.id), + const Divider(height: 1, color: AppColors.border), + Expanded(child: _SessionBody(session: active)), + ], + ), + ), + ); + } +} + +class _SessionStrip extends StatelessWidget { + final List sessions; + final String activeId; + const _SessionStrip({required this.sessions, required this.activeId}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + children: [ + for (final s in sessions) + Padding( + padding: const EdgeInsets.only(right: 6), + child: ChoiceChip( + label: Text(s.tabLabel), + selected: s.id == activeId, + onSelected: (_) => context.read().setActive(s.id), + ), + ), + ], + ), + ); + } +} + +class _SessionBody extends StatelessWidget { + final SshSession session; + const _SessionBody({required this.session}); + + @override + Widget build(BuildContext context) { + if (session.status == SessionStatus.connected) { + return TerminalView(session.terminal); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (session.status == SessionStatus.connecting) + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: CircularProgressIndicator(), + ), + Text(session.statusLabel, + style: const TextStyle(color: AppColors.textSecondary)), + if (session.errorMessage != null) ...[ + const SizedBox(height: 8), + Text(session.errorMessage!, + style: const TextStyle(color: AppColors.red, fontSize: 12)), + ], + ], + ), + ); + } +} +``` + +> Verify at execution: `AppColors.red` exists (used by `session_connecting_view.dart`). `TerminalView` is exported by `package:xterm/xterm.dart`. `SshSession` must `extends ChangeNotifier` (or be listenable) for the strip/body to rebuild on status change — the screen `context.watch()` rebuilds when the provider notifies, which `SessionProvider` does on connect; if per-session status changes don't notify the provider, wrap `_SessionBody` in an `AnimatedBuilder`/`ListenableBuilder(listenable: session)`. + +- [ ] **Step 4: Run test + analyze** + +Run: `cd app && flutter test test/mobile/mobile_sessions_screen_test.dart && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/screens/mobile_sessions_screen.dart app/test/mobile/mobile_sessions_screen_test.dart +git commit -m "feat(mobile): sessions screen with terminal + status" +``` + +--- + +### Task 5: Wire Hosts + Sessions into the shell + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` +- Test: update `app/test/mobile/mobile_home_shell_test.dart` (now needs providers) + +**Interfaces:** +- Consumes: `MobileHostsScreen`, `MobileSessionsScreen`, `MobileAddHostScreen`. +- Produces: shell that shows the real Hosts (index 0) and Sessions (index 1) screens; SFTP/Settings stay placeholders. Tapping a host connects and switches to Sessions; FAB pushes the add-host route. + +- [ ] **Step 1: Update the shell test** + +Replace `app/test/mobile/mobile_home_shell_test.dart` with a provider-backed pump (the shell now reads `HostProvider`/`SessionProvider`): +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +Future _pump(WidgetTester tester) async { + final storage = StorageService(); + final ssh = SshService(storage); + await tester.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => HostProvider(storage)), + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider(create: (_) => SessionProvider(ssh, TabMetadataService())), + ], + child: const MaterialApp(home: MobileHomeShell()), + )); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows four destinations; Hosts first', (tester) async { + await _pump(tester); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.textContaining('No hosts'), findsOneWidget); // Hosts screen body + }); + + testWidgets('switches to SFTP placeholder', (tester) async { + await _pump(tester); + await tester.tap(find.text('SFTP').last); + await tester.pumpAndSettle(); + expect(find.text('SFTP — coming soon'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_home_shell_test.dart` +Expected: FAIL — the current shell shows `'Hosts — coming soon'`, not the hosts list. + +- [ ] **Step 3: Update `MobileHomeShell`** + +Replace the body builder so indices 0/1 render real screens: +```dart +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import 'mobile_add_host_screen.dart'; +import 'mobile_hosts_screen.dart'; +import 'mobile_sessions_screen.dart'; + +class MobileHomeShell extends StatefulWidget { + const MobileHomeShell({super.key}); + + @override + State createState() => _MobileHomeShellState(); +} + +class _MobileHomeShellState extends State { + int _index = 0; + + static const _labels = ['Hosts', 'Sessions', 'SFTP', 'Settings']; + static const _icons = [ + Icons.dns_outlined, + Icons.terminal_outlined, + Icons.folder_outlined, + Icons.settings_outlined, + ]; + + void _openAddHost() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileAddHostScreen()), + ); + } + + Widget _body() { + switch (_index) { + case 0: + return MobileHostsScreen( + onConnected: () => setState(() => _index = 1), + onAddHost: _openAddHost, + ); + case 1: + return const MobileSessionsScreen(); + default: + return Center( + child: Text('${_labels[_index]} — coming soon', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 15)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.bg, + body: _body(), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: [ + for (var i = 0; i < _labels.length; i++) + NavigationDestination(icon: Icon(_icons[i]), label: _labels[i]), + ], + ), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/` +Expected: PASS (all mobile tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/mobile_home_shell_test.dart +git commit -m "feat(mobile): wire hosts + sessions into shell" +``` + +--- + +## Phase M2.2 — Accessory key bar + pinch-zoom + +### Task 6: Accessory bar controller (sticky modifiers) — pure logic + +**Files:** +- Create: `app/lib/mobile/terminal/accessory_bar_controller.dart` +- Test: `app/test/mobile/accessory_bar_controller_test.dart` + +**Interfaces:** +- Produces: `class AccessoryBarController` — holds sticky `ctrl`/`alt` arm state; `armCtrl()`, `armAlt()` toggle; `consumeModifiers()` returns `({bool ctrl, bool alt})` and clears the arm (one-shot). Decouples the sticky logic from the widget so it is unit-testable. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/accessory_bar_controller_test.dart`: +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/terminal/accessory_bar_controller.dart'; + +void main() { + test('modifiers default off and consume returns false', () { + final c = AccessoryBarController(); + final m = c.consumeModifiers(); + expect(m.ctrl, isFalse); + expect(m.alt, isFalse); + }); + + test('armed ctrl is one-shot: consumed once then cleared', () { + final c = AccessoryBarController(); + c.armCtrl(); + expect(c.ctrlArmed, isTrue); + final m1 = c.consumeModifiers(); + expect(m1.ctrl, isTrue); + expect(c.ctrlArmed, isFalse); // cleared after consume + final m2 = c.consumeModifiers(); + expect(m2.ctrl, isFalse); + }); + + test('arm toggles off when armed again', () { + final c = AccessoryBarController(); + c.armCtrl(); + c.armCtrl(); + expect(c.ctrlArmed, isFalse); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/accessory_bar_controller_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/terminal/accessory_bar_controller.dart`: +```dart +import 'package:flutter/foundation.dart'; + +/// Sticky Ctrl/Alt state for the mobile terminal accessory bar. Modifiers are +/// one-shot: arming sets the flag, the next emitted key consumes and clears it +/// (mstsc/Termux-style). A [ChangeNotifier] so the bar highlights armed keys. +class AccessoryBarController extends ChangeNotifier { + bool _ctrl = false; + bool _alt = false; + + bool get ctrlArmed => _ctrl; + bool get altArmed => _alt; + + void armCtrl() { + _ctrl = !_ctrl; + notifyListeners(); + } + + void armAlt() { + _alt = !_alt; + notifyListeners(); + } + + /// Returns the currently-armed modifiers and clears them (one-shot). + ({bool ctrl, bool alt}) consumeModifiers() { + final m = (ctrl: _ctrl, alt: _alt); + if (_ctrl || _alt) { + _ctrl = false; + _alt = false; + notifyListeners(); + } + return m; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/accessory_bar_controller_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/terminal/accessory_bar_controller.dart app/test/mobile/accessory_bar_controller_test.dart +git commit -m "feat(mobile): accessory bar sticky-modifier controller" +``` + +--- + +### Task 7: Accessory bar widget + wire to terminal + +**Files:** +- Create: `app/lib/mobile/terminal/accessory_key_bar.dart` +- Modify: `app/lib/mobile/screens/mobile_sessions_screen.dart` (dock the bar under the terminal when connected) +- Test: `app/test/mobile/accessory_key_bar_test.dart` + +**Interfaces:** +- Consumes: `AccessoryBarController`, an `onKey(TerminalKey, {ctrl, alt})` callback and an `onText(String)` callback. +- Produces: `class AccessoryKeyBar extends StatelessWidget` — a horizontally scrollable row: Esc, Tab, Ctrl (sticky), Alt (sticky), ←↑↓→, and chars `/ - | ~`. Special keys call `onKey` with consumed modifiers; chars call `onText` (consuming modifiers — when ctrl armed, a char routes via `onKey` for the letter so Ctrl+C works). + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/accessory_key_bar_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh/mobile/terminal/accessory_bar_controller.dart'; +import 'package:yourssh/mobile/terminal/accessory_key_bar.dart'; + +void main() { + testWidgets('Esc taps emit escape key', (tester) async { + TerminalKey? got; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: AccessoryKeyBar( + controller: AccessoryBarController(), + onKey: (k, {ctrl = false, alt = false}) => got = k, + onText: (_) {}, + ), + ), + )); + await tester.tap(find.text('Esc')); + expect(got, TerminalKey.escape); + }); + + testWidgets('Ctrl then arrow emits ctrl-modified key', (tester) async { + bool? gotCtrl; + final c = AccessoryBarController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: AccessoryKeyBar( + controller: c, + onKey: (k, {ctrl = false, alt = false}) => gotCtrl = ctrl, + onText: (_) {}, + ), + ), + )); + await tester.tap(find.text('Ctrl')); + await tester.pump(); + await tester.tap(find.byTooltip('Up')); + expect(gotCtrl, isTrue); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/accessory_key_bar_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/terminal/accessory_key_bar.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:xterm/xterm.dart'; + +import '../../theme/app_theme.dart'; +import 'accessory_bar_controller.dart'; + +/// Mobile terminal accessory bar: special keys + sticky Ctrl/Alt above the +/// soft keyboard. Emits xterm [TerminalKey]s (with consumed modifiers) via +/// [onKey] and literal characters via [onText]. +class AccessoryKeyBar extends StatelessWidget { + final AccessoryBarController controller; + final void Function(TerminalKey key, {bool ctrl, bool alt}) onKey; + final void Function(String text) onText; + + const AccessoryKeyBar({ + super.key, + required this.controller, + required this.onKey, + required this.onText, + }); + + void _key(TerminalKey k) { + final m = controller.consumeModifiers(); + onKey(k, ctrl: m.ctrl, alt: m.alt); + } + + void _text(String s) { + final m = controller.consumeModifiers(); + if (m.ctrl && s.length == 1) { + // Route a Ctrl+ through keyInput so control codes are produced. + final k = _letterKey(s); + if (k != null) { + onKey(k, ctrl: true, alt: m.alt); + return; + } + } + onText(s); + } + + TerminalKey? _letterKey(String s) { + final lower = s.toLowerCase(); + if (lower.length != 1 || lower.codeUnitAt(0) < 0x61 || lower.codeUnitAt(0) > 0x7a) { + return null; + } + final index = lower.codeUnitAt(0) - 0x61; // a..z + return TerminalKey.values.firstWhere( + (k) => k.name == 'key${s.toUpperCase()}', + orElse: () => TerminalKey.keyA, + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return Container( + height: 46, + color: AppColors.card, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + children: [ + _btn('Esc', onTap: () => _key(TerminalKey.escape)), + _btn('Tab', onTap: () => _key(TerminalKey.tab)), + _btn('Ctrl', armed: controller.ctrlArmed, onTap: controller.armCtrl), + _btn('Alt', armed: controller.altArmed, onTap: controller.armAlt), + _icon(Icons.keyboard_arrow_left, 'Left', + () => _key(TerminalKey.arrowLeft)), + _icon(Icons.keyboard_arrow_up, 'Up', () => _key(TerminalKey.arrowUp)), + _icon(Icons.keyboard_arrow_down, 'Down', + () => _key(TerminalKey.arrowDown)), + _icon(Icons.keyboard_arrow_right, 'Right', + () => _key(TerminalKey.arrowRight)), + for (final ch in const ['/', '-', '|', '~', ':']) + _btn(ch, onTap: () => _text(ch)), + ], + ), + ); + }, + ); + } + + Widget _btn(String label, {required VoidCallback onTap, bool armed = false}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: InkWell( + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: armed ? AppColors.accent : AppColors.bg, + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, + style: TextStyle( + color: armed ? Colors.black : AppColors.textPrimary, + fontSize: 14)), + ), + ), + ); + } + + Widget _icon(IconData icon, String tooltip, VoidCallback onTap) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: AppColors.bg, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppColors.textPrimary, size: 20), + ), + ), + ), + ); + } +} +``` + +> Verify at execution: `TerminalKey` enum member names (`escape`, `tab`, `arrowUp`, `keyA`…). If `firstWhere` by name is brittle, build a small `const` map `{'c': TerminalKey.keyC, ...}` for the common Ctrl letters (c, d, z, l, a, e, k, u, r) instead. + +- [ ] **Step 4: Dock the bar in the sessions screen** + +In `mobile_sessions_screen.dart`, give the screen an `AccessoryBarController` (StatefulWidget) and, when the active session is connected, place `AccessoryKeyBar` below the `TerminalView`, wiring: +```dart +AccessoryKeyBar( + controller: _accessory, + onKey: (k, {ctrl = false, alt = false}) => + session.terminal.keyInput(k, ctrl: ctrl, alt: alt), + onText: (s) => session.terminal.textInput(s), +) +``` +(Convert `MobileSessionsScreen` to `StatefulWidget`; create `_accessory = AccessoryBarController()` in `initState`; dispose it. Wrap the `Column`'s terminal area in `Expanded(child: TerminalView(...))` and add the bar as the last child so it sits above the soft keyboard.) + +- [ ] **Step 5: Run tests + analyze** + +Run: `cd app && flutter test test/mobile/ && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/mobile/terminal/accessory_key_bar.dart app/lib/mobile/screens/mobile_sessions_screen.dart app/test/mobile/accessory_key_bar_test.dart +git commit -m "feat(mobile): accessory key bar wired to terminal" +``` + +--- + +### Task 8: Pinch-to-zoom terminal font + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_sessions_screen.dart` +- Test: manual (gesture-driven; covered by analyze + on-device check) + +**Interfaces:** +- Produces: a `GestureDetector` (scale) around the `TerminalView` that adjusts a local `_fontSize` (clamped 8–28) and passes `TerminalView(..., textStyle: TerminalStyle(fontSize: _fontSize))`. + +- [ ] **Step 1: Add pinch-zoom state** + +In `_MobileSessionsScreenState`, add `double _fontSize = 14;` and a scale gesture: +```dart +GestureDetector( + onScaleStart: (_) => _scaleBase = _fontSize, + onScaleUpdate: (d) { + if (d.scale == 1.0) return; + setState(() => _fontSize = (_scaleBase * d.scale).clamp(8.0, 28.0)); + }, + child: TerminalView( + session.terminal, + textStyle: TerminalStyle(fontSize: _fontSize), + ), +) +``` +(Add `double _scaleBase = 14;`.) + +- [ ] **Step 2: Analyze + full mobile tests** + +Run: `cd app && flutter analyze lib/mobile && flutter test test/mobile/` +Expected: `No issues found!`; tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add app/lib/mobile/screens/mobile_sessions_screen.dart +git commit -m "feat(mobile): pinch-to-zoom terminal font" +``` + +--- + +### Task 9: Build, regression, on-device check + +**Files:** none (verification) + +- [ ] **Step 1: Full test suite (desktop regression)** + +Run: `cd app && flutter test` +Expected: all tests pass (desktop unaffected; mobile tests added). + +- [ ] **Step 2: Analyze whole repo** + +Run: `cd app && flutter analyze` +Expected: no NEW issues (the 2 pre-existing `integration_test` probe warnings may remain). + +- [ ] **Step 3: Build the APK** + +Run: `cd app && flutter build apk --debug` +Expected: `✓ Built build/app/outputs/flutter-apk/app-debug.apk`. + +- [ ] **Step 4: On-device (user, when a device/emulator is available)** + +``` +cd app && flutter run -d +``` +Manual checklist: add a host → tap → Sessions tab shows it connecting → on connect the terminal renders → type with the soft keyboard → Esc/Ctrl+C/arrows from the accessory bar work → pinch zoom resizes the font. + +--- + +## Self-Review + +**Spec coverage (M2 portion):** +- "Hosts list + detail + connect" → Tasks 2, 3, 5. ✓ +- "mobile TerminalView" → Task 4. ✓ +- "accessory key bar (sticky Ctrl/Alt)" → Tasks 6, 7. ✓ +- "multi-session strip" → Task 4 (`_SessionStrip`). ✓ +- "pinch-zoom" → Task 8. ✓ +- "shared provider bootstrap" → Task 1 (mobile-scoped reuse of the existing classes; desktop bootstrap untouched — documented refinement). ✓ + +**Placeholder scan:** No "TBD/handle errors" steps; code is complete. The three `> Verify at execution` notes name a concrete fallback each (provider export, `SshKeyEntry` field names, `TerminalKey` member names) — verification instructions, not placeholders. + +**Type consistency:** `MobileBootstrap` field/getter names (Task 1) match their use. `MobileHostsScreen(onConnected, onAddHost)` (Task 2) matches the shell wiring (Task 5). `AccessoryBarController.armCtrl/armAlt/consumeModifiers/ctrlArmed/altArmed` (Task 6) match the bar widget (Task 7). `onKey(TerminalKey, {ctrl, alt})` / `onText(String)` consistent across Tasks 7–8 and the terminal wiring. + +## Deferred to later milestones + +- TOFU **mismatch** challenge dialog → M5 (first-connect auto-trusts in M2). +- Host edit/delete, tags, proxy, jump-chain, RDP/VNC host forms → not on mobile v1 / later. +- Snippets + command-history insert → M4. +- SFTP → M4. Settings (appearance, app-lock) → M5. From 5f250f67d15eabe2017eee5c82e82fdc60d3a093 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:29:04 +0700 Subject: [PATCH 09/96] feat(mobile): bootstrap providers + SSH wiring --- app/lib/mobile/mobile_app.dart | 30 +++++++---- app/lib/mobile/mobile_bootstrap.dart | 63 ++++++++++++++++++++++ app/test/mobile/mobile_bootstrap_test.dart | 22 ++++++++ 3 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 app/lib/mobile/mobile_bootstrap.dart create mode 100644 app/test/mobile/mobile_bootstrap_test.dart diff --git a/app/lib/mobile/mobile_app.dart b/app/lib/mobile/mobile_app.dart index 8e0980f7..753d252a 100644 --- a/app/lib/mobile/mobile_app.dart +++ b/app/lib/mobile/mobile_app.dart @@ -1,22 +1,34 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../theme/app_theme.dart'; +import 'mobile_bootstrap.dart'; import 'screens/mobile_home_shell.dart'; /// Root widget for the Android build. Dark-only; reuses the shared -/// [buildAppTheme] so the mobile surface matches desktop. Providers/services -/// are wired here starting in M2. -class YourSSHMobileApp extends StatelessWidget { +/// [buildAppTheme] so the mobile surface matches desktop. Holds the +/// [MobileBootstrap] and exposes its providers to the tree. +class YourSSHMobileApp extends StatefulWidget { const YourSSHMobileApp({super.key}); + @override + State createState() => _YourSSHMobileAppState(); +} + +class _YourSSHMobileAppState extends State { + final _bootstrap = MobileBootstrap(); + @override Widget build(BuildContext context) { - return MaterialApp( - title: 'YourSSH', - debugShowCheckedModeBanner: false, - themeMode: ThemeMode.dark, - theme: buildAppTheme(), - home: const MobileHomeShell(), + return MultiProvider( + providers: _bootstrap.providers, + child: MaterialApp( + title: 'YourSSH', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: buildAppTheme(), + home: const MobileHomeShell(), + ), ); } } diff --git a/app/lib/mobile/mobile_bootstrap.dart b/app/lib/mobile/mobile_bootstrap.dart new file mode 100644 index 00000000..80d7749e --- /dev/null +++ b/app/lib/mobile/mobile_bootstrap.dart @@ -0,0 +1,63 @@ +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +import '../providers/host_provider.dart'; +import '../providers/key_provider.dart'; +import '../providers/known_hosts_provider.dart'; +import '../providers/session_provider.dart'; +import '../providers/settings_provider.dart'; +import '../services/ssh_service.dart'; +import '../services/storage_service.dart'; +import '../services/tab_metadata_service.dart'; + +/// Constructs the platform-agnostic services/providers the Android app needs +/// and wires the minimal callbacks required to connect over SSH. Mirrors the +/// desktop wiring in `main.dart` but only for the mobile-relevant subset — +/// kept separate so the desktop bootstrap stays untouched. +class MobileBootstrap { + late final StorageService storage; + late final SshService ssh; + late final HostProvider hostProvider; + late final KeyProvider keyProvider; + late final SettingsProvider settings; + late final KnownHostsProvider knownHosts; + late final SessionProvider sessions; + + MobileBootstrap() { + storage = StorageService(); + ssh = SshService(storage); + hostProvider = HostProvider(storage); + keyProvider = KeyProvider()..savePassphrase = storage.savePassphrase; + settings = SettingsProvider(); + knownHosts = KnownHostsProvider(storage)..load(); + sessions = SessionProvider(ssh, TabMetadataService()); + _wire(); + } + + void _wire() { + sessions.keyLookup = (id) => keyProvider.findById(id); + sessions.jumpHostLookup = + (id) => hostProvider.allHosts.where((h) => h.id == id).firstOrNull; + sessions.autoReconnectEnabled = () => settings.autoReconnect; + sessions.reconnectAttempts = () => settings.reconnectAttempts; + sessions.tmuxEnabled = () => settings.tmuxEnabled; + sessions.terminalType = () => settings.terminalType; + sessions.hostKeyVerifier = knownHosts.verifyHostKey; + sessions.onOsDetected = (id, os) => hostProvider.updateDetectedOs(id, os); + + ssh.defaultHostKeyVerifier = knownHosts.verifyHostKey; + ssh.defaultKeyLookup = (id) => keyProvider.findById(id); + ssh.defaultJumpHostLookup = + (id) => hostProvider.allHosts.where((h) => h.id == id).firstOrNull; + } + + List get providers => [ + Provider.value(value: storage), + Provider.value(value: ssh), + ChangeNotifierProvider.value(value: hostProvider), + ChangeNotifierProvider.value(value: keyProvider), + ChangeNotifierProvider.value(value: settings), + ChangeNotifierProvider.value(value: knownHosts), + ChangeNotifierProvider.value(value: sessions), + ]; +} diff --git a/app/test/mobile/mobile_bootstrap_test.dart b/app/test/mobile/mobile_bootstrap_test.dart new file mode 100644 index 00000000..38df58fe --- /dev/null +++ b/app/test/mobile/mobile_bootstrap_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/mobile_bootstrap.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('constructs services and wires SessionProvider callbacks', () { + final b = MobileBootstrap(); + + // Connect-critical callbacks must be wired. + expect(b.sessions.keyLookup, isNotNull); + expect(b.sessions.hostKeyVerifier, isNotNull); + expect(b.sessions.autoReconnectEnabled, isNotNull); + expect(b.sessions.terminalType, isNotNull); + expect(b.ssh.defaultHostKeyVerifier, isNotNull); + expect(b.ssh.defaultKeyLookup, isNotNull); + + // Exposes a provider list for the widget tree. + expect(b.providers, isNotEmpty); + }); +} From 62cd740784a728b056f52aa489ba6eeff0acfe24 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:29:52 +0700 Subject: [PATCH 10/96] feat(mobile): hosts screen with search and connect --- .../mobile/screens/mobile_hosts_screen.dart | 112 ++++++++++++++++++ app/test/mobile/mobile_hosts_screen_test.dart | 42 +++++++ 2 files changed, 154 insertions(+) create mode 100644 app/lib/mobile/screens/mobile_hosts_screen.dart create mode 100644 app/test/mobile/mobile_hosts_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart new file mode 100644 index 00000000..5ed6406c --- /dev/null +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../providers/host_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Hosts tab: searchable list of saved hosts; tap to connect, FAB to add. +class MobileHostsScreen extends StatefulWidget { + final VoidCallback onConnected; + final VoidCallback onAddHost; + + const MobileHostsScreen({ + super.key, + required this.onConnected, + required this.onAddHost, + }); + + @override + State createState() => _MobileHostsScreenState(); +} + +class _MobileHostsScreenState extends State { + String _query = ''; + + @override + Widget build(BuildContext context) { + final all = context.watch().allHosts; + final q = _query.trim().toLowerCase(); + final hosts = q.isEmpty + ? all + : all + .where((h) => + h.label.toLowerCase().contains(q) || + h.host.toLowerCase().contains(q) || + h.username.toLowerCase().contains(q)) + .toList(); + + return Scaffold( + backgroundColor: AppColors.bg, + floatingActionButton: FloatingActionButton( + onPressed: widget.onAddHost, + backgroundColor: AppColors.accent, + child: const Icon(Icons.add, color: Colors.black), + ), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Search hosts', + prefixIcon: + const Icon(Icons.search, color: AppColors.textSecondary), + filled: true, + fillColor: AppColors.card, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + Expanded( + child: hosts.isEmpty + ? const Center( + child: Text('No hosts yet — tap + to add one', + style: TextStyle(color: AppColors.textSecondary))) + : ListView.builder( + itemCount: hosts.length, + itemBuilder: (_, i) => _HostRow( + host: hosts[i], + onTap: () => _connect(hosts[i]), + ), + ), + ), + ], + ), + ), + ); + } + + void _connect(Host host) { + // Fire the connect (async) and immediately switch to the Sessions tab so + // the user watches it come up live. + context.read().connectAny(host); + widget.onConnected(); + } +} + +class _HostRow extends StatelessWidget { + final Host host; + final VoidCallback onTap; + const _HostRow({required this.host, required this.onTap}); + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: onTap, + leading: const Icon(Icons.dns_outlined, color: AppColors.textSecondary), + title: + Text(host.label, style: const TextStyle(color: AppColors.textPrimary)), + subtitle: Text('${host.username}@${host.host}:${host.port}', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), + trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), + ); + } +} diff --git a/app/test/mobile/mobile_hosts_screen_test.dart b/app/test/mobile/mobile_hosts_screen_test.dart new file mode 100644 index 00000000..f9a2a7ab --- /dev/null +++ b/app/test/mobile/mobile_hosts_screen_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/mobile/screens/mobile_hosts_screen.dart'; +import 'package:yourssh/services/storage_service.dart'; + +Future _pump(WidgetTester tester, HostProvider hosts) async { + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: hosts, + child: MobileHostsScreen(onConnected: () {}, onAddHost: () {}), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('lists hosts and filters by search', (tester) async { + final hosts = HostProvider(StorageService()); + await hosts.addHost(Host(label: 'prod-web', host: '10.0.0.1', username: 'root')); + await hosts.addHost(Host(label: 'db-1', host: '10.0.0.2', username: 'admin')); + + await _pump(tester, hosts); + expect(find.text('prod-web'), findsOneWidget); + expect(find.text('db-1'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'prod'); + await tester.pumpAndSettle(); + expect(find.text('prod-web'), findsOneWidget); + expect(find.text('db-1'), findsNothing); + }); + + testWidgets('shows empty state with no hosts', (tester) async { + await _pump(tester, HostProvider(StorageService())); + expect(find.textContaining('No hosts'), findsOneWidget); + }); +} From 999bd8d3813e30e8f0e2737fb61605531dd8e9eb Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:31:09 +0700 Subject: [PATCH 11/96] feat(mobile): minimal add-host form --- .../screens/mobile_add_host_screen.dart | 133 ++++++++++++++++++ .../mobile/mobile_add_host_screen_test.dart | 36 +++++ 2 files changed, 169 insertions(+) create mode 100644 app/lib/mobile/screens/mobile_add_host_screen.dart create mode 100644 app/test/mobile/mobile_add_host_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart new file mode 100644 index 00000000..b7249fe2 --- /dev/null +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../models/ssh_key.dart'; +import '../../providers/host_provider.dart'; +import '../../providers/key_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Minimal add-host form for mobile: label/host/port/username + password or a +/// saved key. The full editor (tags, proxy, jump chain, RDP/VNC) is +/// desktop-only. +class MobileAddHostScreen extends StatefulWidget { + const MobileAddHostScreen({super.key}); + + @override + State createState() => _MobileAddHostScreenState(); +} + +class _MobileAddHostScreenState extends State { + final _label = TextEditingController(); + final _address = TextEditingController(); + final _port = TextEditingController(text: '22'); + final _username = TextEditingController(); + final _password = TextEditingController(); + bool _useKey = false; + String? _keyId; + + @override + void dispose() { + for (final c in [_label, _address, _port, _username, _password]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + final label = _label.text.trim(); + final address = _address.text.trim(); + final username = _username.text.trim(); + if (label.isEmpty || address.isEmpty || username.isEmpty) return; + + final host = Host( + label: label, + host: address, + port: int.tryParse(_port.text.trim()) ?? 22, + username: username, + authType: _useKey ? AuthType.privateKey : AuthType.password, + keyId: _useKey ? _keyId : null, + ); + await context.read().addHost( + host, + password: _useKey ? null : _password.text, + ); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final keys = context.watch().keys; + return Scaffold( + backgroundColor: AppColors.bg, + appBar: AppBar( + backgroundColor: AppColors.bg, + title: const Text('Add host'), + actions: [TextButton(onPressed: _save, child: const Text('Save'))], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _field(_label, 'Label', fieldKey: 'host-label'), + _field(_address, 'Host / IP', fieldKey: 'host-address'), + _field(_port, 'Port', + fieldKey: 'host-port', + keyboard: TextInputType.number, + formatters: [FilteringTextInputFormatter.digitsOnly]), + _field(_username, 'Username', fieldKey: 'host-username'), + const SizedBox(height: 8), + SwitchListTile( + value: _useKey, + onChanged: + keys.isEmpty ? null : (v) => setState(() => _useKey = v), + title: const Text('Use SSH key', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: keys.isEmpty + ? const Text('No keys imported — using password', + style: + TextStyle(color: AppColors.textSecondary, fontSize: 12)) + : null, + ), + if (_useKey) + DropdownButtonFormField( + initialValue: _keyId, + items: [ + for (final SshKeyEntry k in keys) + DropdownMenuItem(value: k.id, child: Text(k.label)), + ], + onChanged: (v) => setState(() => _keyId = v), + decoration: const InputDecoration(labelText: 'Key'), + ) + else + _field(_password, 'Password', + fieldKey: 'host-password', obscure: true), + ], + ), + ), + ); + } + + Widget _field( + TextEditingController c, + String label, { + required String fieldKey, + bool obscure = false, + TextInputType? keyboard, + List? formatters, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + key: Key(fieldKey), + controller: c, + obscureText: obscure, + keyboardType: keyboard, + inputFormatters: formatters, + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration(labelText: label), + ), + ); + } +} diff --git a/app/test/mobile/mobile_add_host_screen_test.dart b/app/test/mobile/mobile_add_host_screen_test.dart new file mode 100644 index 00000000..9c6d2781 --- /dev/null +++ b/app/test/mobile/mobile_add_host_screen_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/mobile/screens/mobile_add_host_screen.dart'; +import 'package:yourssh/services/storage_service.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('saving creates a host in the provider', (tester) async { + final hosts = HostProvider(StorageService()); + await tester.pumpWidget(MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: hosts), + ChangeNotifierProvider(create: (_) => KeyProvider()), + ], + child: const MobileAddHostScreen(), + ), + )); + + await tester.enterText(find.byKey(const Key('host-label')), 'edge-1'); + await tester.enterText(find.byKey(const Key('host-address')), '192.168.1.9'); + await tester.enterText(find.byKey(const Key('host-username')), 'pi'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect( + hosts.allHosts + .any((h) => h.label == 'edge-1' && h.host == '192.168.1.9'), + isTrue); + }); +} From 1bfdb03aea1961bd4de18dcdc34c695e8db8db58 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:32:17 +0700 Subject: [PATCH 12/96] feat(mobile): sessions screen with terminal + status --- .../screens/mobile_sessions_screen.dart | 107 ++++++++++++++++++ .../mobile/mobile_sessions_screen_test.dart | 25 ++++ 2 files changed, 132 insertions(+) create mode 100644 app/lib/mobile/screens/mobile_sessions_screen.dart create mode 100644 app/test/mobile/mobile_sessions_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart new file mode 100644 index 00000000..330e69c8 --- /dev/null +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:xterm/xterm.dart'; + +import '../../models/ssh_session.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Sessions tab: a session strip + the active SSH session's terminal (when +/// connected) or its connection status. The accessory key bar (M2.2) docks +/// below the terminal. +class MobileSessionsScreen extends StatelessWidget { + const MobileSessionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final sp = context.watch(); + final ssh = sp.sshSessions; + + if (ssh.isEmpty) { + return const Scaffold( + backgroundColor: AppColors.bg, + body: Center( + child: Text('No active sessions — connect from Hosts', + style: TextStyle(color: AppColors.textSecondary)), + ), + ); + } + + final active = sp.activeSession is SshSession + ? sp.activeSession as SshSession + : ssh.last; + + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + child: Column( + children: [ + _SessionStrip(sessions: ssh, activeId: active.id), + const Divider(height: 1, color: AppColors.border), + Expanded(child: _SessionBody(session: active)), + ], + ), + ), + ); + } +} + +class _SessionStrip extends StatelessWidget { + final List sessions; + final String activeId; + const _SessionStrip({required this.sessions, required this.activeId}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + children: [ + for (final s in sessions) + Padding( + padding: const EdgeInsets.only(right: 6), + child: ChoiceChip( + label: Text(s.tabLabel), + selected: s.id == activeId, + onSelected: (_) => + context.read().setActive(s.id), + ), + ), + ], + ), + ); + } +} + +class _SessionBody extends StatelessWidget { + final SshSession session; + const _SessionBody({required this.session}); + + @override + Widget build(BuildContext context) { + if (session.status == SessionStatus.connected) { + return TerminalView(session.terminal); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (session.status == SessionStatus.connecting) + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: CircularProgressIndicator(), + ), + Text(session.statusLabel, + style: const TextStyle(color: AppColors.textSecondary)), + if (session.errorMessage != null) ...[ + const SizedBox(height: 8), + Text(session.errorMessage!, + style: const TextStyle(color: AppColors.red, fontSize: 12)), + ], + ], + ), + ); + } +} diff --git a/app/test/mobile/mobile_sessions_screen_test.dart b/app/test/mobile/mobile_sessions_screen_test.dart new file mode 100644 index 00000000..22b6bdcb --- /dev/null +++ b/app/test/mobile/mobile_sessions_screen_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/screens/mobile_sessions_screen.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('empty state with no sessions', (tester) async { + final sp = + SessionProvider(SshService(StorageService()), TabMetadataService()); + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: sp, + child: const MobileSessionsScreen(), + ), + )); + expect(find.textContaining('No active sessions'), findsOneWidget); + }); +} From d3d399b03c183e92e364c297589c9fa6f8ef56c5 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:32:56 +0700 Subject: [PATCH 13/96] feat(mobile): wire hosts + sessions into shell --- app/lib/mobile/screens/mobile_home_shell.dart | 41 +++++++++++----- app/test/mobile/mobile_home_shell_test.dart | 47 ++++++++++++------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 98111b9a..6f99a165 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; +import 'mobile_add_host_screen.dart'; +import 'mobile_hosts_screen.dart'; +import 'mobile_sessions_screen.dart'; -/// Bottom-navigation shell for the Android app. M1 renders placeholder bodies; -/// each destination is filled in over later milestones (M2 terminal, M3 sync, -/// M4 SFTP/snippets, M5 settings/app-lock). +/// Bottom-navigation shell for the Android app. Hosts (0) and Sessions (1) are +/// live; SFTP (2) and Settings (3) are placeholders filled in M4/M5. class MobileHomeShell extends StatefulWidget { const MobileHomeShell({super.key}); @@ -23,18 +25,35 @@ class _MobileHomeShellState extends State { Icons.settings_outlined, ]; + void _openAddHost() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileAddHostScreen()), + ); + } + + Widget _body() { + switch (_index) { + case 0: + return MobileHostsScreen( + onConnected: () => setState(() => _index = 1), + onAddHost: _openAddHost, + ); + case 1: + return const MobileSessionsScreen(); + default: + return Center( + child: Text('${_labels[_index]} — coming soon', + style: + const TextStyle(color: AppColors.textSecondary, fontSize: 15)), + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.bg, - body: SafeArea( - child: Center( - child: Text( - '${_labels[_index]} — coming soon', - style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), - ), - ), - ), + body: _body(), bottomNavigationBar: NavigationBar( selectedIndex: _index, onDestinationSelected: (i) => setState(() => _index = i), diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index 9cca9e2d..acf0cd03 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -1,28 +1,43 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +Future _pump(WidgetTester tester) async { + final storage = StorageService(); + final ssh = SshService(storage); + await tester.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => HostProvider(storage)), + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider( + create: (_) => SessionProvider(ssh, TabMetadataService())), + ], + child: const MaterialApp(home: MobileHomeShell()), + )); + await tester.pumpAndSettle(); +} void main() { - testWidgets('shows four destinations and Hosts first', (tester) async { - await tester.pumpWidget(const MaterialApp(home: MobileHomeShell())); + setUp(() => SharedPreferences.setMockInitialValues({})); + testWidgets('shows four destinations; Hosts first', (tester) async { + await _pump(tester); expect(find.byType(NavigationBar), findsOneWidget); - expect(find.text('Hosts'), findsWidgets); - expect(find.text('Sessions'), findsWidgets); - expect(find.text('SFTP'), findsWidgets); - expect(find.text('Settings'), findsWidgets); - - // Hosts tab body is shown first. - expect(find.text('Hosts — coming soon'), findsOneWidget); + expect(find.textContaining('No hosts'), findsOneWidget); // Hosts screen body }); - testWidgets('tapping a destination switches the body', (tester) async { - await tester.pumpWidget(const MaterialApp(home: MobileHomeShell())); - - await tester.tap(find.text('Settings').last); + testWidgets('switches to SFTP placeholder', (tester) async { + await _pump(tester); + await tester.tap(find.text('SFTP').last); await tester.pumpAndSettle(); - - expect(find.text('Settings — coming soon'), findsOneWidget); - expect(find.text('Hosts — coming soon'), findsNothing); + expect(find.text('SFTP — coming soon'), findsOneWidget); }); } From 59bec980bcb3c7200c0ce4a6647a89e56cebb103 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:33:29 +0700 Subject: [PATCH 14/96] feat(mobile): accessory bar sticky-modifier controller --- .../terminal/accessory_bar_controller.dart | 33 +++++++++++++++++++ .../mobile/accessory_bar_controller_test.dart | 29 ++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 app/lib/mobile/terminal/accessory_bar_controller.dart create mode 100644 app/test/mobile/accessory_bar_controller_test.dart diff --git a/app/lib/mobile/terminal/accessory_bar_controller.dart b/app/lib/mobile/terminal/accessory_bar_controller.dart new file mode 100644 index 00000000..b8d44cee --- /dev/null +++ b/app/lib/mobile/terminal/accessory_bar_controller.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; + +/// Sticky Ctrl/Alt state for the mobile terminal accessory bar. Modifiers are +/// one-shot: arming sets the flag, the next emitted key consumes and clears it +/// (mstsc/Termux-style). A [ChangeNotifier] so the bar highlights armed keys. +class AccessoryBarController extends ChangeNotifier { + bool _ctrl = false; + bool _alt = false; + + bool get ctrlArmed => _ctrl; + bool get altArmed => _alt; + + void armCtrl() { + _ctrl = !_ctrl; + notifyListeners(); + } + + void armAlt() { + _alt = !_alt; + notifyListeners(); + } + + /// Returns the currently-armed modifiers and clears them (one-shot). + ({bool ctrl, bool alt}) consumeModifiers() { + final m = (ctrl: _ctrl, alt: _alt); + if (_ctrl || _alt) { + _ctrl = false; + _alt = false; + notifyListeners(); + } + return m; + } +} diff --git a/app/test/mobile/accessory_bar_controller_test.dart b/app/test/mobile/accessory_bar_controller_test.dart new file mode 100644 index 00000000..54b4f9fc --- /dev/null +++ b/app/test/mobile/accessory_bar_controller_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/terminal/accessory_bar_controller.dart'; + +void main() { + test('modifiers default off and consume returns false', () { + final c = AccessoryBarController(); + final m = c.consumeModifiers(); + expect(m.ctrl, isFalse); + expect(m.alt, isFalse); + }); + + test('armed ctrl is one-shot: consumed once then cleared', () { + final c = AccessoryBarController(); + c.armCtrl(); + expect(c.ctrlArmed, isTrue); + final m1 = c.consumeModifiers(); + expect(m1.ctrl, isTrue); + expect(c.ctrlArmed, isFalse); // cleared after consume + final m2 = c.consumeModifiers(); + expect(m2.ctrl, isFalse); + }); + + test('arm toggles off when armed again', () { + final c = AccessoryBarController(); + c.armCtrl(); + c.armCtrl(); + expect(c.ctrlArmed, isFalse); + }); +} From d1c5ea88dfc16c1d2fae9b5b45a07d5873c0f572 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:35:38 +0700 Subject: [PATCH 15/96] feat(mobile): accessory key bar wired to terminal --- .../screens/mobile_sessions_screen.dart | 38 +++++- .../mobile/terminal/accessory_key_bar.dart | 113 ++++++++++++++++++ app/test/mobile/accessory_key_bar_test.dart | 59 +++++++++ 3 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 app/lib/mobile/terminal/accessory_key_bar.dart create mode 100644 app/test/mobile/accessory_key_bar_test.dart diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart index 330e69c8..c879241d 100644 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -5,13 +5,28 @@ import 'package:xterm/xterm.dart'; import '../../models/ssh_session.dart'; import '../../providers/session_provider.dart'; import '../../theme/app_theme.dart'; +import '../terminal/accessory_bar_controller.dart'; +import '../terminal/accessory_key_bar.dart'; /// Sessions tab: a session strip + the active SSH session's terminal (when -/// connected) or its connection status. The accessory key bar (M2.2) docks -/// below the terminal. -class MobileSessionsScreen extends StatelessWidget { +/// connected, with the accessory key bar docked below) or its connection +/// status. +class MobileSessionsScreen extends StatefulWidget { const MobileSessionsScreen({super.key}); + @override + State createState() => _MobileSessionsScreenState(); +} + +class _MobileSessionsScreenState extends State { + final _accessory = AccessoryBarController(); + + @override + void dispose() { + _accessory.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final sp = context.watch(); @@ -38,7 +53,7 @@ class MobileSessionsScreen extends StatelessWidget { children: [ _SessionStrip(sessions: ssh, activeId: active.id), const Divider(height: 1, color: AppColors.border), - Expanded(child: _SessionBody(session: active)), + Expanded(child: _SessionBody(session: active, accessory: _accessory)), ], ), ), @@ -77,12 +92,23 @@ class _SessionStrip extends StatelessWidget { class _SessionBody extends StatelessWidget { final SshSession session; - const _SessionBody({required this.session}); + final AccessoryBarController accessory; + const _SessionBody({required this.session, required this.accessory}); @override Widget build(BuildContext context) { if (session.status == SessionStatus.connected) { - return TerminalView(session.terminal); + return Column( + children: [ + Expanded(child: TerminalView(session.terminal)), + AccessoryKeyBar( + controller: accessory, + onKey: (k, {ctrl = false, alt = false}) => + session.terminal.keyInput(k, ctrl: ctrl, alt: alt), + onText: (s) => session.terminal.textInput(s), + ), + ], + ); } return Center( child: Column( diff --git a/app/lib/mobile/terminal/accessory_key_bar.dart b/app/lib/mobile/terminal/accessory_key_bar.dart new file mode 100644 index 00000000..8b0b1386 --- /dev/null +++ b/app/lib/mobile/terminal/accessory_key_bar.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:xterm/xterm.dart'; + +import '../../theme/app_theme.dart'; +import 'accessory_bar_controller.dart'; + +/// Mobile terminal accessory bar: special keys + sticky Ctrl/Alt above the +/// soft keyboard. Emits xterm [TerminalKey]s (with consumed modifiers) via +/// [onKey] and literal characters via [onText]. Dedicated `^C` / `^D` buttons +/// send the critical interrupts directly, independent of the sticky state. +class AccessoryKeyBar extends StatelessWidget { + final AccessoryBarController controller; + final void Function(TerminalKey key, {bool ctrl, bool alt}) onKey; + final void Function(String text) onText; + + const AccessoryKeyBar({ + super.key, + required this.controller, + required this.onKey, + required this.onText, + }); + + /// Emit a special key, applying (and clearing) any armed sticky modifiers. + void _key(TerminalKey k) { + final m = controller.consumeModifiers(); + onKey(k, ctrl: m.ctrl, alt: m.alt); + } + + /// Emit a literal character; armed modifiers are consumed (cleared) since + /// plain text carries no Ctrl/Alt. + void _text(String s) { + controller.consumeModifiers(); + onText(s); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return Container( + height: 46, + color: AppColors.card, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + children: [ + _btn('Esc', onTap: () => _key(TerminalKey.escape)), + _btn('Tab', onTap: () => _key(TerminalKey.tab)), + _btn('Ctrl', + armed: controller.ctrlArmed, onTap: controller.armCtrl), + _btn('Alt', armed: controller.altArmed, onTap: controller.armAlt), + _btn('^C', onTap: () => onKey(TerminalKey.keyC, ctrl: true)), + _btn('^D', onTap: () => onKey(TerminalKey.keyD, ctrl: true)), + _icon(Icons.keyboard_arrow_left, 'Left', + () => _key(TerminalKey.arrowLeft)), + _icon(Icons.keyboard_arrow_up, 'Up', + () => _key(TerminalKey.arrowUp)), + _icon(Icons.keyboard_arrow_down, 'Down', + () => _key(TerminalKey.arrowDown)), + _icon(Icons.keyboard_arrow_right, 'Right', + () => _key(TerminalKey.arrowRight)), + for (final ch in const ['/', '-', '|', '~', ':']) + _btn(ch, onTap: () => _text(ch)), + ], + ), + ); + }, + ); + } + + Widget _btn(String label, {required VoidCallback onTap, bool armed = false}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: InkWell( + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: armed ? AppColors.accent : AppColors.bg, + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, + style: TextStyle( + color: armed ? Colors.black : AppColors.textPrimary, + fontSize: 14)), + ), + ), + ); + } + + Widget _icon(IconData icon, String tooltip, VoidCallback onTap) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: AppColors.bg, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppColors.textPrimary, size: 20), + ), + ), + ), + ); + } +} diff --git a/app/test/mobile/accessory_key_bar_test.dart b/app/test/mobile/accessory_key_bar_test.dart new file mode 100644 index 00000000..d41f52cb --- /dev/null +++ b/app/test/mobile/accessory_key_bar_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh/mobile/terminal/accessory_bar_controller.dart'; +import 'package:yourssh/mobile/terminal/accessory_key_bar.dart'; + +Future _pump( + WidgetTester tester, { + required AccessoryBarController controller, + void Function(TerminalKey, {bool ctrl, bool alt})? onKey, + void Function(String)? onText, +}) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: AccessoryKeyBar( + controller: controller, + onKey: onKey ?? (k, {ctrl = false, alt = false}) {}, + onText: onText ?? (_) {}, + ), + ), + )); +} + +void main() { + testWidgets('Esc taps emit the escape key', (tester) async { + TerminalKey? got; + await _pump(tester, + controller: AccessoryBarController(), + onKey: (k, {ctrl = false, alt = false}) => got = k); + await tester.tap(find.text('Esc')); + expect(got, TerminalKey.escape); + }); + + testWidgets('Ctrl then arrow emits a ctrl-modified key', (tester) async { + bool? gotCtrl; + final c = AccessoryBarController(); + await _pump(tester, + controller: c, + onKey: (k, {ctrl = false, alt = false}) => gotCtrl = ctrl); + await tester.tap(find.text('Ctrl')); + await tester.pump(); + await tester.tap(find.byTooltip('Up')); + expect(gotCtrl, isTrue); + }); + + testWidgets('^C emits Ctrl+C directly', (tester) async { + TerminalKey? gotKey; + var gotCtrl = false; + await _pump(tester, + controller: AccessoryBarController(), + onKey: (k, {ctrl = false, alt = false}) { + gotKey = k; + gotCtrl = ctrl; + }); + await tester.tap(find.text('^C')); + expect(gotKey, TerminalKey.keyC); + expect(gotCtrl, isTrue); + }); +} From 37bcac26fc6b6586941ba41ee162afa35379e979 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 07:36:49 +0700 Subject: [PATCH 16/96] feat(mobile): pinch-to-zoom terminal font --- .../screens/mobile_sessions_screen.dart | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart index c879241d..eaab682f 100644 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -9,8 +9,8 @@ import '../terminal/accessory_bar_controller.dart'; import '../terminal/accessory_key_bar.dart'; /// Sessions tab: a session strip + the active SSH session's terminal (when -/// connected, with the accessory key bar docked below) or its connection -/// status. +/// connected, with the accessory key bar docked below and pinch-to-zoom font) +/// or its connection status. class MobileSessionsScreen extends StatefulWidget { const MobileSessionsScreen({super.key}); @@ -20,6 +20,8 @@ class MobileSessionsScreen extends StatefulWidget { class _MobileSessionsScreenState extends State { final _accessory = AccessoryBarController(); + double _fontSize = 14; + double _scaleBase = 14; @override void dispose() { @@ -27,6 +29,13 @@ class _MobileSessionsScreenState extends State { super.dispose(); } + void _onScaleStart(ScaleStartDetails _) => _scaleBase = _fontSize; + + void _onScaleUpdate(ScaleUpdateDetails d) { + if (d.scale == 1.0) return; + setState(() => _fontSize = (_scaleBase * d.scale).clamp(8.0, 28.0)); + } + @override Widget build(BuildContext context) { final sp = context.watch(); @@ -53,7 +62,15 @@ class _MobileSessionsScreenState extends State { children: [ _SessionStrip(sessions: ssh, activeId: active.id), const Divider(height: 1, color: AppColors.border), - Expanded(child: _SessionBody(session: active, accessory: _accessory)), + Expanded( + child: _SessionBody( + session: active, + accessory: _accessory, + fontSize: _fontSize, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + ), + ), ], ), ), @@ -93,14 +110,33 @@ class _SessionStrip extends StatelessWidget { class _SessionBody extends StatelessWidget { final SshSession session; final AccessoryBarController accessory; - const _SessionBody({required this.session, required this.accessory}); + final double fontSize; + final void Function(ScaleStartDetails) onScaleStart; + final void Function(ScaleUpdateDetails) onScaleUpdate; + + const _SessionBody({ + required this.session, + required this.accessory, + required this.fontSize, + required this.onScaleStart, + required this.onScaleUpdate, + }); @override Widget build(BuildContext context) { if (session.status == SessionStatus.connected) { return Column( children: [ - Expanded(child: TerminalView(session.terminal)), + Expanded( + child: GestureDetector( + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + child: TerminalView( + session.terminal, + textStyle: TerminalStyle(fontSize: fontSize), + ), + ), + ), AccessoryKeyBar( controller: accessory, onKey: (k, {ctrl = false, alt = false}) => From 57603650ef84ddab8f3826809d04ee118894e794 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:01:47 +0700 Subject: [PATCH 17/96] docs(plan): Android mobile app M3 implementation plan --- .../plans/2026-06-25-android-mobile-app-m3.md | 622 ++++++++++++++++++ 1 file changed, 622 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-android-mobile-app-m3.md diff --git a/docs/superpowers/plans/2026-06-25-android-mobile-app-m3.md b/docs/superpowers/plans/2026-06-25-android-mobile-app-m3.md new file mode 100644 index 00000000..ba016316 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-android-mobile-app-m3.md @@ -0,0 +1,622 @@ +# Android Mobile App — Milestone 3 (Sync import) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On Android, import hosts from the desktop — pull from Supabase cloud sync, and scan a desktop's P2P QR code with the camera. + +**Architecture:** Reuse the existing sync stack unchanged — `SyncProvider`/`SyncService` (cloud), `P2PSyncService`/`P2PSyncEncryption` + `SyncService.parsePayload` (P2P) — and apply results through the existing `HostProvider.replaceAll(hosts, passwords)`, exactly as desktop does. M3.1 adds cloud pull (no new dependency). M3.2 adds camera QR scanning, which requires a new `mobile_scanner` dependency and the Android camera permission. + +**Tech Stack:** Flutter, `provider`, existing sync services, `mobile_scanner` (new, M3.2). + +## Global Constraints + +- Dart package `yourssh`; dark-only (`AppColors`); reuse existing classes; don't modify desktop bootstrap/widgets. +- Mobile is a sync **consumer** (pull/receive only) — no push from mobile. +- Cloud apply and P2P apply both go through `HostProvider.replaceAll` (replaces all hosts — same semantics as desktop import). +- Desktop builds + full `flutter test` + `flutter analyze` MUST stay green. +- No Claude attribution. Commit after each task. Branch `feat/android-mobile-app`. + +## Key existing APIs (verified) + +- `SyncProvider({StorageService? storage})` auto-loads in ctor; `setSupabaseConfig(url, anonKey)`, `setSyncCode(value)`, getters `supabaseUrl`/`supabaseAnonKey`/`syncCode`/`enabled`/`status` (`SyncStatus.{idle,syncing,synced,error}`)/`error`, `isSupabaseConfigured`, `hasSyncCode`. +- `SyncService(SyncProvider)`; `Future pull()` (returns null when disabled/no-change/error; sets provider status). +- `HostProvider.replaceAll(List hosts, Map passwords)`. +- P2P transfer code is JSON `{"u": "", "k": ""}`. `P2PSyncService().fetchPayload(url) → Future` (encrypted); `P2PSyncEncryption.decrypt(encrypted, List key) → Future`; `SyncService.parsePayload(decrypted) → SyncPayload`. +- `SyncCode.length == 12`. + +--- + +## Phase M3.1 — Cloud sync pull (no new dependency) + +### Task 1: Add SyncProvider + SyncService to the mobile bootstrap + +**Files:** +- Modify: `app/lib/mobile/mobile_bootstrap.dart` +- Test: `app/test/mobile/mobile_bootstrap_test.dart` (extend) + +**Interfaces:** +- Produces: `MobileBootstrap.sync` (SyncProvider) and `MobileBootstrap.syncService` (SyncService); both added to `providers` (SyncProvider as ChangeNotifier, SyncService as plain Provider). + +- [ ] **Step 1: Extend the bootstrap test** + +Add to `app/test/mobile/mobile_bootstrap_test.dart` inside `main()`: +```dart + test('exposes sync provider + service', () { + final b = MobileBootstrap(); + expect(b.sync, isNotNull); + expect(b.syncService, isNotNull); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_bootstrap_test.dart` +Expected: FAIL — `sync`/`syncService` getters undefined. + +- [ ] **Step 3: Add to the bootstrap** + +In `app/lib/mobile/mobile_bootstrap.dart`, add imports: +```dart +import '../providers/sync_provider.dart'; +import '../services/sync_service.dart'; +``` +Add fields + construction (after `knownHosts`): +```dart + late final SyncProvider sync; + late final SyncService syncService; +``` +In the constructor body (after `knownHosts = ...`): +```dart + sync = SyncProvider(storage: storage); + syncService = SyncService(sync); +``` +Add to the `providers` getter list: +```dart + ChangeNotifierProvider.value(value: sync), + Provider.value(value: syncService), +``` + +- [ ] **Step 4: Run test + analyze** + +Run: `cd app && flutter test test/mobile/mobile_bootstrap_test.dart && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/mobile_bootstrap.dart app/test/mobile/mobile_bootstrap_test.dart +git commit -m "feat(mobile): add sync provider + service to bootstrap" +``` + +--- + +### Task 2: Settings screen with Cloud Sync section + Pull + +**Files:** +- Create: `app/lib/mobile/screens/mobile_settings_screen.dart` +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` (index 3 → settings screen) +- Test: `app/test/mobile/mobile_settings_screen_test.dart` + +**Interfaces:** +- Consumes: `SyncProvider` (config getters + `setSupabaseConfig`/`setSyncCode`), `SyncService.pull()`, `HostProvider.replaceAll`. +- Produces: `class MobileSettingsScreen extends StatefulWidget` — Cloud Sync card (URL / anon key / sync code fields seeded from the provider, Save persists via `setSupabaseConfig` + `setSyncCode`, "Pull from cloud" runs `pull()` → `replaceAll` → SnackBar with count or the provider error). P2P entry is added in M3.2. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_settings_screen_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/screens/mobile_settings_screen.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/sync_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/sync_service.dart'; + +Future _pump(WidgetTester tester, SyncProvider sync) async { + final storage = StorageService(); + await tester.pumpWidget(MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider(create: (_) => HostProvider(storage)), + Provider(create: (_) => SyncService(sync)), + ], + child: const MobileSettingsScreen(), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('saving cloud config writes to the provider', (tester) async { + final sync = SyncProvider(); + await _pump(tester, sync); + + await tester.enterText( + find.byKey(const Key('sync-url')), 'https://x.supabase.co'); + await tester.enterText(find.byKey(const Key('sync-anon')), 'anon-key-123'); + await tester.enterText( + find.byKey(const Key('sync-code')), '123456789012'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(sync.supabaseUrl, 'https://x.supabase.co'); + expect(sync.supabaseAnonKey, 'anon-key-123'); + expect(sync.syncCode, '123456789012'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_settings_screen_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/screens/mobile_settings_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/host_provider.dart'; +import '../../providers/sync_provider.dart'; +import '../../services/sync_service.dart'; +import '../../theme/app_theme.dart'; + +/// Settings tab. M3 ships the Sync section (cloud pull; P2P scan added in +/// M3.2). Appearance + app-lock arrive in M5. +class MobileSettingsScreen extends StatefulWidget { + const MobileSettingsScreen({super.key}); + + @override + State createState() => _MobileSettingsScreenState(); +} + +class _MobileSettingsScreenState extends State { + final _url = TextEditingController(); + final _anon = TextEditingController(); + final _code = TextEditingController(); + bool _seeded = false; + bool _pulling = false; + + @override + void dispose() { + _url.dispose(); + _anon.dispose(); + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + if (!_seeded) { + _url.text = sync.supabaseUrl; + _anon.text = sync.supabaseAnonKey; + _code.text = sync.syncCode; + _seeded = true; + } + + return Scaffold( + backgroundColor: AppColors.bg, + appBar: AppBar( + backgroundColor: AppColors.bg, title: const Text('Settings')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Cloud Sync', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text('Pull hosts from your desktop via Supabase.', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: 12), + _field(_url, 'Supabase URL', 'sync-url'), + _field(_anon, 'Anon key', 'sync-anon'), + _field(_code, 'Sync code (12 chars)', 'sync-code'), + const SizedBox(height: 8), + Row( + children: [ + FilledButton(onPressed: _save, child: const Text('Save')), + const SizedBox(width: 10), + OutlinedButton( + onPressed: _pulling ? null : _pull, + child: _pulling + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Pull from cloud'), + ), + ], + ), + if (sync.error != null) ...[ + const SizedBox(height: 10), + Text(sync.error!, + style: const TextStyle(color: AppColors.red, fontSize: 12)), + ], + ], + ), + ), + ); + } + + Future _save() async { + final sync = context.read(); + await sync.setSupabaseConfig(_url.text, _anon.text); + await sync.setSyncCode(_code.text); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Sync settings saved'))); + } + } + + Future _pull() async { + setState(() => _pulling = true); + final payload = await context.read().pull(); + if (!mounted) return; + if (payload != null) { + await context + .read() + .replaceAll(payload.hosts, payload.passwords); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Imported ${payload.hosts.length} hosts from cloud'))); + } + } else if (mounted) { + final err = context.read().error; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err ?? 'Nothing new to pull'))); + } + if (mounted) setState(() => _pulling = false); + } + + Widget _field(TextEditingController c, String label, String key) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + key: Key(key), + controller: c, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + decoration: InputDecoration(labelText: label), + ), + ); + } +} +``` + +- [ ] **Step 4: Wire into the shell (index 3)** + +In `app/lib/mobile/screens/mobile_home_shell.dart`, import `mobile_settings_screen.dart` and change the `_body()` switch: add `case 3: return const MobileSettingsScreen();` (keep SFTP at 2 as the placeholder). The `default` now only covers index 2. + +- [ ] **Step 5: Run tests + analyze** + +Run: `cd app && flutter test test/mobile/ && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/mobile/screens/mobile_settings_screen.dart app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/mobile_settings_screen_test.dart +git commit -m "feat(mobile): settings screen with cloud sync pull" +``` + +--- + +## Phase M3.2 — P2P QR camera import (adds mobile_scanner) + +### Task 3: Transfer-code parser (pure) + +**Files:** +- Create: `app/lib/mobile/sync/transfer_code.dart` +- Test: `app/test/mobile/transfer_code_test.dart` + +**Interfaces:** +- Produces: `({String url, List key}) parseTransferCode(String raw)` — decodes the `{"u","k"}` JSON; throws `FormatException` on missing/blank fields or bad base64. Used by the QR scan screen so the network/decrypt steps run on validated input. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/transfer_code_test.dart`: +```dart +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/sync/transfer_code.dart'; + +void main() { + test('parses a valid transfer code', () { + final key = List.generate(32, (i) => i); + final raw = jsonEncode({'u': 'http://192.168.1.5:8080/sync', 'k': base64.encode(key)}); + final parsed = parseTransferCode(raw); + expect(parsed.url, 'http://192.168.1.5:8080/sync'); + expect(parsed.key, key); + }); + + test('throws on missing fields', () { + expect(() => parseTransferCode('{"u":"http://x"}'), throwsFormatException); + expect(() => parseTransferCode('not json'), throwsFormatException); + expect(() => parseTransferCode('{"u":"","k":"AAAA"}'), throwsFormatException); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/transfer_code_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/mobile/sync/transfer_code.dart`: +```dart +import 'dart:convert'; + +/// Parses a P2P transfer code (the JSON encoded in the desktop's QR / export +/// string: `{"u": "", "k": ""}`). Throws [FormatException] +/// on malformed input so the caller can show a clear "invalid code" error. +({String url, List key}) parseTransferCode(String raw) { + final Object? decoded; + try { + decoded = jsonDecode(raw.trim()); + } catch (_) { + throw const FormatException('Not a valid transfer code'); + } + if (decoded is! Map) throw const FormatException('Not a transfer code'); + final url = decoded['u']; + final k = decoded['k']; + if (url is! String || url.isEmpty || k is! String || k.isEmpty) { + throw const FormatException('Transfer code is missing fields'); + } + final List key; + try { + key = base64.decode(k); + } catch (_) { + throw const FormatException('Transfer code key is invalid'); + } + return (url: url, key: key); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/transfer_code_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/sync/transfer_code.dart app/test/mobile/transfer_code_test.dart +git commit -m "feat(mobile): P2P transfer-code parser" +``` + +--- + +### Task 4: Add mobile_scanner dependency + Android camera permission + +**Files:** +- Modify: `app/pubspec.yaml` +- Modify: `app/android/app/src/main/AndroidManifest.xml` + +**Interfaces:** +- Produces: `mobile_scanner` available for import; `` declared. + +- [ ] **Step 1: Add the dependency** + +In `app/pubspec.yaml` dependencies, add (a recent stable line): +```yaml + mobile_scanner: ^5.2.3 +``` +Run: `cd app && flutter pub get` +Expected: resolves successfully. (If `^5.2.3` doesn't resolve against the pinned Flutter SDK, run `flutter pub add mobile_scanner` and accept whatever compatible version it picks.) + +- [ ] **Step 2: Add the camera permission** + +In `app/android/app/src/main/AndroidManifest.xml`, add inside `` (before ``): +```xml + +``` + +- [ ] **Step 3: Analyze** + +Run: `cd app && flutter analyze lib/mobile` +Expected: `No issues found!` (no usages yet, just the dep present). + +- [ ] **Step 4: Commit** + +```bash +git add app/pubspec.yaml app/pubspec.lock app/android/app/src/main/AndroidManifest.xml +git commit -m "build(mobile): add mobile_scanner + camera permission" +``` + +--- + +### Task 5: QR scan screen + P2P import flow + Settings entry + +**Files:** +- Create: `app/lib/mobile/screens/mobile_qr_scan_screen.dart` +- Modify: `app/lib/mobile/screens/mobile_settings_screen.dart` (add P2P section with a "Scan QR code" button) +- Test: manual (camera) — logic covered by Task 3's parser test. + +**Interfaces:** +- Consumes: `parseTransferCode`, `P2PSyncService.fetchPayload`, `P2PSyncEncryption.decrypt`, `SyncService.parsePayload`, `HostProvider.replaceAll`. +- Produces: `class MobileQrScanScreen extends StatefulWidget` — full-screen `MobileScanner`; on the first detected barcode, runs the import flow once (guarded), pops with a result, and the caller shows a SnackBar. + +- [ ] **Step 1: Write the scan screen** + +Create `app/lib/mobile/screens/mobile_qr_scan_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/host_provider.dart'; +import '../../services/p2p_sync_encryption.dart'; +import '../../services/p2p_sync_service.dart'; +import '../../services/sync_service.dart'; +import '../../theme/app_theme.dart'; +import '../sync/transfer_code.dart'; + +/// Full-screen camera QR scanner for P2P host import. On the first valid code +/// it fetches + decrypts the payload from the exporting device and replaces +/// the local host list, then pops `true` (imported count via SnackBar in the +/// caller). Pops with an error string on failure. +class MobileQrScanScreen extends StatefulWidget { + const MobileQrScanScreen({super.key}); + + @override + State createState() => _MobileQrScanScreenState(); +} + +class _MobileQrScanScreenState extends State { + final _p2p = P2PSyncService(); + bool _handled = false; + + @override + void dispose() { + _p2p.stop(); + super.dispose(); + } + + Future _onDetect(BarcodeCapture capture) async { + if (_handled) return; + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw == null) return; + _handled = true; + try { + final code = parseTransferCode(raw); + final encrypted = await _p2p.fetchPayload(code.url); + final decrypted = await P2PSyncEncryption.decrypt(encrypted, code.key); + final payload = SyncService.parsePayload(decrypted); + if (payload.hosts.isEmpty) { + throw const FormatException('No hosts in transfer'); + } + if (!mounted) return; + await context + .read() + .replaceAll(payload.hosts, payload.passwords); + if (mounted) Navigator.of(context).pop('Imported ${payload.hosts.length} hosts'); + } catch (e) { + if (mounted) { + Navigator.of(context).pop( + 'Import failed: ${e.toString().replaceFirst('FormatException: ', '')}'); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: AppColors.bg, title: const Text('Scan transfer QR')), + body: MobileScanner(onDetect: _onDetect), + ); + } +} +``` + +> Verify at execution: `mobile_scanner` v5 exposes `MobileScanner(onDetect:)` and `BarcodeCapture.barcodes` (List with `.rawValue`). If the installed major version differs, adjust the callback signature accordingly. `firstOrNull` needs `package:collection` OR use `capture.barcodes.isEmpty ? null : capture.barcodes.first`. + +- [ ] **Step 2: Add the P2P section + scan button to Settings** + +In `mobile_settings_screen.dart`, add below the Cloud Sync section a P2P block: +```dart + const SizedBox(height: 24), + const Text('P2P transfer', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text('Scan the QR code shown on your desktop (Settings → Sync → Show QR).', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scan QR code'), + onPressed: _scan, + ), +``` +And add the `_scan` method (uses a push to the scan screen + SnackBar on result): +```dart + Future _scan() async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileQrScanScreen()), + ); + if (result != null && mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(result))); + } + } +``` +Add the import: `import 'mobile_qr_scan_screen.dart';` + +- [ ] **Step 3: Analyze + mobile tests** + +Run: `cd app && flutter analyze lib/mobile && flutter test test/mobile/` +Expected: `No issues found!`; tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add app/lib/mobile/screens/mobile_qr_scan_screen.dart app/lib/mobile/screens/mobile_settings_screen.dart +git commit -m "feat(mobile): P2P QR camera import" +``` + +--- + +### Task 6: Build + regression + +**Files:** none (verification) + +- [ ] **Step 1: Full test suite** + +Run: `cd app && flutter test` +Expected: all tests pass. + +- [ ] **Step 2: Analyze whole repo** + +Run: `cd app && flutter analyze` +Expected: no NEW issues (the 2 pre-existing `integration_test` probe warnings may remain). + +- [ ] **Step 3: Build the APK (confirms mobile_scanner builds for Android)** + +Run: `cd app && flutter build apk --debug` +Expected: `✓ Built ... app-debug.apk`. If mobile_scanner bumps `minSdkVersion`, set it in `android/app/build.gradle.kts` and re-run. + +- [ ] **Step 4: Restore any regenerated desktop registrants** + +Run (keep the branch free of pub-get registrant churn): +```bash +git checkout HEAD -- app/macos/Flutter/GeneratedPluginRegistrant.swift app/windows/flutter/generated_plugin_registrant.cc app/windows/flutter/generated_plugins.cmake 2>/dev/null || true +``` + +- [ ] **Step 5: On-device (user, when a device/emulator is available)** + +Manual: Settings → enter Supabase config → Pull → hosts appear in Hosts tab; OR Settings → Scan QR (grant camera) → point at desktop QR → hosts imported. + +--- + +## Self-Review + +**Spec coverage (M3):** +- "Cloud Supabase sync import" → Tasks 1, 2 (pull → replaceAll). ✓ +- "P2P QR sync" (scan from desktop) → Tasks 3, 4, 5. ✓ +- "pull hosts from desktop" → both apply via `HostProvider.replaceAll`. ✓ + +**Placeholder scan:** No vague steps; code complete. The two `> Verify at execution` notes (mobile_scanner version API, `firstOrNull`) name concrete fallbacks. + +**Type consistency:** `MobileBootstrap.sync`/`syncService` (Task 1) used in Settings test wiring (Task 2). `parseTransferCode` return shape `({String url, List key})` (Task 3) consumed in the scan screen (Task 5). `replaceAll(hosts, passwords)` used identically in cloud (Task 2) and P2P (Task 5) paths. + +## Deferred + +- Mobile→desktop push (mobile stays a consumer). +- TOFU mismatch dialog, appearance, app-lock → M5. SFTP, snippets/history → M4. From 980df132dc859dfe5a91e97580cc453dd6c9baf0 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:02:55 +0700 Subject: [PATCH 18/96] feat(mobile): add sync provider + service to bootstrap --- app/lib/mobile/mobile_bootstrap.dart | 8 ++++++++ app/test/mobile/mobile_bootstrap_test.dart | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/app/lib/mobile/mobile_bootstrap.dart b/app/lib/mobile/mobile_bootstrap.dart index 80d7749e..82224015 100644 --- a/app/lib/mobile/mobile_bootstrap.dart +++ b/app/lib/mobile/mobile_bootstrap.dart @@ -6,8 +6,10 @@ import '../providers/key_provider.dart'; import '../providers/known_hosts_provider.dart'; import '../providers/session_provider.dart'; import '../providers/settings_provider.dart'; +import '../providers/sync_provider.dart'; import '../services/ssh_service.dart'; import '../services/storage_service.dart'; +import '../services/sync_service.dart'; import '../services/tab_metadata_service.dart'; /// Constructs the platform-agnostic services/providers the Android app needs @@ -22,6 +24,8 @@ class MobileBootstrap { late final SettingsProvider settings; late final KnownHostsProvider knownHosts; late final SessionProvider sessions; + late final SyncProvider sync; + late final SyncService syncService; MobileBootstrap() { storage = StorageService(); @@ -31,6 +35,8 @@ class MobileBootstrap { settings = SettingsProvider(); knownHosts = KnownHostsProvider(storage)..load(); sessions = SessionProvider(ssh, TabMetadataService()); + sync = SyncProvider(storage: storage); + syncService = SyncService(sync); _wire(); } @@ -59,5 +65,7 @@ class MobileBootstrap { ChangeNotifierProvider.value(value: settings), ChangeNotifierProvider.value(value: knownHosts), ChangeNotifierProvider.value(value: sessions), + ChangeNotifierProvider.value(value: sync), + Provider.value(value: syncService), ]; } diff --git a/app/test/mobile/mobile_bootstrap_test.dart b/app/test/mobile/mobile_bootstrap_test.dart index 38df58fe..5e926e22 100644 --- a/app/test/mobile/mobile_bootstrap_test.dart +++ b/app/test/mobile/mobile_bootstrap_test.dart @@ -19,4 +19,10 @@ void main() { // Exposes a provider list for the widget tree. expect(b.providers, isNotEmpty); }); + + test('exposes sync provider + service', () { + final b = MobileBootstrap(); + expect(b.sync, isNotNull); + expect(b.syncService, isNotNull); + }); } From 151fbdfb6d0f9b8f2ad3d9eaed943ffb1b2cdb13 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:04:29 +0700 Subject: [PATCH 19/96] feat(mobile): settings screen with cloud sync pull --- app/lib/mobile/screens/mobile_home_shell.dart | 3 + .../screens/mobile_settings_screen.dart | 132 ++++++++++++++++++ .../mobile/mobile_settings_screen_test.dart | 44 ++++++ 3 files changed, 179 insertions(+) create mode 100644 app/lib/mobile/screens/mobile_settings_screen.dart create mode 100644 app/test/mobile/mobile_settings_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 6f99a165..73760038 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -4,6 +4,7 @@ import '../../theme/app_theme.dart'; import 'mobile_add_host_screen.dart'; import 'mobile_hosts_screen.dart'; import 'mobile_sessions_screen.dart'; +import 'mobile_settings_screen.dart'; /// Bottom-navigation shell for the Android app. Hosts (0) and Sessions (1) are /// live; SFTP (2) and Settings (3) are placeholders filled in M4/M5. @@ -40,6 +41,8 @@ class _MobileHomeShellState extends State { ); case 1: return const MobileSessionsScreen(); + case 3: + return const MobileSettingsScreen(); default: return Center( child: Text('${_labels[_index]} — coming soon', diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart new file mode 100644 index 00000000..6b926065 --- /dev/null +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/host_provider.dart'; +import '../../providers/sync_provider.dart'; +import '../../services/sync_service.dart'; +import '../../theme/app_theme.dart'; + +/// Settings tab. M3.1 ships the Cloud Sync section (pull from desktop). The +/// P2P QR scan entry is added in M3.2; appearance + app-lock in M5. +class MobileSettingsScreen extends StatefulWidget { + const MobileSettingsScreen({super.key}); + + @override + State createState() => _MobileSettingsScreenState(); +} + +class _MobileSettingsScreenState extends State { + final _url = TextEditingController(); + final _anon = TextEditingController(); + final _code = TextEditingController(); + bool _seeded = false; + bool _pulling = false; + + @override + void dispose() { + _url.dispose(); + _anon.dispose(); + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + if (!_seeded) { + _url.text = sync.supabaseUrl; + _anon.text = sync.supabaseAnonKey; + _code.text = sync.syncCode; + _seeded = true; + } + + return Scaffold( + backgroundColor: AppColors.bg, + appBar: + AppBar(backgroundColor: AppColors.bg, title: const Text('Settings')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Cloud Sync', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text('Pull hosts from your desktop via Supabase.', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: 12), + _field(_url, 'Supabase URL', 'sync-url'), + _field(_anon, 'Anon key', 'sync-anon'), + _field(_code, 'Sync code (12 chars)', 'sync-code'), + const SizedBox(height: 8), + Row( + children: [ + FilledButton(onPressed: _save, child: const Text('Save')), + const SizedBox(width: 10), + OutlinedButton( + onPressed: _pulling ? null : _pull, + child: _pulling + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Pull from cloud'), + ), + ], + ), + if (sync.error != null) ...[ + const SizedBox(height: 10), + Text(sync.error!, + style: const TextStyle(color: AppColors.red, fontSize: 12)), + ], + ], + ), + ), + ); + } + + Future _save() async { + final sync = context.read(); + await sync.setSupabaseConfig(_url.text, _anon.text); + await sync.setSyncCode(_code.text); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Sync settings saved'))); + } + } + + Future _pull() async { + setState(() => _pulling = true); + final payload = await context.read().pull(); + if (!mounted) return; + if (payload != null) { + await context + .read() + .replaceAll(payload.hosts, payload.passwords); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text('Imported ${payload.hosts.length} hosts from cloud'))); + } + } else if (mounted) { + final err = context.read().error; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(err ?? 'Nothing new to pull'))); + } + if (mounted) setState(() => _pulling = false); + } + + Widget _field(TextEditingController c, String label, String key) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + key: Key(key), + controller: c, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + decoration: InputDecoration(labelText: label), + ), + ); + } +} diff --git a/app/test/mobile/mobile_settings_screen_test.dart b/app/test/mobile/mobile_settings_screen_test.dart new file mode 100644 index 00000000..feee6aec --- /dev/null +++ b/app/test/mobile/mobile_settings_screen_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/screens/mobile_settings_screen.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/sync_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/sync_service.dart'; + +Future _pump(WidgetTester tester, SyncProvider sync) async { + final storage = StorageService(); + await tester.pumpWidget(MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider(create: (_) => HostProvider(storage)), + Provider(create: (_) => SyncService(sync)), + ], + child: const MobileSettingsScreen(), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('saving cloud config writes to the provider', (tester) async { + final sync = SyncProvider(); + await _pump(tester, sync); + + await tester.enterText( + find.byKey(const Key('sync-url')), 'https://x.supabase.co'); + await tester.enterText(find.byKey(const Key('sync-anon')), 'anon-key-123'); + await tester.enterText(find.byKey(const Key('sync-code')), '123456789012'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(sync.supabaseUrl, 'https://x.supabase.co'); + expect(sync.supabaseAnonKey, 'anon-key-123'); + expect(sync.syncCode, '123456789012'); + }); +} From 97e9c29da9f26706cbfb825d5588a602eb0893d9 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:04:59 +0700 Subject: [PATCH 20/96] feat(mobile): P2P transfer-code parser --- app/lib/mobile/sync/transfer_code.dart | 26 +++++++++++++++++++++++++ app/test/mobile/transfer_code_test.dart | 20 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/lib/mobile/sync/transfer_code.dart create mode 100644 app/test/mobile/transfer_code_test.dart diff --git a/app/lib/mobile/sync/transfer_code.dart b/app/lib/mobile/sync/transfer_code.dart new file mode 100644 index 00000000..72f109a9 --- /dev/null +++ b/app/lib/mobile/sync/transfer_code.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +/// Parses a P2P transfer code (the JSON encoded in the desktop's QR / export +/// string: `{"u": "", "k": ""}`). Throws [FormatException] +/// on malformed input so the caller can show a clear "invalid code" error. +({String url, List key}) parseTransferCode(String raw) { + final Object? decoded; + try { + decoded = jsonDecode(raw.trim()); + } catch (_) { + throw const FormatException('Not a valid transfer code'); + } + if (decoded is! Map) throw const FormatException('Not a transfer code'); + final url = decoded['u']; + final k = decoded['k']; + if (url is! String || url.isEmpty || k is! String || k.isEmpty) { + throw const FormatException('Transfer code is missing fields'); + } + final List key; + try { + key = base64.decode(k); + } catch (_) { + throw const FormatException('Transfer code key is invalid'); + } + return (url: url, key: key); +} diff --git a/app/test/mobile/transfer_code_test.dart b/app/test/mobile/transfer_code_test.dart new file mode 100644 index 00000000..90e8f01b --- /dev/null +++ b/app/test/mobile/transfer_code_test.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/sync/transfer_code.dart'; + +void main() { + test('parses a valid transfer code', () { + final key = List.generate(32, (i) => i); + final raw = jsonEncode( + {'u': 'http://192.168.1.5:8080/sync', 'k': base64.encode(key)}); + final parsed = parseTransferCode(raw); + expect(parsed.url, 'http://192.168.1.5:8080/sync'); + expect(parsed.key, key); + }); + + test('throws on missing or malformed fields', () { + expect(() => parseTransferCode('{"u":"http://x"}'), throwsFormatException); + expect(() => parseTransferCode('not json'), throwsFormatException); + expect(() => parseTransferCode('{"u":"","k":"AAAA"}'), throwsFormatException); + }); +} From 5a473526f28d58c173306cc6827b14cf3d71296d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:06:05 +0700 Subject: [PATCH 21/96] build(mobile): add mobile_scanner + camera permission --- app/android/app/src/main/AndroidManifest.xml | 2 ++ app/pubspec.lock | 8 ++++++++ app/pubspec.yaml | 1 + 3 files changed, 11 insertions(+) diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 569c7681..36c95101 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + Date: Thu, 25 Jun 2026 10:07:10 +0700 Subject: [PATCH 22/96] feat(mobile): P2P QR camera import --- .../mobile/screens/mobile_qr_scan_screen.dart | 70 +++++++++++++++++++ .../screens/mobile_settings_screen.dart | 31 +++++++- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 app/lib/mobile/screens/mobile_qr_scan_screen.dart diff --git a/app/lib/mobile/screens/mobile_qr_scan_screen.dart b/app/lib/mobile/screens/mobile_qr_scan_screen.dart new file mode 100644 index 00000000..d6c20e8c --- /dev/null +++ b/app/lib/mobile/screens/mobile_qr_scan_screen.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/host_provider.dart'; +import '../../services/p2p_sync_encryption.dart'; +import '../../services/p2p_sync_service.dart'; +import '../../services/sync_service.dart'; +import '../../theme/app_theme.dart'; +import '../sync/transfer_code.dart'; + +/// Full-screen camera QR scanner for P2P host import. On the first valid code +/// it fetches + decrypts the payload from the exporting device and replaces +/// the local host list, then pops a result message (shown as a SnackBar by the +/// caller). +class MobileQrScanScreen extends StatefulWidget { + const MobileQrScanScreen({super.key}); + + @override + State createState() => _MobileQrScanScreenState(); +} + +class _MobileQrScanScreenState extends State { + final _p2p = P2PSyncService(); + bool _handled = false; + + @override + void dispose() { + _p2p.stop(); + super.dispose(); + } + + Future _onDetect(BarcodeCapture capture) async { + if (_handled) return; + final raw = capture.barcodes.isEmpty ? null : capture.barcodes.first.rawValue; + if (raw == null) return; + _handled = true; + try { + final code = parseTransferCode(raw); + final encrypted = await _p2p.fetchPayload(code.url); + final decrypted = await P2PSyncEncryption.decrypt(encrypted, code.key); + final payload = SyncService.parsePayload(decrypted); + if (payload.hosts.isEmpty) { + throw const FormatException('No hosts in transfer'); + } + if (!mounted) return; + await context + .read() + .replaceAll(payload.hosts, payload.passwords); + if (mounted) { + Navigator.of(context).pop('Imported ${payload.hosts.length} hosts'); + } + } catch (e) { + if (mounted) { + Navigator.of(context).pop( + 'Import failed: ${e.toString().replaceFirst('FormatException: ', '')}'); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: AppColors.bg, title: const Text('Scan transfer QR')), + body: MobileScanner(onDetect: _onDetect), + ); + } +} diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index 6b926065..f80b7ecd 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -5,9 +5,10 @@ import '../../providers/host_provider.dart'; import '../../providers/sync_provider.dart'; import '../../services/sync_service.dart'; import '../../theme/app_theme.dart'; +import 'mobile_qr_scan_screen.dart'; -/// Settings tab. M3.1 ships the Cloud Sync section (pull from desktop). The -/// P2P QR scan entry is added in M3.2; appearance + app-lock in M5. +/// Settings tab. M3 ships the Sync section (cloud pull + P2P QR scan). +/// Appearance + app-lock arrive in M5. class MobileSettingsScreen extends StatefulWidget { const MobileSettingsScreen({super.key}); @@ -81,12 +82,38 @@ class _MobileSettingsScreenState extends State { Text(sync.error!, style: const TextStyle(color: AppColors.red, fontSize: 12)), ], + const SizedBox(height: 24), + const Text('P2P transfer', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text( + 'Scan the QR code shown on your desktop (Settings → Sync → Show QR).', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scan QR code'), + onPressed: _scan, + ), ], ), ), ); } + Future _scan() async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileQrScanScreen()), + ); + if (result != null && mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(result))); + } + } + Future _save() async { final sync = context.read(); await sync.setSupabaseConfig(_url.text, _anon.text); From 163a3ef10d23ba6d6793ed36c1b8b2140df6b862 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:21:21 +0700 Subject: [PATCH 23/96] docs(plan): Android mobile app M4 implementation plan --- .../plans/2026-06-25-android-mobile-app-m4.md | 601 ++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-android-mobile-app-m4.md diff --git a/docs/superpowers/plans/2026-06-25-android-mobile-app-m4.md b/docs/superpowers/plans/2026-06-25-android-mobile-app-m4.md new file mode 100644 index 00000000..48d32ab1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-android-mobile-app-m4.md @@ -0,0 +1,601 @@ +# Android Mobile App — Milestone 4 (SFTP + Snippets) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On Android, insert reusable snippets into the live terminal, and browse / download / upload files over SFTP on a connected host (single-panel). + +**Architecture:** Reuse the existing `yourssh_snippets` `SnippetProvider` (standalone — not the plugin wrapper) for snippets, inserting `snippet.command` into the active session's xterm `Terminal.textInput`. SFTP mirrors the desktop `sftp_screen.dart`: `SshService.openSftp(host)` → `sftp.listdir(path)` over the active SSH session's already-connected client, with `file_picker` for upload (pick) and download (pick destination dir). No new dependencies. + +**Tech Stack:** Flutter, `provider`, `yourssh_snippets`, `dartssh2` SFTP, `file_picker`, `path`. + +## Global Constraints + +- Dart package `yourssh`; dark-only (`AppColors`); reuse existing classes; don't touch desktop bootstrap/widgets. +- SFTP operates on the **active SSH session's host** (its SSH client is already open); if there is no connected SSH session, the SFTP tab shows a "connect a host first" state. +- Snippets insert text only (no auto-Enter) so the user can review before running. +- Desktop builds + full `flutter test` + `flutter analyze` MUST stay green. +- No Claude attribution. Commit after each task. Branch `feat/android-mobile-app`. + +## Key existing APIs (verified) + +- `SnippetProvider()` (from `package:yourssh_snippets/yourssh_snippets.dart`) auto-loads (defaults if empty); `snippets` (List); `filterSnippets(List, String query)`. `Snippet` has `id`, `label`, `command`, `description`, `tag`. +- `SessionProvider.activeSshSession` → `SshSession?`; `SshSession.terminal` (xterm `Terminal`), `.host`. +- `SshService.openSftp(Host)` → `Future` (reuses the open client). `SftpClient.listdir(path)` → `List` (`filename`, `attr.isDirectory`, `attr.size`), `.open(path)` → `SftpFile` (`readBytes()`, `close()`). +- `SftpTransferService(SshService).uploadFile(Host, String localPath, String remotePath)`. +- `file_picker`: `FilePicker.platform.pickFiles()` (upload source, `.files.single.path`), `FilePicker.platform.getDirectoryPath()` (download destination — Android SAF tree). +- `Terminal.textInput(String)`. + +--- + +## Phase M4.1 — Snippets quick-insert + +### Task 1: Add SnippetProvider + SftpTransferService to the bootstrap + +**Files:** +- Modify: `app/lib/mobile/mobile_bootstrap.dart` +- Test: `app/test/mobile/mobile_bootstrap_test.dart` (extend) + +**Interfaces:** +- Produces: `MobileBootstrap.snippets` (SnippetProvider) and `MobileBootstrap.transfer` (SftpTransferService); SnippetProvider added to `providers`, SftpTransferService as a plain Provider. + +- [ ] **Step 1: Extend the bootstrap test** + +Add inside `main()` of `app/test/mobile/mobile_bootstrap_test.dart`: +```dart + test('exposes snippets + transfer service', () { + final b = MobileBootstrap(); + expect(b.snippets, isNotNull); + expect(b.transfer, isNotNull); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_bootstrap_test.dart` +Expected: FAIL — `snippets`/`transfer` undefined. + +- [ ] **Step 3: Add to the bootstrap** + +In `app/lib/mobile/mobile_bootstrap.dart` add imports: +```dart +import 'package:yourssh_snippets/yourssh_snippets.dart'; +import '../services/sftp_transfer_service.dart'; +``` +Add fields: +```dart + late final SnippetProvider snippets; + late final SftpTransferService transfer; +``` +In the constructor (after `syncService = ...`): +```dart + snippets = SnippetProvider(); + transfer = SftpTransferService(ssh); +``` +Add to `providers`: +```dart + ChangeNotifierProvider.value(value: snippets), + Provider.value(value: transfer), +``` + +> Verify at execution: `SftpTransferService`'s constructor argument (it takes the `SshService`). If its ctor differs, match it. + +- [ ] **Step 4: Run test + analyze** + +Run: `cd app && flutter test test/mobile/mobile_bootstrap_test.dart && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/mobile_bootstrap.dart app/test/mobile/mobile_bootstrap_test.dart +git commit -m "feat(mobile): add snippets + transfer service to bootstrap" +``` + +--- + +### Task 2: Snippets sheet + insert into the terminal + +**Files:** +- Create: `app/lib/mobile/terminal/mobile_snippets_sheet.dart` +- Modify: `app/lib/mobile/screens/mobile_sessions_screen.dart` (snippets button → open sheet → insert into active terminal) +- Test: `app/test/mobile/mobile_snippets_sheet_test.dart` + +**Interfaces:** +- Produces: `class MobileSnippetsSheet extends StatefulWidget` taking `void Function(String command) onInsert`; searchable list of `SnippetProvider.snippets` via `filterSnippets`; tapping a row calls `onInsert(snippet.command)` then pops. The Sessions screen opens it via `showModalBottomSheet` and inserts into `activeSession.terminal`. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_snippets_sheet_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; +import 'package:yourssh/mobile/terminal/mobile_snippets_sheet.dart'; + +Future _pump(WidgetTester tester, {required void Function(String) onInsert}) async { + final sp = SnippetProvider(); + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: sp, + child: Scaffold(body: MobileSnippetsSheet(onInsert: onInsert)), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('tapping a snippet inserts its command', (tester) async { + String? inserted; + await _pump(tester, onInsert: (c) => inserted = c); + + // SnippetProvider seeds defaults; tap the first snippet row. + final firstTile = find.byType(ListTile).first; + expect(firstTile, findsOneWidget); + await tester.tap(firstTile); + await tester.pumpAndSettle(); + + expect(inserted, isNotNull); + expect(inserted!.isNotEmpty, isTrue); + }); + + testWidgets('search filters the list', (tester) async { + await _pump(tester, onInsert: (_) {}); + final before = tester.widgetList(find.byType(ListTile)).length; + await tester.enterText(find.byType(TextField), 'zzz-no-match-xyz'); + await tester.pumpAndSettle(); + final after = tester.widgetList(find.byType(ListTile)).length; + expect(after, lessThanOrEqualTo(before)); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_snippets_sheet_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the sheet** + +Create `app/lib/mobile/terminal/mobile_snippets_sheet.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; + +import '../../theme/app_theme.dart'; + +/// Bottom-sheet list of snippets; tapping one inserts its command into the +/// active terminal (via [onInsert]) and closes the sheet. +class MobileSnippetsSheet extends StatefulWidget { + final void Function(String command) onInsert; + const MobileSnippetsSheet({super.key, required this.onInsert}); + + @override + State createState() => _MobileSnippetsSheetState(); +} + +class _MobileSnippetsSheetState extends State { + String _query = ''; + + @override + Widget build(BuildContext context) { + final all = context.watch().snippets; + final shown = filterSnippets(all, _query); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Search snippets', + prefixIcon: Icon(Icons.search, color: AppColors.textSecondary), + ), + ), + const SizedBox(height: 8), + Flexible( + child: shown.isEmpty + ? const Padding( + padding: EdgeInsets.all(24), + child: Text('No snippets', + style: TextStyle(color: AppColors.textSecondary)), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: shown.length, + itemBuilder: (_, i) { + final s = shown[i]; + return ListTile( + title: Text(s.label, + style: const TextStyle(color: AppColors.textPrimary)), + subtitle: Text(s.command, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontFamily: 'monospace')), + onTap: () { + widget.onInsert(s.command); + Navigator.of(context).pop(); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/mobile_snippets_sheet_test.dart` +Expected: PASS. + +- [ ] **Step 5: Wire a snippets button into the Sessions screen** + +In `mobile_sessions_screen.dart`: +- Import `'../terminal/mobile_snippets_sheet.dart';`. +- Pass an `onSnippets` callback into `_SessionStrip` and render a trailing `IconButton(icon: Icon(Icons.code))` after the chips. +- In the state, implement `_openSnippets(SshSession active)`: +```dart + void _openSnippets(SshSession active) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + isScrollControlled: true, + builder: (_) => MobileSnippetsSheet( + onInsert: (cmd) => active.terminal.textInput(cmd), + ), + ); + } +``` +- Wire `_SessionStrip(..., onSnippets: () => _openSnippets(active))`. Add `final VoidCallback onSnippets;` to `_SessionStrip` and the trailing button. + +- [ ] **Step 6: Run mobile tests + analyze** + +Run: `cd app && flutter test test/mobile/ && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 7: Commit** + +```bash +git add app/lib/mobile/terminal/mobile_snippets_sheet.dart app/lib/mobile/screens/mobile_sessions_screen.dart app/test/mobile/mobile_snippets_sheet_test.dart +git commit -m "feat(mobile): snippets sheet inserts into terminal" +``` + +--- + +## Phase M4.2 — SFTP single-panel + +### Task 3: SFTP browser (list + navigate) + +**Files:** +- Create: `app/lib/mobile/screens/mobile_sftp_screen.dart` +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` (index 2 → SFTP screen) +- Test: `app/test/mobile/mobile_sftp_screen_test.dart` + +**Interfaces:** +- Consumes: `SessionProvider.activeSshSession`, `SshService.openSftp`, `SftpClient.listdir`. +- Produces: `class MobileSftpScreen extends StatefulWidget` — no connected SSH session → "Connect a host to browse files"; else list the remote dir (dirs first), breadcrumb path, tap dir to descend, up button to ascend. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/mobile_sftp_screen_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/screens/mobile_sftp_screen.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows connect prompt with no SSH session', (tester) async { + final storage = StorageService(); + final ssh = SshService(storage); + final sp = SessionProvider(ssh, TabMetadataService()); + await tester.pumpWidget(MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sp), + Provider.value(value: ssh), + ], + child: const MobileSftpScreen(), + ), + )); + expect(find.textContaining('Connect a host'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_sftp_screen_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the SFTP screen** + +Create `app/lib/mobile/screens/mobile_sftp_screen.dart`: +```dart +import 'dart:io' as io; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../providers/session_provider.dart'; +import '../../services/sftp_transfer_service.dart'; +import '../../services/ssh_service.dart'; +import '../../theme/app_theme.dart'; + +/// Single-panel SFTP browser over the active SSH session's host. Lists, opens +/// directories, downloads files to a picked folder, and uploads a picked file. +class MobileSftpScreen extends StatefulWidget { + const MobileSftpScreen({super.key}); + + @override + State createState() => _MobileSftpScreenState(); +} + +class _MobileSftpScreenState extends State { + String _path = '.'; + List _entries = []; + bool _loading = false; + String? _error; + String? _loadedHostId; + + Future _load(Host host, String path) async { + setState(() { + _loading = true; + _error = null; + }); + try { + final sftp = await context.read().openSftp(host); + final raw = await sftp.listdir(path); + sftp.close(); + raw.sort((a, b) { + final ad = a.attr.isDirectory, bd = b.attr.isDirectory; + if (ad != bd) return ad ? -1 : 1; + return a.filename.compareTo(b.filename); + }); + if (!mounted) return; + setState(() { + _path = path; + _entries = raw.where((e) => e.filename != '.' && e.filename != '..').toList(); + _loading = false; + }); + } catch (e) { + if (mounted) setState(() { _loading = false; _error = '$e'; }); + } + } + + void _maybeLoad(Host host) { + if (_loadedHostId == host.id) return; + _loadedHostId = host.id; + _path = '.'; + WidgetsBinding.instance.addPostFrameCallback((_) => _load(host, '.')); + } + + @override + Widget build(BuildContext context) { + final active = context.watch().activeSshSession; + if (active == null) { + return const Scaffold( + backgroundColor: AppColors.bg, + body: Center( + child: Text('Connect a host to browse files', + style: TextStyle(color: AppColors.textSecondary)), + ), + ); + } + final host = active.host; + _maybeLoad(host); + + return Scaffold( + backgroundColor: AppColors.bg, + floatingActionButton: FloatingActionButton( + onPressed: () => _upload(host), + backgroundColor: AppColors.accent, + child: const Icon(Icons.upload_file, color: Colors.black), + ), + body: SafeArea( + child: Column( + children: [ + _bar(host), + if (_error != null) + Padding( + padding: const EdgeInsets.all(12), + child: Text(_error!, + style: const TextStyle(color: AppColors.red, fontSize: 12)), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + itemCount: _entries.length, + itemBuilder: (_, i) => _row(host, _entries[i]), + ), + ), + ], + ), + ), + ); + } + + Widget _bar(Host host) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + color: AppColors.card, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_upward, color: AppColors.textSecondary), + onPressed: _path == '.' || _path == '/' + ? null + : () => _load(host, p.posix.dirname(_path)), + ), + Expanded( + child: Text(_path, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13)), + ), + IconButton( + icon: const Icon(Icons.refresh, color: AppColors.textSecondary), + onPressed: () => _load(host, _path), + ), + ], + ), + ); + } + + Widget _row(Host host, SftpName e) { + final isDir = e.attr.isDirectory; + return ListTile( + leading: Icon(isDir ? Icons.folder : Icons.insert_drive_file, + color: isDir ? AppColors.accent : AppColors.textSecondary), + title: Text(e.filename, style: const TextStyle(color: AppColors.textPrimary)), + subtitle: isDir + ? null + : Text('${e.attr.size ?? 0} bytes', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11)), + trailing: isDir + ? null + : IconButton( + icon: const Icon(Icons.download, color: AppColors.textSecondary), + onPressed: () => _download(host, e), + ), + onTap: isDir ? () => _load(host, _join(e.filename)) : null, + ); + } + + String _join(String name) => + _path == '.' ? name : p.posix.join(_path, name); + + Future _download(Host host, SftpName e) async { + final dir = await FilePicker.platform.getDirectoryPath(); + if (dir == null) return; + try { + final sftp = await context.read().openSftp(host); + final file = await sftp.open(_join(e.filename)); + final bytes = await file.readBytes(); + await file.close(); + sftp.close(); + await io.File(p.join(dir, e.filename)).writeAsBytes(bytes); + _snack('Downloaded ${e.filename}'); + } catch (err) { + _snack('Download failed: $err'); + } + } + + Future _upload(Host host) async { + final picked = await FilePicker.platform.pickFiles(); + final localPath = picked?.files.single.path; + if (localPath == null) return; + final name = p.basename(localPath); + try { + await context + .read() + .uploadFile(host, localPath, _join(name)); + _snack('Uploaded $name'); + _load(host, _path); + } catch (err) { + _snack('Upload failed: $err'); + } + } + + void _snack(String msg) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + } +} +``` + +> Verify at execution: `package:dartssh2/dartssh2.dart` exports `SftpName`/`SftpFile`. `path` package is a dependency (`p.posix`). `FilePicker.platform.getDirectoryPath()` exists in file_picker 8. + +- [ ] **Step 4: Wire into the shell (index 2)** + +In `mobile_home_shell.dart`, import `mobile_sftp_screen.dart` and add `case 2: return const MobileSftpScreen();` (so `default` now only covers nothing — keep a `default` returning an empty `SizedBox.shrink()` or the placeholder for safety). + +- [ ] **Step 5: Run test to verify it passes + analyze** + +Run: `cd app && flutter test test/mobile/mobile_sftp_screen_test.dart && flutter analyze lib/mobile` +Expected: PASS; `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/mobile/screens/mobile_sftp_screen.dart app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/mobile_sftp_screen_test.dart +git commit -m "feat(mobile): single-panel SFTP browser + download/upload" +``` + +--- + +### Task 4: Build + regression + +**Files:** none (verification) + +- [ ] **Step 1: Full test suite** + +Run: `cd app && flutter test` +Expected: all tests pass. + +- [ ] **Step 2: Analyze whole repo** + +Run: `cd app && flutter analyze` +Expected: no NEW issues (the 2 pre-existing `integration_test` probe warnings may remain). + +- [ ] **Step 3: Build the APK** + +Run: `cd app && flutter build apk --debug` +Expected: `✓ Built ... app-debug.apk`. + +- [ ] **Step 4: Restore any regenerated desktop registrants** + +```bash +git checkout HEAD -- app/macos/Flutter/GeneratedPluginRegistrant.swift app/windows/flutter/generated_plugin_registrant.cc app/windows/flutter/generated_plugins.cmake 2>/dev/null || true +``` + +- [ ] **Step 5: On-device (user, when available)** + +Manual: connect a host → Sessions → snippets button → tap a snippet (inserts into terminal) → SFTP tab → browse, download a file (pick folder), upload a file (pick file). + +--- + +## Self-Review + +**Spec coverage (M4):** +- "SFTP browser — single-panel mobile layout (remote only); browse, upload, download" → Tasks 3 (+ download/upload in the same screen). ✓ +- "Snippets … insert into the active session" → Tasks 1, 2. ✓ +- "command history" → **deferred** (documented below): mobile types directly into xterm with no input bar to capture commands; revisit when/if an optional input bar lands. + +**Placeholder scan:** No vague steps; code complete. The `> Verify at execution` notes (SftpTransferService ctor arg, dartssh2 exports, file_picker API) name concrete checks. + +**Type consistency:** `MobileBootstrap.snippets`/`transfer` (Task 1) consumed by the snippets sheet (Task 2, via provider) and the SFTP screen (Task 3, `context.read()`). `MobileSnippetsSheet(onInsert:)` matches the Sessions-screen wiring. `_join`/`_load(host, path)` consistent within the SFTP screen. + +## Deferred + +- **Command history** on mobile (no input bar to capture commands; needs an optional input-bar capture mechanism first). +- SFTP rename/delete/mkdir/permissions, multi-select, transfer queue UI → later (download/upload cover the core). +- TOFU mismatch dialog, appearance, app-lock, release signing → M5. From ebb8d25069960e8fe77fbe1590d107f586325a99 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:22:23 +0700 Subject: [PATCH 24/96] feat(mobile): add snippets + transfer service to bootstrap --- app/lib/mobile/mobile_bootstrap.dart | 8 ++++++++ app/test/mobile/mobile_bootstrap_test.dart | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/app/lib/mobile/mobile_bootstrap.dart b/app/lib/mobile/mobile_bootstrap.dart index 82224015..921707aa 100644 --- a/app/lib/mobile/mobile_bootstrap.dart +++ b/app/lib/mobile/mobile_bootstrap.dart @@ -1,5 +1,6 @@ import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; import '../providers/host_provider.dart'; import '../providers/key_provider.dart'; @@ -7,6 +8,7 @@ import '../providers/known_hosts_provider.dart'; import '../providers/session_provider.dart'; import '../providers/settings_provider.dart'; import '../providers/sync_provider.dart'; +import '../services/sftp_transfer_service.dart'; import '../services/ssh_service.dart'; import '../services/storage_service.dart'; import '../services/sync_service.dart'; @@ -26,6 +28,8 @@ class MobileBootstrap { late final SessionProvider sessions; late final SyncProvider sync; late final SyncService syncService; + late final SnippetProvider snippets; + late final SftpTransferService transfer; MobileBootstrap() { storage = StorageService(); @@ -37,6 +41,8 @@ class MobileBootstrap { sessions = SessionProvider(ssh, TabMetadataService()); sync = SyncProvider(storage: storage); syncService = SyncService(sync); + snippets = SnippetProvider(); + transfer = SftpTransferService(ssh); _wire(); } @@ -67,5 +73,7 @@ class MobileBootstrap { ChangeNotifierProvider.value(value: sessions), ChangeNotifierProvider.value(value: sync), Provider.value(value: syncService), + ChangeNotifierProvider.value(value: snippets), + Provider.value(value: transfer), ]; } diff --git a/app/test/mobile/mobile_bootstrap_test.dart b/app/test/mobile/mobile_bootstrap_test.dart index 5e926e22..1511674d 100644 --- a/app/test/mobile/mobile_bootstrap_test.dart +++ b/app/test/mobile/mobile_bootstrap_test.dart @@ -25,4 +25,10 @@ void main() { expect(b.sync, isNotNull); expect(b.syncService, isNotNull); }); + + test('exposes snippets + transfer service', () { + final b = MobileBootstrap(); + expect(b.snippets, isNotNull); + expect(b.transfer, isNotNull); + }); } From acf8c683032dcb016f24e999ec1d92ec68fb20d3 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:23:45 +0700 Subject: [PATCH 25/96] feat(mobile): snippets sheet inserts into terminal --- .../screens/mobile_sessions_screen.dart | 23 +++++- .../terminal/mobile_snippets_sheet.dart | 76 +++++++++++++++++++ .../mobile/mobile_snippets_sheet_test.dart | 44 +++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 app/lib/mobile/terminal/mobile_snippets_sheet.dart create mode 100644 app/test/mobile/mobile_snippets_sheet_test.dart diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart index eaab682f..fc6cabca 100644 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -7,6 +7,7 @@ import '../../providers/session_provider.dart'; import '../../theme/app_theme.dart'; import '../terminal/accessory_bar_controller.dart'; import '../terminal/accessory_key_bar.dart'; +import '../terminal/mobile_snippets_sheet.dart'; /// Sessions tab: a session strip + the active SSH session's terminal (when /// connected, with the accessory key bar docked below and pinch-to-zoom font) @@ -29,6 +30,17 @@ class _MobileSessionsScreenState extends State { super.dispose(); } + void _openSnippets(SshSession active) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + isScrollControlled: true, + builder: (_) => MobileSnippetsSheet( + onInsert: (cmd) => active.terminal.textInput(cmd), + ), + ); + } + void _onScaleStart(ScaleStartDetails _) => _scaleBase = _fontSize; void _onScaleUpdate(ScaleUpdateDetails d) { @@ -60,7 +72,16 @@ class _MobileSessionsScreenState extends State { body: SafeArea( child: Column( children: [ - _SessionStrip(sessions: ssh, activeId: active.id), + Row( + children: [ + Expanded(child: _SessionStrip(sessions: ssh, activeId: active.id)), + IconButton( + icon: const Icon(Icons.code, color: AppColors.textSecondary), + tooltip: 'Snippets', + onPressed: () => _openSnippets(active), + ), + ], + ), const Divider(height: 1, color: AppColors.border), Expanded( child: _SessionBody( diff --git a/app/lib/mobile/terminal/mobile_snippets_sheet.dart b/app/lib/mobile/terminal/mobile_snippets_sheet.dart new file mode 100644 index 00000000..b3b43b38 --- /dev/null +++ b/app/lib/mobile/terminal/mobile_snippets_sheet.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; + +import '../../theme/app_theme.dart'; + +/// Bottom-sheet list of snippets; tapping one inserts its command into the +/// active terminal (via [onInsert]) and closes the sheet. +class MobileSnippetsSheet extends StatefulWidget { + final void Function(String command) onInsert; + const MobileSnippetsSheet({super.key, required this.onInsert}); + + @override + State createState() => _MobileSnippetsSheetState(); +} + +class _MobileSnippetsSheetState extends State { + String _query = ''; + + @override + Widget build(BuildContext context) { + final all = context.watch().snippets; + final shown = filterSnippets(all, _query); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Search snippets', + prefixIcon: Icon(Icons.search, color: AppColors.textSecondary), + ), + ), + const SizedBox(height: 8), + Flexible( + child: shown.isEmpty + ? const Padding( + padding: EdgeInsets.all(24), + child: Text('No snippets', + style: TextStyle(color: AppColors.textSecondary)), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: shown.length, + itemBuilder: (_, i) { + final s = shown[i]; + return ListTile( + title: Text(s.label, + style: + const TextStyle(color: AppColors.textPrimary)), + subtitle: Text(s.command, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontFamily: 'monospace')), + onTap: () { + widget.onInsert(s.command); + Navigator.of(context).pop(); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/test/mobile/mobile_snippets_sheet_test.dart b/app/test/mobile/mobile_snippets_sheet_test.dart new file mode 100644 index 00000000..1b2dbd1c --- /dev/null +++ b/app/test/mobile/mobile_snippets_sheet_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; +import 'package:yourssh/mobile/terminal/mobile_snippets_sheet.dart'; + +Future _pump(WidgetTester tester, + {required void Function(String) onInsert}) async { + final sp = SnippetProvider(); + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: sp, + child: Scaffold(body: MobileSnippetsSheet(onInsert: onInsert)), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('tapping a snippet inserts its command', (tester) async { + String? inserted; + await _pump(tester, onInsert: (c) => inserted = c); + + final firstTile = find.byType(ListTile).first; + expect(firstTile, findsOneWidget); + await tester.tap(firstTile); + await tester.pumpAndSettle(); + + expect(inserted, isNotNull); + expect(inserted!.isNotEmpty, isTrue); + }); + + testWidgets('search filters the list', (tester) async { + await _pump(tester, onInsert: (_) {}); + final before = tester.widgetList(find.byType(ListTile)).length; + await tester.enterText(find.byType(TextField), 'zzz-no-match-xyz'); + await tester.pumpAndSettle(); + final after = tester.widgetList(find.byType(ListTile)).length; + expect(after, lessThanOrEqualTo(before)); + }); +} From 7adc3a88c36470c0fbf715468972f0ee0cee503d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:26:05 +0700 Subject: [PATCH 26/96] feat(mobile): single-panel SFTP browser + download/upload --- app/lib/mobile/screens/mobile_home_shell.dart | 9 +- .../mobile/screens/mobile_sftp_screen.dart | 205 ++++++++++++++++++ app/test/mobile/mobile_home_shell_test.dart | 4 +- app/test/mobile/mobile_sftp_screen_test.dart | 29 +++ 4 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 app/lib/mobile/screens/mobile_sftp_screen.dart create mode 100644 app/test/mobile/mobile_sftp_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 73760038..2feede9f 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -5,6 +5,7 @@ import 'mobile_add_host_screen.dart'; import 'mobile_hosts_screen.dart'; import 'mobile_sessions_screen.dart'; import 'mobile_settings_screen.dart'; +import 'mobile_sftp_screen.dart'; /// Bottom-navigation shell for the Android app. Hosts (0) and Sessions (1) are /// live; SFTP (2) and Settings (3) are placeholders filled in M4/M5. @@ -41,14 +42,12 @@ class _MobileHomeShellState extends State { ); case 1: return const MobileSessionsScreen(); + case 2: + return const MobileSftpScreen(); case 3: return const MobileSettingsScreen(); default: - return Center( - child: Text('${_labels[_index]} — coming soon', - style: - const TextStyle(color: AppColors.textSecondary, fontSize: 15)), - ); + return const SizedBox.shrink(); } } diff --git a/app/lib/mobile/screens/mobile_sftp_screen.dart b/app/lib/mobile/screens/mobile_sftp_screen.dart new file mode 100644 index 00000000..5a64a136 --- /dev/null +++ b/app/lib/mobile/screens/mobile_sftp_screen.dart @@ -0,0 +1,205 @@ +import 'dart:io' as io; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../providers/session_provider.dart'; +import '../../services/sftp_transfer_service.dart'; +import '../../services/ssh_service.dart'; +import '../../theme/app_theme.dart'; + +/// Single-panel SFTP browser over the active SSH session's host. Lists, opens +/// directories, downloads files to a picked folder, and uploads a picked file. +class MobileSftpScreen extends StatefulWidget { + const MobileSftpScreen({super.key}); + + @override + State createState() => _MobileSftpScreenState(); +} + +class _MobileSftpScreenState extends State { + String _path = '.'; + List _entries = []; + bool _loading = false; + String? _error; + String? _loadedHostId; + + Future _load(Host host, String path) async { + setState(() { + _loading = true; + _error = null; + }); + try { + final sftp = await context.read().openSftp(host); + final raw = await sftp.listdir(path); + sftp.close(); + raw.sort((a, b) { + final ad = a.attr.isDirectory, bd = b.attr.isDirectory; + if (ad != bd) return ad ? -1 : 1; + return a.filename.compareTo(b.filename); + }); + if (!mounted) return; + setState(() { + _path = path; + _entries = raw + .where((e) => e.filename != '.' && e.filename != '..') + .toList(); + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = '$e'; + }); + } + } + } + + void _maybeLoad(Host host) { + if (_loadedHostId == host.id) return; + _loadedHostId = host.id; + _path = '.'; + WidgetsBinding.instance.addPostFrameCallback((_) => _load(host, '.')); + } + + @override + Widget build(BuildContext context) { + final active = context.watch().activeSshSession; + if (active == null) { + return const Scaffold( + backgroundColor: AppColors.bg, + body: Center( + child: Text('Connect a host to browse files', + style: TextStyle(color: AppColors.textSecondary)), + ), + ); + } + final host = active.host; + _maybeLoad(host); + + return Scaffold( + backgroundColor: AppColors.bg, + floatingActionButton: FloatingActionButton( + onPressed: () => _upload(host), + backgroundColor: AppColors.accent, + child: const Icon(Icons.upload_file, color: Colors.black), + ), + body: SafeArea( + child: Column( + children: [ + _bar(host), + if (_error != null) + Padding( + padding: const EdgeInsets.all(12), + child: Text(_error!, + style: const TextStyle(color: AppColors.red, fontSize: 12)), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + itemCount: _entries.length, + itemBuilder: (_, i) => _row(host, _entries[i]), + ), + ), + ], + ), + ), + ); + } + + Widget _bar(Host host) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + color: AppColors.card, + child: Row( + children: [ + IconButton( + icon: + const Icon(Icons.arrow_upward, color: AppColors.textSecondary), + onPressed: _path == '.' || _path == '/' + ? null + : () => _load(host, p.posix.dirname(_path)), + ), + Expanded( + child: Text(_path, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(color: AppColors.textPrimary, fontSize: 13)), + ), + IconButton( + icon: const Icon(Icons.refresh, color: AppColors.textSecondary), + onPressed: () => _load(host, _path), + ), + ], + ), + ); + } + + Widget _row(Host host, SftpName e) { + final isDir = e.attr.isDirectory; + return ListTile( + leading: Icon(isDir ? Icons.folder : Icons.insert_drive_file, + color: isDir ? AppColors.accent : AppColors.textSecondary), + title: + Text(e.filename, style: const TextStyle(color: AppColors.textPrimary)), + subtitle: isDir + ? null + : Text('${e.attr.size ?? 0} bytes', + style: + const TextStyle(color: AppColors.textSecondary, fontSize: 11)), + trailing: isDir + ? null + : IconButton( + icon: const Icon(Icons.download, color: AppColors.textSecondary), + onPressed: () => _download(host, e), + ), + onTap: isDir ? () => _load(host, _join(e.filename)) : null, + ); + } + + String _join(String name) => _path == '.' ? name : p.posix.join(_path, name); + + Future _download(Host host, SftpName e) async { + final ssh = context.read(); + final dir = await FilePicker.platform.getDirectoryPath(); + if (dir == null) return; + try { + final sftp = await ssh.openSftp(host); + final file = await sftp.open(_join(e.filename)); + final bytes = await file.readBytes(); + await file.close(); + sftp.close(); + await io.File(p.join(dir, e.filename)).writeAsBytes(bytes); + _snack('Downloaded ${e.filename}'); + } catch (err) { + _snack('Download failed: $err'); + } + } + + Future _upload(Host host) async { + final transfer = context.read(); + final picked = await FilePicker.platform.pickFiles(); + final localPath = picked?.files.single.path; + if (localPath == null) return; + final name = p.basename(localPath); + try { + await transfer.uploadFile(host, localPath, _join(name)); + _snack('Uploaded $name'); + _load(host, _path); + } catch (err) { + _snack('Upload failed: $err'); + } + } + + void _snack(String msg) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + } +} diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index acf0cd03..bbaf0c67 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -34,10 +34,10 @@ void main() { expect(find.textContaining('No hosts'), findsOneWidget); // Hosts screen body }); - testWidgets('switches to SFTP placeholder', (tester) async { + testWidgets('SFTP tab prompts to connect when no session', (tester) async { await _pump(tester); await tester.tap(find.text('SFTP').last); await tester.pumpAndSettle(); - expect(find.text('SFTP — coming soon'), findsOneWidget); + expect(find.textContaining('Connect a host'), findsOneWidget); }); } diff --git a/app/test/mobile/mobile_sftp_screen_test.dart b/app/test/mobile/mobile_sftp_screen_test.dart new file mode 100644 index 00000000..ed750d26 --- /dev/null +++ b/app/test/mobile/mobile_sftp_screen_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/screens/mobile_sftp_screen.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows connect prompt with no SSH session', (tester) async { + final storage = StorageService(); + final ssh = SshService(storage); + final sp = SessionProvider(ssh, TabMetadataService()); + await tester.pumpWidget(MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sp), + Provider.value(value: ssh), + ], + child: const MobileSftpScreen(), + ), + )); + expect(find.textContaining('Connect a host'), findsOneWidget); + }); +} From c868b5e98553e0b94de51ae305204dab5bba6166 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:58:35 +0700 Subject: [PATCH 27/96] docs(plan): Android mobile app M5 implementation plan --- .../plans/2026-06-25-android-mobile-app-m5.md | 656 ++++++++++++++++++ 1 file changed, 656 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-android-mobile-app-m5.md diff --git a/docs/superpowers/plans/2026-06-25-android-mobile-app-m5.md b/docs/superpowers/plans/2026-06-25-android-mobile-app-m5.md new file mode 100644 index 00000000..2b7f1505 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-android-mobile-app-m5.md @@ -0,0 +1,656 @@ +# Android Mobile App — Milestone 5 (Security + Polish, v1 finale) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish Android v1 — biometric app-lock, the TOFU host-key-mismatch dialog, terminal appearance settings, and release APK signing. + +**Architecture:** App-lock is a `AppLockGate` wrapping the home, with an injectable authenticator (real one = `local_auth`) so the lock state machine is unit-testable without device biometrics. The TOFU mismatch dialog is a small watcher on `KnownHostsProvider.pendingChallenge` (mobile equivalent of the desktop `MainScreen` watcher). Appearance reuses the shared `TerminalAppearanceControls` + `resolveTerminalAppearance` + `terminalThemeByName`. Release signing follows the standard Flutter `key.properties` pattern (gitignored; falls back to debug signing when absent). + +**Tech Stack:** Flutter, `provider`, `local_auth` (new), existing `KnownHostsProvider`/`SettingsProvider`/`xterm`. + +## Global Constraints + +- Dart package `yourssh`; dark-only (`AppColors`); reuse existing classes; don't modify desktop bootstrap/widgets. +- App-lock default **on**; toggle persisted (pref key `app_lock_enabled`); never lock desktop (gate is mobile-only). +- TOFU: first-connect still auto-trusts; this milestone only adds the **mismatch** dialog. +- Desktop builds + full `flutter test` + `flutter analyze` MUST stay green. +- `local_auth` reintroduced **for mobile**; same accepted bundle tradeoff as noted in the spec (single shared pubspec). +- No Claude attribution. Commit after each task. Branch `feat/android-mobile-app`. + +## Key existing APIs (verified) + +- `KnownHostsProvider.pendingChallenge` → `HostKeyChallenge?` (`host`, `port`, `keyType`, `oldFingerprint`, `newFingerprint`; `accept()`, `reject()` from `TofuChallenge`). The provider `notifyListeners()` when it changes. +- `resolveTerminalAppearance({required Host? host, required String globalTheme, required String globalFont, required double globalFontSize})` → `TerminalAppearance{themeName, fontFamily, fontSize}`. +- `terminalThemeByName(String)` → xterm `TerminalTheme`. `SettingsProvider.terminalTheme` / `.terminalFont` / `.fontSize`. +- `TerminalAppearanceControls({required AppearanceControlsLayout layout})` — standalone, reads/writes `SettingsProvider`. +- `TerminalView(terminal, {theme, textStyle})`; `TerminalStyle(fontSize:, fontFamily:)`. + +--- + +## Phase M5.1 — Biometric app-lock + +### Task 1: Add local_auth dependency + +**Files:** Modify `app/pubspec.yaml` + +- [ ] **Step 1: Add the dependency** + +In `app/pubspec.yaml` dependencies add: +```yaml + local_auth: ^2.3.0 +``` +Run: `cd app && flutter pub get` +Expected: resolves. (If `^2.3.0` won't resolve, `flutter pub add local_auth` and accept the compatible version.) + +- [ ] **Step 2: Analyze** + +Run: `cd app && flutter analyze lib/mobile` +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add app/pubspec.yaml app/pubspec.lock +git commit -m "build(mobile): add local_auth for app-lock" +``` + +--- + +### Task 2: App-lock gate + +**Files:** +- Create: `app/lib/mobile/security/app_lock_gate.dart` +- Modify: `app/lib/mobile/mobile_app.dart` (wrap home in `AppLockGate`) +- Test: `app/test/mobile/app_lock_gate_test.dart` + +**Interfaces:** +- Produces: `class AppLockGate extends StatefulWidget` with `child`, optional `Future Function()? authenticator` (default `local_auth`), optional `bool? enabledOverride` (tests), optional `Future Function()? readEnabled`. Locked → shows a lock screen with an Unlock button; unlocked → shows `child`. Re-locks on resume-from-background when enabled. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/app_lock_gate_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/security/app_lock_gate.dart'; + +const _childKey = Key('locked-child'); + +Future _pump(WidgetTester tester, + {required bool enabled, required Future Function() auth}) async { + await tester.pumpWidget(MaterialApp( + home: AppLockGate( + enabledOverride: enabled, + authenticator: auth, + child: const Scaffold(body: SizedBox(key: _childKey)), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('disabled shows child immediately', (tester) async { + await _pump(tester, enabled: false, auth: () async => false); + expect(find.byKey(_childKey), findsOneWidget); + }); + + testWidgets('enabled + success unlocks to child', (tester) async { + await _pump(tester, enabled: true, auth: () async => true); + expect(find.byKey(_childKey), findsOneWidget); + }); + + testWidgets('enabled + failure keeps the lock screen', (tester) async { + await _pump(tester, enabled: true, auth: () async => false); + expect(find.byKey(_childKey), findsNothing); + expect(find.text('Unlock'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/app_lock_gate_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the gate** + +Create `app/lib/mobile/security/app_lock_gate.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../theme/app_theme.dart'; + +const String kAppLockPrefKey = 'app_lock_enabled'; + +/// Wraps the mobile app in a biometric lock. Locked on launch (and on +/// resume-from-background) when enabled; unlocks on a successful auth. The +/// [authenticator] and [enabledOverride] seams keep the state machine testable +/// without real device biometrics. +class AppLockGate extends StatefulWidget { + final Widget child; + final Future Function()? authenticator; + final bool? enabledOverride; + + const AppLockGate({ + super.key, + required this.child, + this.authenticator, + this.enabledOverride, + }); + + @override + State createState() => _AppLockGateState(); +} + +class _AppLockGateState extends State with WidgetsBindingObserver { + bool _enabled = false; + bool _locked = false; + bool _authInFlight = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _init(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + Future _init() async { + final enabled = widget.enabledOverride ?? + (await SharedPreferences.getInstance()).getBool(kAppLockPrefKey) ?? + true; + if (!mounted) return; + setState(() { + _enabled = enabled; + _locked = enabled; + }); + if (enabled) _authenticate(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && _enabled && !_locked) { + setState(() => _locked = true); + _authenticate(); + } + } + + Future _defaultAuth() async { + final auth = LocalAuthentication(); + try { + return await auth.authenticate( + localizedReason: 'Unlock YourSSH', + options: const AuthenticationOptions(stickyAuth: true), + ); + } catch (_) { + return false; + } + } + + Future _authenticate() async { + if (_authInFlight) return; + _authInFlight = true; + final ok = await (widget.authenticator ?? _defaultAuth)(); + _authInFlight = false; + if (mounted && ok) setState(() => _locked = false); + } + + @override + Widget build(BuildContext context) { + if (!_locked) return widget.child; + return Scaffold( + backgroundColor: AppColors.bg, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_outline, size: 56, color: AppColors.textSecondary), + const SizedBox(height: 16), + const Text('YourSSH is locked', + style: TextStyle(color: AppColors.textPrimary, fontSize: 16)), + const SizedBox(height: 16), + FilledButton(onPressed: _authenticate, child: const Text('Unlock')), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/app_lock_gate_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Wrap the home** + +In `app/lib/mobile/mobile_app.dart`, import `security/app_lock_gate.dart` and change `home:` to: +```dart + home: const AppLockGate(child: MobileHomeShell()), +``` + +- [ ] **Step 6: Analyze + commit** + +Run: `cd app && flutter analyze lib/mobile && flutter test test/mobile/app_lock_gate_test.dart` +Expected: clean; PASS. +```bash +git add app/lib/mobile/security/app_lock_gate.dart app/lib/mobile/mobile_app.dart app/test/mobile/app_lock_gate_test.dart +git commit -m "feat(mobile): biometric app-lock gate" +``` + +--- + +### Task 3: App-lock toggle in Settings + +**Files:** Modify `app/lib/mobile/screens/mobile_settings_screen.dart` + +- [ ] **Step 1: Add a Security section with a switch** + +In `mobile_settings_screen.dart`: +- Import `'../security/app_lock_gate.dart';` and `'package:shared_preferences/shared_preferences.dart';`. +- Add state `bool _appLock = true;` and load it in `initState`: +```dart + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((p) { + if (mounted) setState(() => _appLock = p.getBool(kAppLockPrefKey) ?? true); + }); + } +``` +- Add a Security section before the end of the `ListView`: +```dart + const SizedBox(height: 24), + const Text('Security', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _appLock, + onChanged: (v) async { + setState(() => _appLock = v); + final p = await SharedPreferences.getInstance(); + await p.setBool(kAppLockPrefKey, v); + }, + title: const Text('Require biometrics to open', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: const Text('Applies on next launch / resume', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), +``` +(`MobileSettingsScreen` is already a `StatefulWidget`; add the `initState` above its existing members.) + +- [ ] **Step 2: Analyze + mobile tests + commit** + +Run: `cd app && flutter analyze lib/mobile && flutter test test/mobile/` +Expected: clean; PASS. +```bash +git add app/lib/mobile/screens/mobile_settings_screen.dart +git commit -m "feat(mobile): app-lock toggle in settings" +``` + +--- + +## Phase M5.2 — TOFU host-key-mismatch dialog + +### Task 4: TOFU watcher + dialog + +**Files:** +- Create: `app/lib/mobile/security/tofu_watcher.dart` +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` (wrap body in the watcher) +- Test: `app/test/mobile/tofu_watcher_test.dart` + +**Interfaces:** +- Produces: `class TofuWatcher extends StatefulWidget` (`child`) — listens to `KnownHostsProvider`; when `pendingChallenge` becomes non-null it shows a single AlertDialog (old vs new fingerprint, Trust / Reject) and calls `accept()` / `reject()`. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/mobile/tofu_watcher_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:typed_data'; +import 'package:yourssh/mobile/security/tofu_watcher.dart'; +import 'package:yourssh/providers/known_hosts_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows a dialog on host-key mismatch and Trust accepts', + (tester) async { + final kh = KnownHostsProvider(StorageService()); + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: kh, + child: const TofuWatcher(child: Scaffold(body: SizedBox())), + ), + )); + + // Seed a trusted key, then present a different key for the same host. + final fp1 = Uint8List.fromList(List.filled(16, 1)); + final fp2 = Uint8List.fromList(List.filled(16, 2)); + await kh.verifyHostKey('h', 22, 'ssh-ed25519', fp1); // first-use trust + final future = kh.verifyHostKey('h', 22, 'ssh-ed25519', fp2); // mismatch + await tester.pumpAndSettle(); + + expect(find.textContaining('host key'), findsOneWidget); + await tester.tap(find.text('Trust')); + await tester.pumpAndSettle(); + expect(await future, isTrue); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/tofu_watcher_test.dart` +Expected: FAIL — URI doesn't exist. + +- [ ] **Step 3: Write the watcher** + +Create `app/lib/mobile/security/tofu_watcher.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/known_host.dart'; +import '../../providers/known_hosts_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Watches [KnownHostsProvider.pendingChallenge] and shows a single TOFU +/// host-key-mismatch dialog (Trust / Reject) when a key changes. +class TofuWatcher extends StatefulWidget { + final Widget child; + const TofuWatcher({super.key, required this.child}); + + @override + State createState() => _TofuWatcherState(); +} + +class _TofuWatcherState extends State { + bool _dialogOpen = false; + + @override + Widget build(BuildContext context) { + final challenge = context.watch().pendingChallenge; + if (challenge != null && !_dialogOpen) { + _dialogOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _show(challenge)); + } + return widget.child; + } + + Future _show(HostKeyChallenge c) async { + final accept = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + backgroundColor: AppColors.card, + title: const Text('Host key changed'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('The host key for ${c.host}:${c.port} has changed. ' + 'This could be a server reinstall — or a man-in-the-middle attack.', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)), + const SizedBox(height: 12), + Text('Old: ${c.oldFingerprint}', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11, fontFamily: 'monospace')), + Text('New: ${c.newFingerprint}', + style: const TextStyle(color: AppColors.textPrimary, fontSize: 11, fontFamily: 'monospace')), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Reject')), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Trust')), + ], + ), + ); + if (accept == true) { + c.accept(); + } else { + c.reject(); + } + _dialogOpen = false; + } +} +``` + +> Verify at execution: the dialog content includes the substring "host key" (the test matches `textContaining('host key')` — the title "Host key changed" contains it case-sensitively as "Host key"; adjust the test matcher or copy so they agree — e.g. match `textContaining('host key')` against the body sentence "...host key for..." which contains lowercase "host key"). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd app && flutter test test/mobile/tofu_watcher_test.dart` +Expected: PASS. + +- [ ] **Step 5: Wrap the shell body** + +In `mobile_home_shell.dart`, import `'../security/tofu_watcher.dart';` and wrap the `Scaffold`'s `body: _body()` so the watcher is always mounted: +```dart + body: TofuWatcher(child: _body()), +``` + +- [ ] **Step 6: Analyze + mobile tests + commit** + +Run: `cd app && flutter analyze lib/mobile && flutter test test/mobile/` +Expected: clean; PASS. +```bash +git add app/lib/mobile/security/tofu_watcher.dart app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/tofu_watcher_test.dart +git commit -m "feat(mobile): TOFU host-key-mismatch dialog" +``` + +--- + +## Phase M5.3 — Appearance settings + apply + +### Task 5: Appearance section in Settings + apply to terminal + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_settings_screen.dart` (Appearance section) +- Modify: `app/lib/mobile/screens/mobile_sessions_screen.dart` (apply theme/font/size) +- Test: covered by analyze + existing mobile tests (the controls widget is already tested on desktop). + +**Interfaces:** +- Consumes: `TerminalAppearanceControls`, `resolveTerminalAppearance`, `terminalThemeByName`, `SettingsProvider`. +- Produces: an Appearance section in mobile Settings; the mobile terminal renders with the resolved theme + font, pinch-zoom overriding only the size. + +- [ ] **Step 1: Add the Appearance section to Settings** + +In `mobile_settings_screen.dart`: +- Import `'../../widgets/terminal_appearance_controls.dart';`. +- Add before the closing `]` of the `ListView` (after Security): +```dart + const SizedBox(height: 24), + const Text('Terminal appearance', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + const TerminalAppearanceControls( + layout: AppearanceControlsLayout.rows), +``` + +- [ ] **Step 2: Apply appearance in the sessions terminal** + +In `mobile_sessions_screen.dart`: +- Imports: +```dart +import 'package:provider/provider.dart'; +import '../../providers/settings_provider.dart'; +import '../../util/terminal_appearance.dart'; +import '../../theme/terminal_themes.dart'; +``` +- Replace the connected `TerminalView(...)` in `_SessionBody` so it resolves appearance from settings and applies theme + font, with pinch overriding the size. Change `_SessionBody` to read settings via `context.watch()` and compute: +```dart + final settings = context.watch(); + final appearance = resolveTerminalAppearance( + host: session.host, + globalTheme: settings.terminalTheme, + globalFont: settings.terminalFont, + globalFontSize: settings.fontSize, + ); + // pinch overrides size only; theme + font come from settings. + final effectiveSize = fontSize == 0 ? appearance.fontSize : fontSize; +``` +and the TerminalView becomes: +```dart + child: TerminalView( + session.terminal, + theme: terminalThemeByName(appearance.themeName), + textStyle: TerminalStyle( + fontSize: effectiveSize, + fontFamily: appearance.fontFamily, + ), + ), +``` +Initialize the pinch base to `0` (meaning "follow settings") in `_MobileSessionsScreenState`: change `double _fontSize = 14;` → `double _fontSize = 0;` and `double _scaleBase = 0;`; in `_onScaleStart`, seed the base from the current effective size if `_fontSize == 0` (read settings): +```dart + void _onScaleStart(ScaleStartDetails _) { + if (_fontSize == 0) { + final s = context.read(); + _fontSize = resolveTerminalAppearance( + host: context.read().activeSshSession?.host, + globalTheme: s.terminalTheme, + globalFont: s.terminalFont, + globalFontSize: s.fontSize, + ).fontSize; + } + _scaleBase = _fontSize; + } +``` + +> Verify at execution: pass the current `fontSize` (0 = follow-settings sentinel) from the state into `_SessionBody`. Keep `_onScaleUpdate` clamping 8–28. + +- [ ] **Step 3: Analyze + mobile tests + commit** + +Run: `cd app && flutter analyze lib/mobile && flutter test test/mobile/` +Expected: clean; PASS. +```bash +git add app/lib/mobile/screens/mobile_settings_screen.dart app/lib/mobile/screens/mobile_sessions_screen.dart +git commit -m "feat(mobile): terminal appearance settings + apply" +``` + +--- + +## Phase M5.4 — Release signing + +### Task 6: Release signing config + +**Files:** Modify `app/android/app/build.gradle.kts` + +**Interfaces:** release build uses a `key.properties`-driven signing config when present, else debug signing (so unsigned debug builds still work). + +- [ ] **Step 1: Add the signing config** + +At the top of `app/android/app/build.gradle.kts` (above `plugins {`), add: +```kotlin +import java.util.Properties +import java.io.FileInputStream + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} +``` +Inside `android { ... }` add a `signingConfigs` block (before `buildTypes`): +```kotlin + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } +``` +Change the `release` build type: +```kotlin + release { + signingConfig = if (keystorePropertiesFile.exists()) + signingConfigs.getByName("release") + else + signingConfigs.getByName("debug") + } +``` + +- [ ] **Step 2: Verify debug build still works (no key.properties)** + +Run: `cd app && flutter build apk --debug` +Expected: `✓ Built ... app-debug.apk` (debug path, no key.properties needed). + +- [ ] **Step 3: Document keystore generation (user step)** + +The release keystore is user-provided (gitignored — `key.properties`, `*.jks` are in `app/android/.gitignore`). To sign a release later: +``` +keytool -genkey -v -keystore ~/yourssh-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias yourssh +``` +then create `app/android/key.properties`: +``` +storePassword=<…> +keyPassword=<…> +keyAlias=yourssh +storeFile=/Users//yourssh-release.jks +``` +and `flutter build apk --release` / `flutter build appbundle --release`. + +- [ ] **Step 4: Commit** + +```bash +git add app/android/app/build.gradle.kts +git commit -m "build(android): release signing via key.properties (debug fallback)" +``` + +--- + +### Task 7: Build + regression (v1 complete) + +**Files:** none (verification) + +- [ ] **Step 1: Full test suite** — `cd app && flutter test` → all pass. +- [ ] **Step 2: Analyze** — `cd app && flutter analyze` → no NEW issues (2 pre-existing probe warnings may remain). +- [ ] **Step 3: Build APK** — `cd app && flutter build apk --debug` → built. +- [ ] **Step 4: Restore regenerated desktop registrants** +```bash +git checkout HEAD -- app/macos/Flutter/GeneratedPluginRegistrant.swift app/windows/flutter/generated_plugin_registrant.cc app/windows/flutter/generated_plugins.cmake 2>/dev/null || true +``` +- [ ] **Step 5: On-device (user)** — launch → biometric prompt → unlock → connect a host → change a host key server-side to see the TOFU dialog → change theme/font in Settings and see the terminal update → background/resume re-locks. + +--- + +## Self-Review + +**Spec coverage (M5):** +- "Biometric app-lock (`local_auth`) … gate on app open + resume" → Tasks 1, 2, 3. ✓ +- "TOFU dialog" (mismatch) → Task 4. ✓ +- "appearance settings" → Task 5 (controls + apply). ✓ +- "release APK signing" → Task 6. ✓ + +**Placeholder scan:** No vague steps. The `> Verify at execution` notes (dialog copy vs test matcher, pinch sentinel plumbing, local_auth version) are concrete checks with stated resolutions. + +**Type consistency:** `AppLockGate(child, authenticator, enabledOverride)` + `kAppLockPrefKey` used by the Settings toggle (Task 3). `TofuWatcher(child)` wraps the shell body (Task 4). `resolveTerminalAppearance(...)` args match `SettingsProvider.terminalTheme/terminalFont/fontSize` (Task 5). + +## v1 complete after M5 + +After this milestone the Android v1 scope (M1–M5) is done. Next step (outside this plan): open a GitHub issue (English, per repo convention) for the Android target and a PR from `feat/android-mobile-app`, and update `docs/roadmap.md` Phase 5 via the `/yourssh-roadmap` skill. From 123451763a042180b7ea0ee4f07d830a71a5ffc8 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 10:59:15 +0700 Subject: [PATCH 28/96] build(mobile): add local_auth for app-lock --- app/pubspec.lock | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ app/pubspec.yaml | 1 + 2 files changed, 49 insertions(+) diff --git a/app/pubspec.lock b/app/pubspec.lock index ef0cd806..1544010a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -509,6 +509,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: @@ -581,6 +589,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" local_notifier: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 066d607c..cdaba9b4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: # QR code generation (P2P sync) qr_flutter: ^4.1.0 mobile_scanner: ^5.2.3 + local_auth: ^2.3.0 # UUID generation uuid: ^4.5.1 From 152d469a62616d69ec415761de30a490e9fdb9ae Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 11:00:20 +0700 Subject: [PATCH 29/96] feat(mobile): biometric app-lock gate --- app/lib/mobile/mobile_app.dart | 3 +- app/lib/mobile/security/app_lock_gate.dart | 108 +++++++++++++++++++++ app/test/mobile/app_lock_gate_test.dart | 35 +++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 app/lib/mobile/security/app_lock_gate.dart create mode 100644 app/test/mobile/app_lock_gate_test.dart diff --git a/app/lib/mobile/mobile_app.dart b/app/lib/mobile/mobile_app.dart index 753d252a..b5fb6328 100644 --- a/app/lib/mobile/mobile_app.dart +++ b/app/lib/mobile/mobile_app.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../theme/app_theme.dart'; import 'mobile_bootstrap.dart'; import 'screens/mobile_home_shell.dart'; +import 'security/app_lock_gate.dart'; /// Root widget for the Android build. Dark-only; reuses the shared /// [buildAppTheme] so the mobile surface matches desktop. Holds the @@ -27,7 +28,7 @@ class _YourSSHMobileAppState extends State { debugShowCheckedModeBanner: false, themeMode: ThemeMode.dark, theme: buildAppTheme(), - home: const MobileHomeShell(), + home: const AppLockGate(child: MobileHomeShell()), ), ); } diff --git a/app/lib/mobile/security/app_lock_gate.dart b/app/lib/mobile/security/app_lock_gate.dart new file mode 100644 index 00000000..8bac6dca --- /dev/null +++ b/app/lib/mobile/security/app_lock_gate.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../theme/app_theme.dart'; + +const String kAppLockPrefKey = 'app_lock_enabled'; + +/// Wraps the mobile app in a biometric lock. Locked on launch (and on +/// resume-from-background) when enabled; unlocks on a successful auth. The +/// [authenticator] and [enabledOverride] seams keep the state machine testable +/// without real device biometrics. +class AppLockGate extends StatefulWidget { + final Widget child; + final Future Function()? authenticator; + final bool? enabledOverride; + + const AppLockGate({ + super.key, + required this.child, + this.authenticator, + this.enabledOverride, + }); + + @override + State createState() => _AppLockGateState(); +} + +class _AppLockGateState extends State with WidgetsBindingObserver { + bool _enabled = false; + bool _locked = false; + bool _authInFlight = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _init(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + Future _init() async { + final enabled = widget.enabledOverride ?? + (await SharedPreferences.getInstance()).getBool(kAppLockPrefKey) ?? + true; + if (!mounted) return; + setState(() { + _enabled = enabled; + _locked = enabled; + }); + if (enabled) _authenticate(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && _enabled && !_locked) { + setState(() => _locked = true); + _authenticate(); + } + } + + Future _defaultAuth() async { + final auth = LocalAuthentication(); + try { + return await auth.authenticate( + localizedReason: 'Unlock YourSSH', + options: const AuthenticationOptions(stickyAuth: true), + ); + } catch (_) { + return false; + } + } + + Future _authenticate() async { + if (_authInFlight) return; + _authInFlight = true; + final ok = await (widget.authenticator ?? _defaultAuth)(); + _authInFlight = false; + if (mounted && ok) setState(() => _locked = false); + } + + @override + Widget build(BuildContext context) { + if (!_locked) return widget.child; + return Scaffold( + backgroundColor: AppColors.bg, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_outline, + size: 56, color: AppColors.textSecondary), + const SizedBox(height: 16), + const Text('YourSSH is locked', + style: TextStyle(color: AppColors.textPrimary, fontSize: 16)), + const SizedBox(height: 16), + FilledButton(onPressed: _authenticate, child: const Text('Unlock')), + ], + ), + ), + ); + } +} diff --git a/app/test/mobile/app_lock_gate_test.dart b/app/test/mobile/app_lock_gate_test.dart new file mode 100644 index 00000000..e85f6fd0 --- /dev/null +++ b/app/test/mobile/app_lock_gate_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/security/app_lock_gate.dart'; + +const _childKey = Key('locked-child'); + +Future _pump(WidgetTester tester, + {required bool enabled, required Future Function() auth}) async { + await tester.pumpWidget(MaterialApp( + home: AppLockGate( + enabledOverride: enabled, + authenticator: auth, + child: const Scaffold(body: SizedBox(key: _childKey)), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('disabled shows child immediately', (tester) async { + await _pump(tester, enabled: false, auth: () async => false); + expect(find.byKey(_childKey), findsOneWidget); + }); + + testWidgets('enabled + success unlocks to child', (tester) async { + await _pump(tester, enabled: true, auth: () async => true); + expect(find.byKey(_childKey), findsOneWidget); + }); + + testWidgets('enabled + failure keeps the lock screen', (tester) async { + await _pump(tester, enabled: true, auth: () async => false); + expect(find.byKey(_childKey), findsNothing); + expect(find.text('Unlock'), findsOneWidget); + }); +} From 0bef8e4fc0fc07db79c40d88ec53668536ad2b35 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 11:01:07 +0700 Subject: [PATCH 30/96] feat(mobile): app-lock toggle in settings --- .../screens/mobile_settings_screen.dart | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index f80b7ecd..c37d212f 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../providers/host_provider.dart'; import '../../providers/sync_provider.dart'; import '../../services/sync_service.dart'; import '../../theme/app_theme.dart'; +import '../security/app_lock_gate.dart'; import 'mobile_qr_scan_screen.dart'; /// Settings tab. M3 ships the Sync section (cloud pull + P2P QR scan). @@ -22,6 +24,17 @@ class _MobileSettingsScreenState extends State { final _code = TextEditingController(); bool _seeded = false; bool _pulling = false; + bool _appLock = true; + + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((p) { + if (mounted) { + setState(() => _appLock = p.getBool(kAppLockPrefKey) ?? true); + } + }); + } @override void dispose() { @@ -98,6 +111,26 @@ class _MobileSettingsScreenState extends State { label: const Text('Scan QR code'), onPressed: _scan, ), + const SizedBox(height: 24), + const Text('Security', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _appLock, + onChanged: (v) async { + setState(() => _appLock = v); + final p = await SharedPreferences.getInstance(); + await p.setBool(kAppLockPrefKey, v); + }, + title: const Text('Require biometrics to open', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: const Text('Applies on next launch / resume', + style: + TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), ], ), ), From a6a876c629f6b5f64416c4b3d68f14cef814e7fe Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 11:03:01 +0700 Subject: [PATCH 31/96] feat(mobile): TOFU host-key-mismatch dialog --- app/lib/mobile/screens/mobile_home_shell.dart | 3 +- app/lib/mobile/security/tofu_watcher.dart | 73 +++++++++++++++++++ app/test/mobile/mobile_home_shell_test.dart | 2 + app/test/mobile/tofu_watcher_test.dart | 34 +++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 app/lib/mobile/security/tofu_watcher.dart create mode 100644 app/test/mobile/tofu_watcher_test.dart diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 2feede9f..15d18e9a 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; +import '../security/tofu_watcher.dart'; import 'mobile_add_host_screen.dart'; import 'mobile_hosts_screen.dart'; import 'mobile_sessions_screen.dart'; @@ -55,7 +56,7 @@ class _MobileHomeShellState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.bg, - body: _body(), + body: TofuWatcher(child: _body()), bottomNavigationBar: NavigationBar( selectedIndex: _index, onDestinationSelected: (i) => setState(() => _index = i), diff --git a/app/lib/mobile/security/tofu_watcher.dart b/app/lib/mobile/security/tofu_watcher.dart new file mode 100644 index 00000000..2b65f1d2 --- /dev/null +++ b/app/lib/mobile/security/tofu_watcher.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/known_host.dart'; +import '../../providers/known_hosts_provider.dart'; +import '../../theme/app_theme.dart'; + +/// Watches [KnownHostsProvider.pendingChallenge] and shows a single TOFU +/// host-key-mismatch dialog (Trust / Reject) when a key changes. +class TofuWatcher extends StatefulWidget { + final Widget child; + const TofuWatcher({super.key, required this.child}); + + @override + State createState() => _TofuWatcherState(); +} + +class _TofuWatcherState extends State { + bool _dialogOpen = false; + + @override + Widget build(BuildContext context) { + final challenge = context.watch().pendingChallenge; + if (challenge != null && !_dialogOpen) { + _dialogOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _show(challenge)); + } + return widget.child; + } + + Future _show(HostKeyChallenge c) async { + final accept = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + backgroundColor: AppColors.card, + title: const Text('Host key changed'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'The host key for ${c.host}:${c.port} has changed. This could ' + 'be a server reinstall — or a man-in-the-middle attack.', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 13)), + const SizedBox(height: 12), + Text('Old: ${c.oldFingerprint}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + fontFamily: 'monospace')), + Text('New: ${c.newFingerprint}', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 11, + fontFamily: 'monospace')), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Reject')), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Trust')), + ], + ), + ); + c.resolve(accept == true); + _dialogOpen = false; + } +} diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index bbaf0c67..44944b16 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/providers/known_hosts_provider.dart'; import 'package:yourssh/providers/session_provider.dart'; import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; @@ -17,6 +18,7 @@ Future _pump(WidgetTester tester) async { providers: [ ChangeNotifierProvider(create: (_) => HostProvider(storage)), ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider(create: (_) => KnownHostsProvider(storage)), ChangeNotifierProvider( create: (_) => SessionProvider(ssh, TabMetadataService())), ], diff --git a/app/test/mobile/tofu_watcher_test.dart b/app/test/mobile/tofu_watcher_test.dart new file mode 100644 index 00000000..df9b6fd8 --- /dev/null +++ b/app/test/mobile/tofu_watcher_test.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/mobile/security/tofu_watcher.dart'; +import 'package:yourssh/providers/known_hosts_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows a dialog on host-key mismatch and Trust accepts', + (tester) async { + final kh = KnownHostsProvider(StorageService()); + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider.value( + value: kh, + child: const TofuWatcher(child: Scaffold(body: SizedBox())), + ), + )); + + final fp1 = Uint8List.fromList(List.filled(16, 1)); + final fp2 = Uint8List.fromList(List.filled(16, 2)); + await kh.verifyHostKey('h', 22, 'ssh-ed25519', fp1); // first-use trust + final future = kh.verifyHostKey('h', 22, 'ssh-ed25519', fp2); // mismatch + await tester.pumpAndSettle(); + + expect(find.textContaining('host key'), findsOneWidget); + await tester.tap(find.text('Trust')); + await tester.pumpAndSettle(); + expect(await future, isTrue); + }); +} From c38cd6dd05b044cb76d15793feb01fb065c563ce Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 11:04:50 +0700 Subject: [PATCH 32/96] feat(mobile): terminal appearance settings + apply --- .../screens/mobile_sessions_screen.dart | 38 +++++++++++++++++-- .../screens/mobile_settings_screen.dart | 10 +++++ .../mobile/mobile_settings_screen_test.dart | 2 + 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart index fc6cabca..9849d22f 100644 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -4,7 +4,10 @@ import 'package:xterm/xterm.dart'; import '../../models/ssh_session.dart'; import '../../providers/session_provider.dart'; +import '../../providers/settings_provider.dart'; import '../../theme/app_theme.dart'; +import '../../theme/terminal_themes.dart'; +import '../../util/terminal_appearance.dart'; import '../terminal/accessory_bar_controller.dart'; import '../terminal/accessory_key_bar.dart'; import '../terminal/mobile_snippets_sheet.dart'; @@ -21,8 +24,9 @@ class MobileSessionsScreen extends StatefulWidget { class _MobileSessionsScreenState extends State { final _accessory = AccessoryBarController(); - double _fontSize = 14; - double _scaleBase = 14; + // 0 = follow the global appearance font size; non-zero = pinch override. + double _fontSize = 0; + double _scaleBase = 0; @override void dispose() { @@ -30,6 +34,16 @@ class _MobileSessionsScreenState extends State { super.dispose(); } + double _settingsFontSize() { + final s = context.read(); + return resolveTerminalAppearance( + host: context.read().activeSshSession?.host, + globalTheme: s.terminalTheme, + globalFont: s.terminalFont, + globalFontSize: s.fontSize, + ).fontSize; + } + void _openSnippets(SshSession active) { showModalBottomSheet( context: context, @@ -41,7 +55,10 @@ class _MobileSessionsScreenState extends State { ); } - void _onScaleStart(ScaleStartDetails _) => _scaleBase = _fontSize; + void _onScaleStart(ScaleStartDetails _) { + if (_fontSize == 0) _fontSize = _settingsFontSize(); + _scaleBase = _fontSize; + } void _onScaleUpdate(ScaleUpdateDetails d) { if (d.scale == 1.0) return; @@ -146,6 +163,15 @@ class _SessionBody extends StatelessWidget { @override Widget build(BuildContext context) { if (session.status == SessionStatus.connected) { + final settings = context.watch(); + final appearance = resolveTerminalAppearance( + host: session.host, + globalTheme: settings.terminalTheme, + globalFont: settings.terminalFont, + globalFontSize: settings.fontSize, + ); + // Pinch (fontSize > 0) overrides size only; theme + font come from settings. + final effectiveSize = fontSize == 0 ? appearance.fontSize : fontSize; return Column( children: [ Expanded( @@ -154,7 +180,11 @@ class _SessionBody extends StatelessWidget { onScaleUpdate: onScaleUpdate, child: TerminalView( session.terminal, - textStyle: TerminalStyle(fontSize: fontSize), + theme: terminalThemeByName(appearance.themeName), + textStyle: TerminalStyle( + fontSize: effectiveSize, + fontFamily: appearance.fontFamily, + ), ), ), ), diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index c37d212f..61673e08 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -6,6 +6,7 @@ import '../../providers/host_provider.dart'; import '../../providers/sync_provider.dart'; import '../../services/sync_service.dart'; import '../../theme/app_theme.dart'; +import '../../widgets/terminal_appearance_controls.dart'; import '../security/app_lock_gate.dart'; import 'mobile_qr_scan_screen.dart'; @@ -131,6 +132,15 @@ class _MobileSettingsScreenState extends State { style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), ), + const SizedBox(height: 24), + const Text('Terminal appearance', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + const TerminalAppearanceControls( + layout: AppearanceControlsLayout.rows), ], ), ), diff --git a/app/test/mobile/mobile_settings_screen_test.dart b/app/test/mobile/mobile_settings_screen_test.dart index feee6aec..5bd909de 100644 --- a/app/test/mobile/mobile_settings_screen_test.dart +++ b/app/test/mobile/mobile_settings_screen_test.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_settings_screen.dart'; import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; import 'package:yourssh/providers/sync_provider.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/services/sync_service.dart'; @@ -15,6 +16,7 @@ Future _pump(WidgetTester tester, SyncProvider sync) async { providers: [ ChangeNotifierProvider.value(value: sync), ChangeNotifierProvider(create: (_) => HostProvider(storage)), + ChangeNotifierProvider(create: (_) => SettingsProvider()), Provider(create: (_) => SyncService(sync)), ], child: const MobileSettingsScreen(), From dbd5933803699727b6e43f3525639a72e30b864e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 11:05:49 +0700 Subject: [PATCH 33/96] build(android): release signing via key.properties (debug fallback) --- app/android/app/build.gradle.kts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index ae750c9f..4d94c381 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -1,9 +1,21 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing is driven by a gitignored `key.properties` (storeFile, +// storePassword, keyAlias, keyPassword). Absent it, release falls back to the +// debug keys so unsigned debug builds still work. +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.thangnm.yourssh" // Transitive plugins (flutter_plugin_android_lifecycle via file_picker) @@ -27,11 +39,23 @@ android { versionName = flutter.versionName } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePropertiesFile.exists()) + signingConfigs.getByName("release") + else + signingConfigs.getByName("debug") } } } From 76b9632b9ef0f73db2f92489fb2ee40696c00610 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 11:30:41 +0700 Subject: [PATCH 34/96] docs: sync README shipped features + note Android v1 (in development) in roadmap/README --- README.md | 26 ++++++++++++++++++++------ docs/roadmap.md | 4 +++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8569ee7c..33d29b96 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ sudo dpkg -r yourssh ### Terminal & Connectivity - **In-app RDP client** — connect to Windows, xrdp, and any RDP-compatible server directly from a tab alongside your SSH sessions; supports NLA, TLS, and auto security modes; optional SSH tunnel via a saved jump host; server-certificate TOFU with pre-auth pin enforcement (a changed certificate aborts before your credentials are sent); **fullscreen mode** with an mstsc-style hover bar at the top edge; full keyboard and mouse support (including Ctrl+Alt+Del); bidirectional clipboard; RDP tabs excluded from terminal-only features (recording, split view, input bar, snippets) +- **In-app VNC client** — connect to Linux VNC servers (TigerVNC, x11vnc, TightVNC) as first-class tabs too; RFB with None / VNC-password auth, framebuffer rendering, full mouse + keyboard, bidirectional clipboard, server-driven auto-resize, optional SSH tunnel through a saved jump host, and the same fullscreen hover-bar UX as RDP - **Multi-tab SSH sessions** with named tabs, per-tab connection state, distro icons (detected via `/etc/os-release`), and middle-click to close - **Terminal sharing (multiplayer)** — share a live SSH session with a session code; guests join via the Command Palette and watch or interact in real time; built on Supabase Realtime - **Split terminal view** — horizontal/vertical pane splitting within a session @@ -85,10 +86,14 @@ sudo dpkg -r yourssh - **Copy & paste that just works** — `Ctrl+C` copies when text is selected and stays SIGINT otherwise (Windows Terminal behavior); `Cmd+C/V` on macOS, `Ctrl(+Shift)+V` to paste, right-click Copy/Paste/Select All menu, and middle-click paste - **Shell integration (bash/zsh)** — injected OSC 7/133 prompt hooks surface the working directory on the session tab, a per-command status gutter (green = ok, red = failed), jump-to-prompt (Cmd/Ctrl+↑/↓), and cwd-aware path completion in the input bar; auto-on with per-host / global opt-out; the setup script is delivered invisibly (never echoed into your terminal or recordings) - **Port forwarding** — local, remote, and dynamic SOCKS5 tunnels with start/stop per rule, auto-start on launch, live connection counters, auto-reconnect with backoff when the SSH link drops (no terminal tab required — tunnels dial the host with stored credentials), and a right-click menu to duplicate rules -- **Jump host / bastion proxy** — connect to internal servers via a bastion host; select any saved host as the jump hop in the host detail panel +- **Jump host / bastion proxy (multi-hop)** — connect to internal servers through one or more bastions (bastion → bastion → target); a Termius-style visual Connection Chain editor in the host panel adds/removes hops, with per-hop host-key verification and loop protection +- **Outbound connection proxy** — per-host HTTP CONNECT or SOCKS5 proxy for SSH connections on restricted networks, with optional username/password auth; composes with the multi-hop jump chain (the proxy password stays in secure storage, never in synced data) - **SSH agent forwarding** — per-host toggle (like `ssh -A`) to hop between servers with the keys on your local machine; serves your system agent, falling back to app-Keychain keys when no agent is running — private keys never leave your machine; a status line in the host panel and a per-session key icon show whether forwarding is ready, active, on Keychain fallback, or refused by the server -- **Local shell** — spawn native macOS/Windows/Linux shell alongside SSH sessions +- **Local shell** — spawn a native macOS/Windows/Linux shell as a first-class tab alongside SSH sessions, and pick which shell to run (PowerShell, cmd, PowerShell 7, Git Bash, WSL distros on Windows; `$SHELL` / `/etc/shells` / custom executables elsewhere) - **Keyword highlighting** — user-defined regex rules tint matching terminal output; ships with Error/Warning/Fail defaults (red/yellow/cyan); toggle, rule list, add/edit dialog, and per-rule color picker in Settings → Terminal and the terminal config side panel +- **OSC 52 clipboard** — remote apps (tmux, vim) can copy to your local system clipboard via the OSC 52 escape sequence; write-only and per-host opt-in for safety +- **Scrollback paging & Reset Terminal** — Shift+PageUp / Shift+PageDown page through scrollback, and a right-click **Reset Terminal** recovers a session stuck in the alternate screen after a TUI crashed (the local equivalent of `reset`) +- **Session templates (per-host presets)** — per-host working directory, environment variables (delivered invisibly via the shell-integration handshake), a startup snippet, and per-host terminal theme / font / size / TERM / tmux overrides - **xterm-256color** terminal emulation with full PTY support ### File Management @@ -102,6 +107,7 @@ sudo dpkg -r yourssh ### Credentials & Security - **4 auth methods**: password, SSH private key, SSH certificate (CA-signed), SSH agent (`SSH_AUTH_SOCK` on macOS/Linux; `\\.\pipe\openssh-ssh-agent` on Windows 10+) +- **In-app SSH key generation** — generate Ed25519 / RSA-4096 / ECDSA-P256 keys from the Keychain screen (optional passphrase), then copy the public key or push it to a host with a built-in `ssh-copy-id`-style deploy dialog - **OS-level secure storage**: credentials encrypted in macOS Keychain / Windows Credential Manager via `flutter_secure_storage` - **Known hosts verification**: interactive fingerprint trust dialog on first connect; persistent known-hosts database - **Zero-knowledge cloud sync**: host configs encrypted client-side (AES-256-GCM) with a 12-character sync code that never leaves your devices — the Supabase anon key alone cannot decrypt anything @@ -118,8 +124,11 @@ sudo dpkg -r yourssh - **Dashboard grid & list view** — switch between host cards and a compact list, with persistent sorting (name, creation date, or hostname) - **Broadcast mode** — send the same input to multiple sessions at once - **Code editor** — edit remote files inline with a Monaco-powered editor -- **Session recording** — record terminal output to Asciinema v2 (`.cast`) files; per-host auto-record setting; manual start/stop from the toolbar; Recording Library with in-app playback (play/pause, speed control 0.5×–5×) +- **Session recording** — record terminal output to Asciinema v2 (`.cast`) files; per-host auto-record setting; manual start/stop from the toolbar; Recording Library with in-app playback (play/pause, speed control 0.5×–5×); secrets are redacted before being written to the file (global + per-host opt-out) - **Notification bell** — a bell in the top tab bar collects in-app notifications (new release available with a one-click Update button, sessions that dropped unexpectedly) behind an unread badge; opening the panel marks them read, items can be dismissed individually or cleared all at once +- **Internal audit log** — a local, searchable SQLite trail of connect / disconnect / exec / input events with per-caller source tagging and secret redaction before write; filter by host / type / time, with CSV / JSON export and configurable retention +- **Network discovery** — scan the local network for SSH/RDP hosts (mDNS + TCP port scan) and add them with one tap, without typing an IP +- **Import from anywhere** — bulk-import hosts from SSH config, JSON, CSV, PuTTY, MobaXterm, SecureCRT, Ansible inventory, WinSCP, Termius, or `ssh://` URIs; import known hosts from `~/.ssh/known_hosts` ### Design - Dark-only interface with a cohesive green-accent palette @@ -129,7 +138,8 @@ sudo dpkg -r yourssh - Minimum window size enforced (800×600); fully resizable ### DevOps & Developer Tools -- **Containers (Docker / Kubernetes)** — list running containers (`docker ps`) and pods (`kubectl get pods`) on the active SSH session, then **Exec** into any of them in a new terminal tab; namespace filter + all-namespaces toggle for Kubernetes, and an install/permission hint when the runtime is missing +- **Containers (Docker / Compose / Kubernetes)** — dedicated tabs on the Containers screen. **Docker**: live `docker ps -a` list with per-container Start / Stop / Restart, one-click **Exec** into a new tab, and a follow-mode log viewer. **Compose**: stack discovery (`docker compose ls` + filesystem sweep), per-stack Up / Down, and per-service Start / Stop with follow-mode logs. **Kubernetes**: context switcher, streamed `kubectl logs -f`, and 1-click port-forward; namespace filter + all-namespaces toggle +- **Server monitor** — per-host live dashboard (CPU / memory / disk / uptime / listening ports / firewall) in a draggable bottom sheet, polled over SSH; auto-detects ufw / iptables / nftables - **Network Tools** — ping, cURL, DNS lookup, traceroute, port scan, whois, netstat, disk usage, memory info, HTTP headers, SSL certificate inspection — all run on the active SSH session - **Cloudflare Tunnel manager** — start/stop quick tunnels via `cloudflared` on the remote host; public URL displayed instantly - **LAN Share** — serve any local file over HTTP for one-click download on the same network @@ -539,6 +549,10 @@ Include a short description of **what** changed and **why**. Screenshots for UI - [x] Multi-provider AI assistant (Claude, OpenAI, Gemini) - [x] P2P host sync via QR code (LAN / Tailscale, AES-256-GCM encrypted) - [x] **Script Engine** — disk-based JS plugins via QuickJS FFI; HookBus; SSH/SFTP/Storage/UI bridges; hot-reload; permission guard + circuit breaker; consent dialog, manager screen, console log viewer +- [x] **In-app RDP client** — Windows / xrdp desktops as tabs (NLA/TLS, TOFU pin enforcement, SSH tunnel, fullscreen) +- [x] **In-app VNC client** — Linux VNC servers (TigerVNC / x11vnc / TightVNC) as tabs +- [x] **Internal audit log** — local SQLite trail of connect/exec/input with secret redaction, filters, and CSV/JSON export +- [x] **Outbound connection proxy** — per-host HTTP CONNECT / SOCKS5 proxy for SSH on restricted networks ### ✅ Phase 1 — Quick wins @@ -562,14 +576,14 @@ Include a short description of **what** changed and **why**. Screenshots for UI ### 🔜 Phase 4 — DevOps tooling -- [ ] **Docker / Kubernetes exec** — list containers/pods on the remote host and exec into them directly +- [x] **Docker / Compose / Kubernetes** — list containers, Compose stacks, and pods; Start/Stop/Restart, exec into a new tab, follow-mode logs, and K8s port-forward - [ ] **Remote process manager** — `htop`-style process list with kill support - [ ] **Log tail viewer** — real-time `tail -f` panel with regex filter and highlight ### 🔜 Phase 5 — Platform expansion - [ ] **iOS / iPadOS target** (experimental) -- [ ] **Android target** (experimental) +- [ ] **Android target** — 🚧 *in development* (`feat/android-mobile-app`): mobile SSH client with multi-tab terminal + accessory key bar, hosts/keys, cloud + P2P QR host import, single-panel SFTP, snippets, biometric app-lock, and TOFU. Not yet released. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b0ccaee..dd081069 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # YourSSH — Roadmap > Direction: **infra workstation for DevOps/SRE managing 10–100+ hosts**, not just an SSH client. -> Current version: 0.1.38 · updated: 2026-06-23 +> Current version: 0.1.38 · updated: 2026-06-25 This document lists proposed features ordered by priority. Each item can be broken out into its own spec (`docs/superpowers/specs/`) when ready for implementation. @@ -9,6 +9,8 @@ Already shipped (not repeated in roadmap): multi-tab terminal, split view, broad **Shipped (0.1.37):** **OSC 52 clipboard** — remote apps (tmux, vim) write to the local system clipboard via the OSC 52 escape sequence; write-only, per-host opt-in (`Host.osc52Clipboard`) for safety; pure payload parser + dispatch on the xterm `onPrivateOSC` path (`osc52_clipboard.dart`). **In-app VNC client (VNC M1–M5, #44 family)** — Linux VNC servers (TigerVNC / x11vnc / TightVNC) as first-class tabs, mirroring the RDP integration one-to-one: `packages/yourssh_vnc` Rust crate over `vnc-rs` via flutter_rust_bridge v2; RFB connect + None / VNC-password auth, framebuffer-update rendering with latest-wins decode (`VncSession`), mouse + keyboard input (X11 keysym map + pointer-coordinate transform), bidirectional clipboard (Client/ServerCutText), server-driven auto-resize, SSH tunneling through the shared loopback proxy (`RdpTunnelProxy` generalized to `LoopbackTunnelProxy`), fullscreen with mstsc-style auto-hide hover pill, `ProtocolBadge` (RDP + VNC), `HostProtocol.vnc` (port 5900) with a VNC mode in the host panel, protocol-aware dashboard actions + `vnc://` copy-url, tab parity (rename / color / pin / restore / audit / notification bell), and a manual integration-screenshots test. **Connection proxy support** — per-host HTTP CONNECT and SOCKS5 outbound proxy for SSH connections on restricted networks; optional username/password auth (HTTP Basic + SOCKS5 RFC 1929); proxy password in secure storage, never in synced JSON; applies to local-originated dials (direct target + first bastion hop) so it composes correctly with the multi-hop jump chain; SOCKS5 sends the target hostname as a domain address (ATYP `0x03`) for remote DNS; pure Dart implementation (`proxy_handshake.dart`, `ConnectionProxy`, `SshService.localDial`) with injectable dial seams for tests; PROXY section in the host panel (type dropdown + host/port/username/password, SSH-only — RDP/VNC use SSH tunneling). +**In progress — Android mobile app (v1)** (`feat/android-mobile-app`, pending merge to develop): the desktop Flutter app gains an **Android target** as a focused mobile SSH client, built as 5 milestones. **M1** adds the `android/` target and a platform branch in `main()` (`isMobilePlatform`) that mounts a mobile UI shell while skipping the desktop-only bootstrap (window_manager / hotkey_manager / local_notifier / flutter_pty), so desktop is untouched. **M2** — hosts list + minimal add-host form + connect, a mobile `TerminalView` with an **accessory key bar** (sticky Ctrl/Alt, ^C/^D, arrows, special chars) and pinch-to-zoom, multi-session strip; a mobile-scoped bootstrap reuses the existing providers/services. **M3** — host import from desktop via cloud Supabase pull and **P2P QR camera scan** (adds `mobile_scanner`). **M4** — single-panel SFTP (browse / download / upload via `file_picker`) and a snippets quick-insert sheet (command history deferred — no input bar to capture commands on mobile). **M5** — biometric **app-lock** (`local_auth`, gate on launch/resume), the TOFU host-key-mismatch dialog, terminal appearance settings (theme / font / size), and release APK signing via `key.properties`. iOS deferred but the structure doesn't preclude it. Specs/plans in `docs/superpowers/`; ~33 commits, all milestones TDD-covered (33 new widget/unit tests), APK builds green. + **Shipped (0.1.38):** **Docker panel completion** — the Containers screen gains dedicated **Docker** and **Compose** tabs beside Kubernetes (`DockerPanel` / `ComposePanel`, extracted from `ContainersScreen`). Docker: live container list (`docker ps -a`) with per-container Stop / Start / Restart, one-click Exec into a new SSH tab, and an inline follow-mode log viewer (`docker logs -f --tail`, auto-scroll with manual-scroll detach, 2000-line cap). Compose (v2): stack discovery via `docker compose ls` merged with a `find` sweep of `~ /opt /srv /home` (ls entries win on dir collision) plus manual compose-file path add; per-stack Up / Down, per-service Start / Stop with replica counts, and per-service follow-mode logs. New `ComposeStack` / `ComposeService` models and `ContainerService` methods (`streamDockerLogs`, `stop`/`start`/`restartContainer`, `discoverComposeStacks`, `listComposeServices`, `composeUp`/`composeDown`, `start`/`stopComposeService`, `streamComposeServiceLogs`) with pure static parsers (`parseComposeLs` / `parseComposeFindOutput` / `parseComposePs`); all exec/stream calls tagged `auditSource: 'devops'` and shell-quote project paths. --- From c4e45031b43d2922083fc6c2d301893745edd4a5 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 11:44:54 +0700 Subject: [PATCH 35/96] =?UTF-8?q?fix(mobile):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20app-lock=20lifecycle,=20sync=20seed=20race,=20SFTP?= =?UTF-8?q?=20perf/OOM/race,=20per-session=20zoom,=20host-query=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/mobile/mobile_app.dart | 6 ++ app/lib/mobile/mobile_bootstrap.dart | 14 ++++ .../mobile/screens/mobile_hosts_screen.dart | 13 ++-- .../screens/mobile_sessions_screen.dart | 66 +++++++++---------- .../screens/mobile_settings_screen.dart | 19 ++++-- .../mobile/screens/mobile_sftp_screen.dart | 50 ++++++++++---- app/lib/mobile/security/app_lock_gate.dart | 23 +++++-- app/lib/mobile/security/tofu_watcher.dart | 8 ++- app/lib/mobile/sync/transfer_code.dart | 5 ++ .../mobile/terminal/accessory_key_bar.dart | 11 +++- app/test/mobile/transfer_code_test.dart | 6 ++ 11 files changed, 151 insertions(+), 70 deletions(-) diff --git a/app/lib/mobile/mobile_app.dart b/app/lib/mobile/mobile_app.dart index b5fb6328..871991fb 100644 --- a/app/lib/mobile/mobile_app.dart +++ b/app/lib/mobile/mobile_app.dart @@ -19,6 +19,12 @@ class YourSSHMobileApp extends StatefulWidget { class _YourSSHMobileAppState extends State { final _bootstrap = MobileBootstrap(); + @override + void dispose() { + _bootstrap.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MultiProvider( diff --git a/app/lib/mobile/mobile_bootstrap.dart b/app/lib/mobile/mobile_bootstrap.dart index 921707aa..ac32dc93 100644 --- a/app/lib/mobile/mobile_bootstrap.dart +++ b/app/lib/mobile/mobile_bootstrap.dart @@ -76,4 +76,18 @@ class MobileBootstrap { ChangeNotifierProvider.value(value: snippets), Provider.value(value: transfer), ]; + + /// Disposes the objects exposed via `Provider.value` (which does not dispose + /// them itself), most importantly [SessionProvider]'s reconnect timers and + /// [SyncService]'s retry timer. Called from the root widget's dispose. + void dispose() { + syncService.dispose(); + sessions.dispose(); + sync.dispose(); + snippets.dispose(); + knownHosts.dispose(); + settings.dispose(); + keyProvider.dispose(); + hostProvider.dispose(); + } } diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 5ed6406c..8bc298f1 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -5,6 +5,7 @@ import '../../models/host.dart'; import '../../providers/host_provider.dart'; import '../../providers/session_provider.dart'; import '../../theme/app_theme.dart'; +import '../../util/host_query.dart'; /// Hosts tab: searchable list of saved hosts; tap to connect, FAB to add. class MobileHostsScreen extends StatefulWidget { @@ -27,15 +28,9 @@ class _MobileHostsScreenState extends State { @override Widget build(BuildContext context) { final all = context.watch().allHosts; - final q = _query.trim().toLowerCase(); - final hosts = q.isEmpty - ? all - : all - .where((h) => - h.label.toLowerCase().contains(q) || - h.host.toLowerCase().contains(q) || - h.username.toLowerCase().contains(q)) - .toList(); + final q = _query.trim(); + // Reuse the dashboard's query engine so tag/facet search works on mobile too. + final hosts = q.isEmpty ? all : all.where(HostQuery.parse(q).matches).toList(); return Scaffold( backgroundColor: AppColors.bg, diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart index 9849d22f..17e443d4 100644 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -13,8 +13,8 @@ import '../terminal/accessory_key_bar.dart'; import '../terminal/mobile_snippets_sheet.dart'; /// Sessions tab: a session strip + the active SSH session's terminal (when -/// connected, with the accessory key bar docked below and pinch-to-zoom font) -/// or its connection status. +/// connected, with the accessory key bar docked below and per-session +/// pinch-to-zoom) or its connection status. class MobileSessionsScreen extends StatefulWidget { const MobileSessionsScreen({super.key}); @@ -24,8 +24,9 @@ class MobileSessionsScreen extends StatefulWidget { class _MobileSessionsScreenState extends State { final _accessory = AccessoryBarController(); - // 0 = follow the global appearance font size; non-zero = pinch override. - double _fontSize = 0; + // Pinch-zoom font overrides, keyed by session id — so zooming one session + // doesn't bleed its size onto another (and absence = follow appearance). + final Map _pinch = {}; double _scaleBase = 0; @override @@ -34,14 +35,13 @@ class _MobileSessionsScreenState extends State { super.dispose(); } - double _settingsFontSize() { - final s = context.read(); - return resolveTerminalAppearance( - host: context.read().activeSshSession?.host, - globalTheme: s.terminalTheme, - globalFont: s.terminalFont, - globalFontSize: s.fontSize, - ).fontSize; + void _onScaleStart(String sessionId, double followSize) { + _scaleBase = _pinch[sessionId] ?? followSize; + } + + void _onScaleUpdate(String sessionId, ScaleUpdateDetails d) { + if (d.scale == 1.0) return; + setState(() => _pinch[sessionId] = (_scaleBase * d.scale).clamp(8.0, 28.0)); } void _openSnippets(SshSession active) { @@ -55,16 +55,6 @@ class _MobileSessionsScreenState extends State { ); } - void _onScaleStart(ScaleStartDetails _) { - if (_fontSize == 0) _fontSize = _settingsFontSize(); - _scaleBase = _fontSize; - } - - void _onScaleUpdate(ScaleUpdateDetails d) { - if (d.scale == 1.0) return; - setState(() => _fontSize = (_scaleBase * d.scale).clamp(8.0, 28.0)); - } - @override Widget build(BuildContext context) { final sp = context.watch(); @@ -84,6 +74,14 @@ class _MobileSessionsScreenState extends State { ? sp.activeSession as SshSession : ssh.last; + final settings = context.watch(); + final appearance = resolveTerminalAppearance( + host: active.host, + globalTheme: settings.terminalTheme, + globalFont: settings.terminalFont, + globalFontSize: settings.fontSize, + ); + return Scaffold( backgroundColor: AppColors.bg, body: SafeArea( @@ -91,7 +89,8 @@ class _MobileSessionsScreenState extends State { children: [ Row( children: [ - Expanded(child: _SessionStrip(sessions: ssh, activeId: active.id)), + Expanded( + child: _SessionStrip(sessions: ssh, activeId: active.id)), IconButton( icon: const Icon(Icons.code, color: AppColors.textSecondary), tooltip: 'Snippets', @@ -104,9 +103,11 @@ class _MobileSessionsScreenState extends State { child: _SessionBody( session: active, accessory: _accessory, - fontSize: _fontSize, - onScaleStart: _onScaleStart, - onScaleUpdate: _onScaleUpdate, + appearance: appearance, + fontSize: _pinch[active.id] ?? appearance.fontSize, + onScaleStart: (_) => + _onScaleStart(active.id, appearance.fontSize), + onScaleUpdate: (d) => _onScaleUpdate(active.id, d), ), ), ], @@ -148,6 +149,7 @@ class _SessionStrip extends StatelessWidget { class _SessionBody extends StatelessWidget { final SshSession session; final AccessoryBarController accessory; + final TerminalAppearance appearance; final double fontSize; final void Function(ScaleStartDetails) onScaleStart; final void Function(ScaleUpdateDetails) onScaleUpdate; @@ -155,6 +157,7 @@ class _SessionBody extends StatelessWidget { const _SessionBody({ required this.session, required this.accessory, + required this.appearance, required this.fontSize, required this.onScaleStart, required this.onScaleUpdate, @@ -163,15 +166,6 @@ class _SessionBody extends StatelessWidget { @override Widget build(BuildContext context) { if (session.status == SessionStatus.connected) { - final settings = context.watch(); - final appearance = resolveTerminalAppearance( - host: session.host, - globalTheme: settings.terminalTheme, - globalFont: settings.terminalFont, - globalFontSize: settings.fontSize, - ); - // Pinch (fontSize > 0) overrides size only; theme + font come from settings. - final effectiveSize = fontSize == 0 ? appearance.fontSize : fontSize; return Column( children: [ Expanded( @@ -182,7 +176,7 @@ class _SessionBody extends StatelessWidget { session.terminal, theme: terminalThemeByName(appearance.themeName), textStyle: TerminalStyle( - fontSize: effectiveSize, + fontSize: fontSize, fontFamily: appearance.fontFamily, ), ), diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index 61673e08..bb3bc87d 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -23,7 +23,9 @@ class _MobileSettingsScreenState extends State { final _url = TextEditingController(); final _anon = TextEditingController(); final _code = TextEditingController(); - bool _seeded = false; + // Once the user edits a field we stop syncing it from the provider, so an + // async config load that completes after the first build can't clobber typing. + bool _dirty = false; bool _pulling = false; bool _appLock = true; @@ -48,11 +50,13 @@ class _MobileSettingsScreenState extends State { @override Widget build(BuildContext context) { final sync = context.watch(); - if (!_seeded) { - _url.text = sync.supabaseUrl; - _anon.text = sync.supabaseAnonKey; - _code.text = sync.syncCode; - _seeded = true; + if (!_dirty) { + // Mirror the provider until the user edits a field. SyncProvider loads its + // values asynchronously, so this re-seeds once the load completes instead + // of latching empty on the first frame (which a Save would then persist). + if (_url.text != sync.supabaseUrl) _url.text = sync.supabaseUrl; + if (_anon.text != sync.supabaseAnonKey) _anon.text = sync.supabaseAnonKey; + if (_code.text != sync.syncCode) _code.text = sync.syncCode; } return Scaffold( @@ -128,7 +132,7 @@ class _MobileSettingsScreenState extends State { }, title: const Text('Require biometrics to open', style: TextStyle(color: AppColors.textPrimary)), - subtitle: const Text('Applies on next launch / resume', + subtitle: const Text('Applies on next app launch', style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), ), @@ -194,6 +198,7 @@ class _MobileSettingsScreenState extends State { child: TextField( key: Key(key), controller: c, + onChanged: (_) => _dirty = true, style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), decoration: InputDecoration(labelText: label), ), diff --git a/app/lib/mobile/screens/mobile_sftp_screen.dart b/app/lib/mobile/screens/mobile_sftp_screen.dart index 5a64a136..6c420e70 100644 --- a/app/lib/mobile/screens/mobile_sftp_screen.dart +++ b/app/lib/mobile/screens/mobile_sftp_screen.dart @@ -14,6 +14,7 @@ import '../../theme/app_theme.dart'; /// Single-panel SFTP browser over the active SSH session's host. Lists, opens /// directories, downloads files to a picked folder, and uploads a picked file. +/// One SFTP channel is held per host and reused for all operations. class MobileSftpScreen extends StatefulWidget { const MobileSftpScreen({super.key}); @@ -28,21 +29,44 @@ class _MobileSftpScreenState extends State { String? _error; String? _loadedHostId; + // One reusable SFTP channel per host (opening one per listdir/download adds a + // full channel-handshake RTT to every tap on a mobile link). + SftpClient? _sftp; + String? _sftpHostId; + // Monotonic token so a slow listdir from a previous host can't overwrite the + // current view when the user switches sessions mid-load. + int _loadToken = 0; + + @override + void dispose() { + _sftp?.close(); + super.dispose(); + } + + Future _client(Host host) async { + if (_sftp != null && _sftpHostId == host.id) return _sftp!; + _sftp?.close(); + _sftp = await context.read().openSftp(host); + _sftpHostId = host.id; + return _sftp!; + } + Future _load(Host host, String path) async { + final token = ++_loadToken; setState(() { _loading = true; _error = null; }); try { - final sftp = await context.read().openSftp(host); + final sftp = await _client(host); final raw = await sftp.listdir(path); - sftp.close(); raw.sort((a, b) { final ad = a.attr.isDirectory, bd = b.attr.isDirectory; if (ad != bd) return ad ? -1 : 1; - return a.filename.compareTo(b.filename); + // Case-insensitive, matching the desktop SFTP ordering. + return a.filename.toLowerCase().compareTo(b.filename.toLowerCase()); }); - if (!mounted) return; + if (!mounted || token != _loadToken) return; // a newer load supersedes us setState(() { _path = path; _entries = raw @@ -51,7 +75,7 @@ class _MobileSftpScreenState extends State { _loading = false; }); } catch (e) { - if (mounted) { + if (mounted && token == _loadToken) { setState(() { _loading = false; _error = '$e'; @@ -166,26 +190,30 @@ class _MobileSftpScreenState extends State { String _join(String name) => _path == '.' ? name : p.posix.join(_path, name); Future _download(Host host, SftpName e) async { - final ssh = context.read(); final dir = await FilePicker.platform.getDirectoryPath(); if (dir == null) return; + final sink = io.File(p.join(dir, e.filename)).openWrite(); try { - final sftp = await ssh.openSftp(host); + final sftp = await _client(host); final file = await sftp.open(_join(e.filename)); - final bytes = await file.readBytes(); + // Stream chunks to disk instead of buffering the whole file in memory. + await for (final chunk in file.read()) { + sink.add(chunk); + } await file.close(); - sftp.close(); - await io.File(p.join(dir, e.filename)).writeAsBytes(bytes); _snack('Downloaded ${e.filename}'); } catch (err) { _snack('Download failed: $err'); + } finally { + await sink.close(); } } Future _upload(Host host) async { final transfer = context.read(); final picked = await FilePicker.platform.pickFiles(); - final localPath = picked?.files.single.path; + final localPath = + picked == null || picked.files.isEmpty ? null : picked.files.first.path; if (localPath == null) return; final name = p.basename(localPath); try { diff --git a/app/lib/mobile/security/app_lock_gate.dart b/app/lib/mobile/security/app_lock_gate.dart index 8bac6dca..4f428c5b 100644 --- a/app/lib/mobile/security/app_lock_gate.dart +++ b/app/lib/mobile/security/app_lock_gate.dart @@ -58,9 +58,21 @@ class _AppLockGateState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed && _enabled && !_locked) { - setState(() => _locked = true); - _authenticate(); + if (!_enabled) return; + // Don't disturb the lock state while our own biometric prompt is up — it + // briefly drives the app through inactive/paused → resumed, which would + // otherwise re-lock and re-prompt in a loop right after a successful unlock. + if (_authInFlight) return; + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + // Cover the app-switcher snapshot: lock before the OS captures it. + if (!_locked && mounted) setState(() => _locked = true); + case AppLifecycleState.resumed: + if (_locked) _authenticate(); + case AppLifecycleState.detached: + break; } } @@ -80,8 +92,11 @@ class _AppLockGateState extends State with WidgetsBindingObserver { if (_authInFlight) return; _authInFlight = true; final ok = await (widget.authenticator ?? _defaultAuth)(); - _authInFlight = false; if (mounted && ok) setState(() => _locked = false); + // Clear the guard one frame later, so the inactive→resumed transition that + // accompanies dismissing the system biometric sheet is still ignored above + // (otherwise it re-locks immediately after unlocking). + WidgetsBinding.instance.addPostFrameCallback((_) => _authInFlight = false); } @override diff --git a/app/lib/mobile/security/tofu_watcher.dart b/app/lib/mobile/security/tofu_watcher.dart index 2b65f1d2..79b85022 100644 --- a/app/lib/mobile/security/tofu_watcher.dart +++ b/app/lib/mobile/security/tofu_watcher.dart @@ -23,7 +23,13 @@ class _TofuWatcherState extends State { final challenge = context.watch().pendingChallenge; if (challenge != null && !_dialogOpen) { _dialogOpen = true; - WidgetsBinding.instance.addPostFrameCallback((_) => _show(challenge)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _show(challenge); + } else { + challenge.reject(); // never leave the connect future hanging + } + }); } return widget.child; } diff --git a/app/lib/mobile/sync/transfer_code.dart b/app/lib/mobile/sync/transfer_code.dart index 72f109a9..fea25628 100644 --- a/app/lib/mobile/sync/transfer_code.dart +++ b/app/lib/mobile/sync/transfer_code.dart @@ -22,5 +22,10 @@ import 'dart:convert'; } catch (_) { throw const FormatException('Transfer code key is invalid'); } + // P2P sync uses AES-256-GCM, whose key is exactly 32 bytes. Reject anything + // else here with a clear message rather than failing deep in decrypt. + if (key.length != 32) { + throw const FormatException('Transfer code key has the wrong length'); + } return (url: url, key: key); } diff --git a/app/lib/mobile/terminal/accessory_key_bar.dart b/app/lib/mobile/terminal/accessory_key_bar.dart index 8b0b1386..667a4374 100644 --- a/app/lib/mobile/terminal/accessory_key_bar.dart +++ b/app/lib/mobile/terminal/accessory_key_bar.dart @@ -33,6 +33,13 @@ class AccessoryKeyBar extends StatelessWidget { onText(s); } + /// Dedicated `Ctrl+` (e.g. ^C/^D). Clears any armed sticky modifier so + /// it can't carry over to the next keypress. + void _ctrlKey(TerminalKey k) { + controller.consumeModifiers(); + onKey(k, ctrl: true); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -50,8 +57,8 @@ class AccessoryKeyBar extends StatelessWidget { _btn('Ctrl', armed: controller.ctrlArmed, onTap: controller.armCtrl), _btn('Alt', armed: controller.altArmed, onTap: controller.armAlt), - _btn('^C', onTap: () => onKey(TerminalKey.keyC, ctrl: true)), - _btn('^D', onTap: () => onKey(TerminalKey.keyD, ctrl: true)), + _btn('^C', onTap: () => _ctrlKey(TerminalKey.keyC)), + _btn('^D', onTap: () => _ctrlKey(TerminalKey.keyD)), _icon(Icons.keyboard_arrow_left, 'Left', () => _key(TerminalKey.arrowLeft)), _icon(Icons.keyboard_arrow_up, 'Up', diff --git a/app/test/mobile/transfer_code_test.dart b/app/test/mobile/transfer_code_test.dart index 90e8f01b..bc4e41e5 100644 --- a/app/test/mobile/transfer_code_test.dart +++ b/app/test/mobile/transfer_code_test.dart @@ -17,4 +17,10 @@ void main() { expect(() => parseTransferCode('not json'), throwsFormatException); expect(() => parseTransferCode('{"u":"","k":"AAAA"}'), throwsFormatException); }); + + test('throws when the key is not 32 bytes (AES-256)', () { + final shortKey = base64.encode(List.filled(16, 0)); + expect(() => parseTransferCode('{"u":"http://x","k":"$shortKey"}'), + throwsFormatException); + }); } From 09926b47d41e61e4b7a98bb26b1a689f10161f1d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:21:43 +0700 Subject: [PATCH 36/96] fix(mobile): app-lock falls open when device has no biometric/credential (no lockout) --- app/lib/mobile/security/app_lock_gate.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/lib/mobile/security/app_lock_gate.dart b/app/lib/mobile/security/app_lock_gate.dart index 4f428c5b..c312186d 100644 --- a/app/lib/mobile/security/app_lock_gate.dart +++ b/app/lib/mobile/security/app_lock_gate.dart @@ -45,9 +45,13 @@ class _AppLockGateState extends State with WidgetsBindingObserver { } Future _init() async { - final enabled = widget.enabledOverride ?? + final wantEnabled = widget.enabledOverride ?? (await SharedPreferences.getInstance()).getBool(kAppLockPrefKey) ?? true; + // If the OS can't authenticate at all (no enrolled biometric and no device + // credential set), enforcing the lock would trap the user on a screen whose + // Unlock button can never succeed — so fall open instead of locking out. + final enabled = wantEnabled && await _canAuthenticate(); if (!mounted) return; setState(() { _enabled = enabled; @@ -56,6 +60,15 @@ class _AppLockGateState extends State with WidgetsBindingObserver { if (enabled) _authenticate(); } + Future _canAuthenticate() async { + if (widget.authenticator != null) return true; // injected in tests + try { + return await LocalAuthentication().isDeviceSupported(); + } catch (_) { + return false; + } + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { if (!_enabled) return; From fad76cd7922e9a467d945dd68a1be10353092a83 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:36:24 +0700 Subject: [PATCH 37/96] docs: mobile Termius-style UI redesign design spec --- ...26-06-25-mobile-termius-redesign-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-mobile-termius-redesign-design.md diff --git a/docs/superpowers/specs/2026-06-25-mobile-termius-redesign-design.md b/docs/superpowers/specs/2026-06-25-mobile-termius-redesign-design.md new file mode 100644 index 00000000..9f0c2576 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-mobile-termius-redesign-design.md @@ -0,0 +1,206 @@ +# Mobile UI Redesign — Termius-style (Android) + +**Date:** 2026-06-25 +**Branch:** `feat/android-mobile-app` +**Status:** Approved design + +## Problem + +The Android/mobile build (`app/lib/mobile/`) already has the right *structure* — a +bottom-nav shell with Hosts / Sessions / SFTP / Settings, an accessory key bar, +a snippets sheet, and pinch-to-zoom. But the *presentation* and *ergonomics* fall +short of a polished mobile SSH client: + +- Hosts are plain `ListTile` rows (grey icon + two text lines + chevron). +- Session switching is a bare `ChoiceChip` strip; there is no consolidated + side panel for keys / snippets / history / themes. +- The accessory bar is flat and ungrouped; cursor movement relies on the OS + keyboard's arrow keys only (no terminal touch gestures). +- SFTP / Settings use generic Material widgets with no shared visual language. + +Goal: bring the mobile UI up to a Termius-class look **and** feel — across all four +screens — without rebuilding navigation or touching the connection/provider logic. + +## Reference design language (Termius mobile) + +- Bottom tab bar for single-handed reach (we already have this). +- Host list with a colored avatar (initials), status dot, and tag badges. +- Terminal with a grouped keyboard-accessory bar and a side panel exposing + Extended Keyboard / Snippets / Command History / Themes. +- Touch gestures: long-press + drag to move the cursor, double-tap for Tab, + pinch to zoom. + +## Non-goals (YAGNI) + +- No new navigation paradigm or custom nav bar — keep Material `NavigationBar`. +- No changes to `SshService`, providers, sync, app-lock, or TOFU logic. +- No iPad/foldable/tablet multi-pane layout in this pass (phone layout only). +- No new desktop theming — desktop `buildAppTheme()` is untouched; mobile gets + an additive token layer. +- No RDP on mobile. + +## Approach + +**Refine in place + introduce a mobile design system.** Keep every provider, +service, and navigation wiring. Add a small mobile token/widget layer and reskin +each screen on top of it. This reuses all existing logic, minimizes regression +risk, and still delivers the Termius look and the gesture/side-panel ergonomics. + +Rejected alternatives: +- *Rebuild the mobile shell* (custom nav, hand-drawn widgets): higher regression + risk, diverges from Material, slower — not justified. +- *Theme-only* (colors + typography): cheapest but does not fix the ergonomics + ("không tiện dùng"), so it fails the brief. + +## Architecture + +``` +app/lib/mobile/ +├── theme/ +│ └── mobile_tokens.dart # NEW — spacing, radii, sizes, durations +├── widgets/ # NEW — shared, isolated, unit-testable +│ ├── host_avatar.dart # seeded-color rounded avatar w/ initials +│ ├── status_dot.dart # connection-state dot +│ ├── tag_chip.dart # compact tag badge +│ ├── section_header.dart # uppercase letter-spaced header +│ └── mobile_card.dart # shared card container styling +├── screens/ # reskinned (logic unchanged) +│ ├── mobile_hosts_screen.dart +│ ├── mobile_sessions_screen.dart +│ ├── mobile_sftp_screen.dart +│ ├── mobile_settings_screen.dart +│ └── mobile_add_host_screen.dart +└── terminal/ + ├── accessory_key_bar.dart # regrouped keys + extended-kbd trigger + ├── terminal_side_panel.dart # NEW — bottom-sheet: Keys/Snippets/History/Themes + └── terminal_cursor_gestures.dart # NEW — pure gesture→key mapping +``` + +Shared widgets depend only on `AppColors` (`app/lib/theme/app_theme.dart`) and +`mobile_tokens.dart`. No widget reaches into providers — screens pass data in. + +## Design tokens (`mobile_tokens.dart`) + +A plain Dart class of constants (no Flutter context needed): + +- **Spacing:** `space1=4, space2=8, space3=12, space4=16, space5=24`. +- **Radii:** `radiusCard=14, radiusPill=22, radiusAvatar=12`. +- **Sizes:** `avatar=44, statusDot=8, accessoryBarHeight=48, touchTarget=44`. +- **Durations:** `tap=120ms`, `panel=220ms`. + +Colors continue to come from `AppColors`. Accent stays green `#22C55E` as the +primary; per-host **seed colors** (existing `hostColorSeed(hostname)` in +`app_theme.dart`) drive avatar backgrounds only, so lists feel varied without a +second accent system. + +## Shared widgets + +- **`HostAvatar`** — rounded square (`radiusAvatar`, size `avatar`). Background = + `hostColorSeed(host)` at ~18% opacity; foreground = up to two initials in the + full-strength seed color, weight 600. Falls back to a `dns`/`terminal` glyph + when the label has no letters. Optional small protocol glyph in a corner + (SSH default; future-proofed but RDP is desktop-only). +- **`StatusDot`** — `statusDot`-sized circle. Green (`AppColors.accent`) = + connected, amber (`AppColors.orange`) = connecting, grey + (`AppColors.textTertiary`) = offline. Takes a `SessionStatus`-derived enum so + it has no provider dependency. +- **`TagChip`** — compact pill, `card`+border background, `textSecondary` label, + 11px. Used for host tags and filter chips. +- **`SectionHeader`** — uppercase, letter-spaced (0.8), 11px, `textTertiary`. +- **`MobileCard`** — `AppColors.card` background, `radiusCard`, 1px `border`, + `space3` padding, `InkWell` ripple; the single source of card styling. + +## Screen designs + +### 1. Hosts (`mobile_hosts_screen.dart`) + +- **Search**: pill-shaped field (leading search icon, `radiusPill`), still backed + by `HostQuery.parse()`. +- **Filter chips**: a horizontally-scrolling `TagChip` row of the distinct tags + across saved hosts; tapping one appends/removes a `tag:` term in the query. + Hidden when there are no tags. +- **Host rows**: `HostCard` = `MobileCard` containing `HostAvatar` + label + (15px/600) + `user@host:port` (12px `textSecondary`) + `TagChip`s + `StatusDot` + + trailing chevron. Tap → `connectAny()` then switch to Sessions (unchanged). +- **Swipe / long-press**: `Dismissible`-style trailing swipe reveals Edit + + Delete; long-press opens a menu (Connect / Edit / Duplicate / Delete). Delete + confirms. These call existing `HostProvider` methods. +- **Empty state**: centered icon + "No hosts yet" + a primary "Add host" button + (in addition to the FAB). + +### 2. Sessions / Terminal (`mobile_sessions_screen.dart`) + +- **App bar**: host label + `StatusDot`; overflow menu = Rename / Disconnect / + Open side panel (Themes tab). +- **Session tabs**: pill tabs (replace `ChoiceChip`) — each shows a host-color + dot + label + a close ×; a trailing "+" opens the host picker. Active pill uses + `accent` border. +- **Side panel** (`terminal_side_panel.dart`): a draggable bottom sheet with a + 4-tab segmented control — **Keys** (full extended-keyboard grid), **Snippets** + (reuses the existing snippets sheet content, tap to insert), **History** + (per-host command history from `CommandHistoryProvider`, tap to insert/run), + **Themes** (`TerminalAppearanceControls`, applied per session/host). +- **Accessory bar** (`accessory_key_bar.dart`): regroup into Esc · Tab · arrows · + Ctrl/Alt (armed-state highlight via `accent`) · ^C/^D · symbols, plus a button + that opens the side panel on the Keys tab. One-shot modifier semantics + preserved (`accessory_bar_controller.dart` unchanged). +- **Cursor gestures** (`terminal_cursor_gestures.dart`, pure mapping): + - long-press + drag → arrow keys in the drag direction, with up to 3 + acceleration gears the longer the drag persists; + - double-tap → Tab; + - pinch → zoom (existing behavior retained). + The widget translates gestures into key bytes; the screen feeds them to the + session's input sink. Mapping logic is pure for unit testing. + +### 3. SFTP (`mobile_sftp_screen.dart`) + +- **Breadcrumb path bar** at top (tap a crumb to jump up the tree). +- **File rows**: type-aware leading icon (folder / file / symlink / archive), + name, and a `textSecondary` size · modified-date subtitle, styled via + `MobileCard`-consistent rows. +- **Toolbar**: upload / new-folder / refresh as compact icon buttons. +- **Transfer progress**: a slim progress strip when an upload/download is active, + fed by the existing transfer state. + +### 4. Settings (`mobile_settings_screen.dart`) + +- Group into labeled sections (`SectionHeader`) inside cards: **Terminal** + (appearance), **Sync** (cloud config + P2P QR import), **Security** (app-lock), + **About**. Each row gets a leading icon and consistent spacing. Functionality + (sync, P2P, app-lock, appearance) is unchanged. + +### Add host (`mobile_add_host_screen.dart`) + +Reskin the form to the token system (section headers, consistent field styling, +a clearer primary save button). No field/logic changes. + +## Error handling + +Reskinning only; existing error paths are preserved: +- Connect failures still surface via the Sessions screen status + error message. +- SFTP errors keep their current snackbar/state handling. +- Gesture mapping never throws — unmapped gestures are no-ops; out-of-range + acceleration clamps to gear 1. + +## Testing + +Pure/unit + widget tests (no device required): + +- `host_avatar` — initials extraction, glyph fallback, seeded color determinism. +- `status_dot` — status→color mapping. +- `terminal_cursor_gestures` — drag direction → arrow bytes, acceleration gear + clamping, double-tap → Tab. +- `accessory_key_bar` — grouped layout renders, armed-modifier one-shot behavior. +- `mobile_hosts_screen` — `HostCard` renders label/subtitle/tags/status; tap + triggers connect; swipe reveals edit/delete. +- Existing mobile tests must continue to pass. + +## Implementation order (milestones) + +1. **Design system**: `mobile_tokens.dart` + shared widgets + their tests. +2. **Hosts**: `HostCard`, search/filter, swipe/long-press, empty state. +3. **Terminal ergonomics**: accessory-bar regroup, side panel, cursor gestures. +4. **SFTP**: breadcrumb + file rows + transfer strip. +5. **Settings + Add host**: sectioned reskin. + +Each milestone is independently shippable and leaves the app buildable. From b542aff173740062471c889463a15874db8a5776 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:44:28 +0700 Subject: [PATCH 38/96] docs: mobile Termius-style UI redesign implementation plan --- .../2026-06-25-mobile-termius-redesign.md | 1942 +++++++++++++++++ 1 file changed, 1942 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-mobile-termius-redesign.md diff --git a/docs/superpowers/plans/2026-06-25-mobile-termius-redesign.md b/docs/superpowers/plans/2026-06-25-mobile-termius-redesign.md new file mode 100644 index 00000000..7dc2d753 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-mobile-termius-redesign.md @@ -0,0 +1,1942 @@ +# Mobile UI Redesign (Termius-style) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reskin the four mobile screens (Hosts / Sessions / SFTP / Settings) plus Add-host to a Termius-class look and feel, on a shared mobile design system, without changing any provider/service/navigation logic. + +**Architecture:** Add an additive mobile token + shared-widget layer (`app/lib/mobile/theme/`, `app/lib/mobile/widgets/`), then reskin each screen on top of it. New testable units (initials, status mapping, cursor-drag→key mapping) are pure Dart and unit-tested; screens get widget tests for presence/behavior. + +**Tech Stack:** Flutter (Material 3, dark-only), `provider`, `xterm` (`TerminalKey`/`TerminalView`), existing `AppColors` in `app/lib/theme/app_theme.dart`. + +## Global Constraints + +- Dark theme only; all colors come from `AppColors` (`app/lib/theme/app_theme.dart`). Per-host variety uses the existing `AppColors.hostColor(seed)`. +- Primary accent stays `AppColors.accent` (`#22C55E`); seeded host colors drive avatars/left-bars only. +- Do NOT modify `SshService`, providers, sync, app-lock, or TOFU logic. Reskin only. +- Phone layout only — no tablet/foldable multi-pane, no iPad sidebar. +- No RDP/VNC on mobile (mobile is SSH-only today; keep it that way). +- Run `cd app && flutter analyze` clean and `cd app && flutter test` green before each commit. +- No Claude attribution anywhere in commits/code/docs. +- Commit messages in English; conventional-commit style matching the repo (`feat(mobile): …`, `refactor(mobile): …`, `test(mobile): …`). + +--- + +## File Structure + +**New:** +- `app/lib/mobile/theme/mobile_tokens.dart` — spacing/radii/size constants. +- `app/lib/mobile/widgets/host_avatar.dart` — `HostAvatar` + pure `hostInitials()`. +- `app/lib/mobile/widgets/status_dot.dart` — `StatusDot` + `HostConnState` + `statusColor()`. +- `app/lib/mobile/widgets/tag_chip.dart` — `TagChip`. +- `app/lib/mobile/widgets/section_header.dart` — `SectionHeader`. +- `app/lib/mobile/widgets/mobile_card.dart` — `MobileCard`. +- `app/lib/mobile/widgets/host_card.dart` — `HostCard` (composes the above; used by Hosts). +- `app/lib/mobile/terminal/terminal_cursor_gestures.dart` — pure `CursorDragMapper`. +- `app/lib/mobile/terminal/terminal_side_panel.dart` — `TerminalSidePanel` bottom sheet (Keys/Snippets/History/Themes). +- Tests under `app/test/mobile/…` mirroring the above. + +**Modified (reskin only):** +- `app/lib/mobile/screens/mobile_hosts_screen.dart` +- `app/lib/mobile/screens/mobile_sessions_screen.dart` +- `app/lib/mobile/terminal/accessory_key_bar.dart` +- `app/lib/mobile/screens/mobile_sftp_screen.dart` +- `app/lib/mobile/screens/mobile_settings_screen.dart` +- `app/lib/mobile/screens/mobile_add_host_screen.dart` + +--- + +## Milestone 1 — Design system foundation + +### Task 1: Mobile tokens + +**Files:** +- Create: `app/lib/mobile/theme/mobile_tokens.dart` + +**Interfaces:** +- Produces: `class MobileTokens` with static `double` consts: `space1=4, space2=8, space3=12, space4=16, space5=24, radiusCard=14, radiusPill=22, radiusAvatar=12, avatar=44, statusDot=8, accessoryBarHeight=48, touchTarget=44`. + +- [ ] **Step 1: Create the tokens file** + +```dart +// app/lib/mobile/theme/mobile_tokens.dart +/// Layout constants for the mobile UI. Colors live in [AppColors]; this is +/// purely spacing/sizing so widgets share one rhythm. +class MobileTokens { + MobileTokens._(); + + static const double space1 = 4; + static const double space2 = 8; + static const double space3 = 12; + static const double space4 = 16; + static const double space5 = 24; + + static const double radiusCard = 14; + static const double radiusPill = 22; + static const double radiusAvatar = 12; + + static const double avatar = 44; + static const double statusDot = 8; + static const double accessoryBarHeight = 48; + static const double touchTarget = 44; +} +``` + +- [ ] **Step 2: Analyze** + +Run: `cd app && flutter analyze lib/mobile/theme/mobile_tokens.dart` +Expected: "No issues found!" + +- [ ] **Step 3: Commit** + +```bash +git add app/lib/mobile/theme/mobile_tokens.dart +git commit -m "feat(mobile): add shared layout tokens" +``` + +### Task 2: HostAvatar + initials + +**Files:** +- Create: `app/lib/mobile/widgets/host_avatar.dart` +- Test: `app/test/mobile/host_avatar_test.dart` + +**Interfaces:** +- Produces: `String hostInitials(String label)` — up to 2 uppercase letters; `class HostAvatar extends StatelessWidget` with `HostAvatar({required String label, required String seed, double size})`. +- Consumes: `MobileTokens`, `AppColors`. + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/mobile/host_avatar_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/widgets/host_avatar.dart'; + +void main() { + group('hostInitials', () { + test('two words → first letter of each, uppercased', () { + expect(hostInitials('production web'), 'PW'); + }); + test('single word → first two letters', () { + expect(hostInitials('staging'), 'ST'); + }); + test('single letter → one letter', () { + expect(hostInitials('x'), 'X'); + }); + test('blank → empty', () { + expect(hostInitials(' '), ''); + }); + test('skips non-letter leading tokens', () { + expect(hostInitials('10.0.0.5'), '1'); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/host_avatar_test.dart` +Expected: FAIL — `host_avatar.dart` does not exist / `hostInitials` undefined. + +- [ ] **Step 3: Implement** + +```dart +// app/lib/mobile/widgets/host_avatar.dart +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Up to two uppercase initials from a host label. Words are whitespace-split; +/// two-plus words use the first letter of the first two, a single word uses its +/// first two characters. Returns '' for blank input. +String hostInitials(String label) { + final words = label.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + if (words.isEmpty) return ''; + if (words.length == 1) { + final w = words.first; + return (w.length == 1 ? w : w.substring(0, 2)).toUpperCase(); + } + return (words[0][0] + words[1][0]).toUpperCase(); +} + +/// Rounded-square avatar: seeded background tint + initials in the seed color. +/// Falls back to a terminal glyph when the label yields no initials. +class HostAvatar extends StatelessWidget { + final String label; + final String seed; + final double size; + + const HostAvatar({ + super.key, + required this.label, + required this.seed, + this.size = MobileTokens.avatar, + }); + + @override + Widget build(BuildContext context) { + final color = AppColors.hostColor(seed); + final initials = hostInitials(label); + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), + ), + child: initials.isEmpty + ? Icon(Icons.dns_outlined, color: color, size: size * 0.5) + : Text( + initials, + style: TextStyle( + color: color, + fontSize: size * 0.36, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && flutter test test/mobile/host_avatar_test.dart` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/widgets/host_avatar.dart app/test/mobile/host_avatar_test.dart +git commit -m "feat(mobile): add HostAvatar with seeded initials" +``` + +### Task 3: StatusDot + state mapping + +**Files:** +- Create: `app/lib/mobile/widgets/status_dot.dart` +- Test: `app/test/mobile/status_dot_test.dart` + +**Interfaces:** +- Produces: `enum HostConnState { connected, connecting, offline }`; `Color statusColor(HostConnState s)`; `class StatusDot extends StatelessWidget` with `StatusDot({required HostConnState state, double size})`. + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/mobile/status_dot_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/theme/app_theme.dart'; +import 'package:yourssh/mobile/widgets/status_dot.dart'; + +void main() { + test('statusColor maps each state', () { + expect(statusColor(HostConnState.connected), AppColors.accent); + expect(statusColor(HostConnState.connecting), AppColors.orange); + expect(statusColor(HostConnState.offline), AppColors.textTertiary); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/status_dot_test.dart` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement** + +```dart +// app/lib/mobile/widgets/status_dot.dart +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Coarse connection state for a host row / session, decoupled from +/// [SessionStatus] so this widget stays provider-free. +enum HostConnState { connected, connecting, offline } + +Color statusColor(HostConnState s) => switch (s) { + HostConnState.connected => AppColors.accent, + HostConnState.connecting => AppColors.orange, + HostConnState.offline => AppColors.textTertiary, + }; + +class StatusDot extends StatelessWidget { + final HostConnState state; + final double size; + + const StatusDot({ + super.key, + required this.state, + this.size = MobileTokens.statusDot, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: statusColor(state), + shape: BoxShape.circle, + ), + ); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && flutter test test/mobile/status_dot_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/widgets/status_dot.dart app/test/mobile/status_dot_test.dart +git commit -m "feat(mobile): add StatusDot + connection-state mapping" +``` + +### Task 4: TagChip, SectionHeader, MobileCard + +**Files:** +- Create: `app/lib/mobile/widgets/tag_chip.dart`, `app/lib/mobile/widgets/section_header.dart`, `app/lib/mobile/widgets/mobile_card.dart` +- Test: `app/test/mobile/shared_widgets_test.dart` + +**Interfaces:** +- Produces: `class TagChip extends StatelessWidget` (`TagChip({required String label, bool selected, VoidCallback? onTap})`); `class SectionHeader extends StatelessWidget` (`SectionHeader(this.title)`); `class MobileCard extends StatelessWidget` (`MobileCard({required Widget child, VoidCallback? onTap, EdgeInsets? padding})`). + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/mobile/shared_widgets_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/widgets/tag_chip.dart'; +import 'package:yourssh/mobile/widgets/section_header.dart'; +import 'package:yourssh/mobile/widgets/mobile_card.dart'; + +void main() { + testWidgets('TagChip renders label', (t) async { + await t.pumpWidget(const MaterialApp(home: Scaffold(body: TagChip(label: 'prod')))); + expect(find.text('prod'), findsOneWidget); + }); + + testWidgets('SectionHeader uppercases the title', (t) async { + await t.pumpWidget(const MaterialApp(home: Scaffold(body: SectionHeader('security')))); + expect(find.text('SECURITY'), findsOneWidget); + }); + + testWidgets('MobileCard fires onTap', (t) async { + var tapped = false; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: MobileCard(onTap: () => tapped = true, child: const Text('x')), + ), + )); + await t.tap(find.text('x')); + expect(tapped, isTrue); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/shared_widgets_test.dart` +Expected: FAIL — undefined widgets. + +- [ ] **Step 3: Implement the three widgets** + +```dart +// app/lib/mobile/widgets/tag_chip.dart +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Compact pill for a host tag or a filter chip. [selected] tints it with the +/// accent; tappable when [onTap] is provided. +class TagChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback? onTap; + + const TagChip({super.key, required this.label, this.selected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + final fg = selected ? Colors.black : AppColors.textSecondary; + final bg = selected ? AppColors.accent : AppColors.bg; + final chip = Container( + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all(color: AppColors.border), + ), + child: Text(label, style: TextStyle(color: fg, fontSize: 11)), + ); + if (onTap == null) return chip; + return InkWell( + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + onTap: onTap, + child: chip, + ); + } +} +``` + +```dart +// app/lib/mobile/widgets/section_header.dart +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Uppercase, letter-spaced section label for grouped lists/settings. +class SectionHeader extends StatelessWidget { + final String title; + const SectionHeader(this.title, {super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + left: MobileTokens.space1, + bottom: MobileTokens.space2, + top: MobileTokens.space2, + ), + child: Text( + title.toUpperCase(), + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } +} +``` + +```dart +// app/lib/mobile/widgets/mobile_card.dart +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// The single source of card styling for the mobile UI: card surface, rounded +/// border, ripple. Used by host rows, SFTP rows, settings groups. +class MobileCard extends StatelessWidget { + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final EdgeInsetsGeometry? padding; + + const MobileCard({ + super.key, + required this.child, + this.onTap, + this.onLongPress, + this.padding, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(MobileTokens.radiusCard), + border: Border.all(color: AppColors.border), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: padding ?? const EdgeInsets.all(MobileTokens.space3), + child: child, + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && flutter test test/mobile/shared_widgets_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/widgets/tag_chip.dart app/lib/mobile/widgets/section_header.dart app/lib/mobile/widgets/mobile_card.dart app/test/mobile/shared_widgets_test.dart +git commit -m "feat(mobile): add TagChip, SectionHeader, MobileCard" +``` + +--- + +## Milestone 2 — Hosts screen + +### Task 5: HostCard widget + +**Files:** +- Create: `app/lib/mobile/widgets/host_card.dart` +- Test: `app/test/mobile/host_card_test.dart` + +**Interfaces:** +- Consumes: `HostAvatar`, `StatusDot`/`HostConnState`, `TagChip`, `MobileCard`, `MobileTokens`, `AppColors`, `Host`. +- Produces: `class HostCard extends StatelessWidget` with `HostCard({required Host host, required HostConnState state, required VoidCallback onTap, VoidCallback? onLongPress})`. + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/mobile/host_card_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/mobile/widgets/host_card.dart'; +import 'package:yourssh/mobile/widgets/status_dot.dart'; + +void main() { + testWidgets('renders label, target, tags, and fires onTap', (t) async { + final host = Host( + label: 'Production Web', + host: '10.0.0.5', + port: 22, + username: 'root', + tags: const ['prod', 'nginx'], + ); + var tapped = false; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: HostCard( + host: host, + state: HostConnState.connected, + onTap: () => tapped = true, + ), + ), + )); + expect(find.text('Production Web'), findsOneWidget); + expect(find.text('root@10.0.0.5:22'), findsOneWidget); + expect(find.text('prod'), findsOneWidget); + expect(find.text('nginx'), findsOneWidget); + await t.tap(find.text('Production Web')); + expect(tapped, isTrue); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/host_card_test.dart` +Expected: FAIL — `host_card.dart` missing. + +- [ ] **Step 3: Implement** + +```dart +// app/lib/mobile/widgets/host_card.dart +import 'package:flutter/material.dart'; + +import '../../models/host.dart'; +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; +import 'host_avatar.dart'; +import 'mobile_card.dart'; +import 'status_dot.dart'; +import 'tag_chip.dart'; + +/// Termius-style host row: seeded avatar + label + user@host:port + tag chips, +/// with a connection status dot and a trailing chevron. +class HostCard extends StatelessWidget { + final Host host; + final HostConnState state; + final VoidCallback onTap; + final VoidCallback? onLongPress; + + const HostCard({ + super.key, + required this.host, + required this.state, + required this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space3, + vertical: MobileTokens.space1 + 2, + ), + child: MobileCard( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + children: [ + HostAvatar(label: host.label, seed: host.host), + const SizedBox(width: MobileTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + host.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + StatusDot(state: state), + ], + ), + const SizedBox(height: 2), + Text( + '${host.username}@${host.host}:${host.port}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + if (host.tags.isNotEmpty) ...[ + const SizedBox(height: MobileTokens.space2), + Wrap( + spacing: MobileTokens.space1 + 2, + runSpacing: MobileTokens.space1, + children: [for (final tag in host.tags) TagChip(label: tag)], + ), + ], + ], + ), + ), + const Icon(Icons.chevron_right, color: AppColors.textTertiary), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && flutter test test/mobile/host_card_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/widgets/host_card.dart app/test/mobile/host_card_test.dart +git commit -m "feat(mobile): add HostCard row" +``` + +### Task 6: Reskin Hosts screen (cards + filter chips + long-press delete + empty state) + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_hosts_screen.dart` (full rewrite of the build + helpers; keep `MobileHostsScreen` constructor/`onConnected`/`onAddHost` signature and `_connect`) +- Test: `app/test/mobile/mobile_hosts_screen_test.dart` + +**Interfaces:** +- Consumes: `HostProvider.allHosts` / `HostProvider.deleteHost(id)`, `SessionProvider.connectAny(host)` / `SessionProvider.sshSessions`, `HostQuery.parse`, `HostCard`, `HostConnState`, `TagChip`, `MobileTokens`. + +- [ ] **Step 1: Write the failing widget test** + +```dart +// app/test/mobile/mobile_hosts_screen_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/mobile/screens/mobile_hosts_screen.dart'; +import 'package:yourssh/mobile/widgets/host_card.dart'; +import 'package:yourssh/providers/host_provider.dart'; + +// Minimal fake exposing just what the screen reads. +class _FakeHostProvider extends HostProvider { + _FakeHostProvider(this._hosts); + final List _hosts; + @override + List get allHosts => _hosts; +} + +void main() { + Host h(String label, String addr) => + Host(label: label, host: addr, username: 'root'); + + testWidgets('empty state shows add CTA', (t) async { + await t.pumpWidget(_wrap(_FakeHostProvider([]))); + expect(find.textContaining('No hosts'), findsOneWidget); + }); + + testWidgets('renders a HostCard per host', (t) async { + await t.pumpWidget(_wrap(_FakeHostProvider([h('Alpha', '1.1.1.1'), h('Beta', '2.2.2.2')]))); + expect(find.byType(HostCard), findsNWidgets(2)); + }); +} + +Widget _wrap(HostProvider hp) { + return MultiProvider( + providers: [ChangeNotifierProvider.value(value: hp)], + child: MaterialApp( + home: MobileHostsScreen(onConnected: () {}, onAddHost: () {}), + ), + ); +} +``` + +> NOTE: If `HostProvider`'s constructor requires args, adapt `_FakeHostProvider` to satisfy them (pass through to `super`). If `SessionProvider` is read during build for status, add a `ChangeNotifierProvider` with an empty fake to `_wrap`. Verify by reading `host_provider.dart` / `session_provider.dart` constructors before writing the fake. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/mobile_hosts_screen_test.dart` +Expected: FAIL — still rendering `ListTile`/`_HostRow` (no `HostCard`). + +- [ ] **Step 3: Rewrite the screen** + +Replace the entire body of `app/lib/mobile/screens/mobile_hosts_screen.dart` with: + +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../models/ssh_session.dart'; +import '../../providers/host_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../util/host_query.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/host_card.dart'; +import '../widgets/status_dot.dart'; +import '../widgets/tag_chip.dart'; + +/// Hosts tab: searchable list of saved hosts as Termius-style cards; tap to +/// connect, long-press to delete, FAB to add. Tag chips filter the list. +class MobileHostsScreen extends StatefulWidget { + final VoidCallback onConnected; + final VoidCallback onAddHost; + + const MobileHostsScreen({ + super.key, + required this.onConnected, + required this.onAddHost, + }); + + @override + State createState() => _MobileHostsScreenState(); +} + +class _MobileHostsScreenState extends State { + String _query = ''; + String? _tagFilter; + + /// Coarse connection state for a host, derived from any live session on it. + HostConnState _stateFor(Host host, List sessions) { + final mine = sessions.where((s) => s.host.id == host.id); + if (mine.any((s) => s.status == SessionStatus.connected)) { + return HostConnState.connected; + } + if (mine.any((s) => s.status == SessionStatus.connecting)) { + return HostConnState.connecting; + } + return HostConnState.offline; + } + + @override + Widget build(BuildContext context) { + final all = context.watch().allHosts; + final sessions = context.watch().sshSessions; + + final tags = {for (final h in all) ...h.tags}.toList()..sort(); + + var hosts = all; + final q = _query.trim(); + if (q.isNotEmpty) { + hosts = hosts.where(HostQuery.parse(q).matches).toList(); + } + if (_tagFilter != null) { + hosts = hosts.where((h) => h.tags.contains(_tagFilter)).toList(); + } + + return Scaffold( + backgroundColor: AppColors.bg, + floatingActionButton: FloatingActionButton( + onPressed: widget.onAddHost, + backgroundColor: AppColors.accent, + child: const Icon(Icons.add, color: Colors.black), + ), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space3, + MobileTokens.space3, + MobileTokens.space3, + MobileTokens.space2, + ), + child: TextField( + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Search hosts', + prefixIcon: const Icon(Icons.search, color: AppColors.textSecondary), + filled: true, + fillColor: AppColors.card, + contentPadding: EdgeInsets.zero, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + borderSide: BorderSide.none, + ), + ), + ), + ), + if (tags.isNotEmpty) + SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space3), + children: [ + Padding( + padding: const EdgeInsets.only(right: MobileTokens.space2), + child: TagChip( + label: 'All', + selected: _tagFilter == null, + onTap: () => setState(() => _tagFilter = null), + ), + ), + for (final tag in tags) + Padding( + padding: const EdgeInsets.only(right: MobileTokens.space2), + child: TagChip( + label: tag, + selected: _tagFilter == tag, + onTap: () => setState( + () => _tagFilter = _tagFilter == tag ? null : tag), + ), + ), + ], + ), + ), + Expanded( + child: hosts.isEmpty + ? _EmptyState(onAddHost: widget.onAddHost) + : ListView.builder( + padding: const EdgeInsets.only( + top: MobileTokens.space2, bottom: 80), + itemCount: hosts.length, + itemBuilder: (_, i) => HostCard( + host: hosts[i], + state: _stateFor(hosts[i], sessions), + onTap: () => _connect(hosts[i]), + onLongPress: () => _confirmDelete(hosts[i]), + ), + ), + ), + ], + ), + ), + ); + } + + void _connect(Host host) { + context.read().connectAny(host); + widget.onConnected(); + } + + Future _confirmDelete(Host host) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.card, + title: Text('Delete ${host.label}?', + style: const TextStyle(color: AppColors.textPrimary)), + content: const Text('This removes the saved host and its credentials.', + style: TextStyle(color: AppColors.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: AppColors.red)), + ), + ], + ), + ); + if (ok == true && mounted) { + await context.read().deleteHost(host.id); + } + } +} + +class _EmptyState extends StatelessWidget { + final VoidCallback onAddHost; + const _EmptyState({required this.onAddHost}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.dns_outlined, size: 48, color: AppColors.textTertiary), + const SizedBox(height: MobileTokens.space3), + const Text('No hosts yet', + style: TextStyle(color: AppColors.textPrimary, fontSize: 16)), + const SizedBox(height: MobileTokens.space1), + const Text('Add a server to get started', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: MobileTokens.space4), + FilledButton.icon( + onPressed: onAddHost, + icon: const Icon(Icons.add), + label: const Text('Add host'), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 4: Run the screen test + full mobile suite** + +Run: `cd app && flutter test test/mobile/` +Expected: PASS. Then `cd app && flutter analyze` → "No issues found!". + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/screens/mobile_hosts_screen.dart app/test/mobile/mobile_hosts_screen_test.dart +git commit -m "feat(mobile): Termius-style hosts list with cards, tag filter, delete" +``` + +--- + +## Milestone 3 — Terminal ergonomics + +### Task 7: Cursor-drag → arrow-key mapper (pure) + +**Files:** +- Create: `app/lib/mobile/terminal/terminal_cursor_gestures.dart` +- Test: `app/test/mobile/terminal_cursor_gestures_test.dart` + +**Interfaces:** +- Produces: `class CursorDragMapper` with `CursorDragMapper({double baseStep = 22})`, `List addDelta(double dx, double dy)`, `void reset()`. + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/mobile/terminal_cursor_gestures_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh/mobile/terminal/terminal_cursor_gestures.dart'; + +void main() { + test('horizontal drag emits arrowRight per step', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(20, 0), [TerminalKey.arrowRight]); + expect(m.addDelta(20, 0), [TerminalKey.arrowRight]); + }); + + test('negative vertical drag emits arrowUp', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(0, -20), [TerminalKey.arrowUp]); + }); + + test('sub-step deltas accumulate then fire once', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(0, 12), isEmpty); + expect(m.addDelta(0, 12), [TerminalKey.arrowDown]); + }); + + test('dominant axis wins (no diagonal double-fire)', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(30, 5), [TerminalKey.arrowRight]); + }); + + test('reset clears accumulators and gear', () { + final m = CursorDragMapper(baseStep: 20); + m.addDelta(100, 0); + m.reset(); + expect(m.addDelta(0, 19), isEmpty); // back to gear 1, sub-step + }); + + test('acceleration: step shrinks after sustained drag', () { + final m = CursorDragMapper(baseStep: 20); + // First burst pushes emitted count up into a higher gear. + m.addDelta(200, 0); // many arrowRight + // After gear-up, a 12px delta should now be enough to fire. + final keys = m.addDelta(12, 0); + expect(keys, isNotEmpty); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/terminal_cursor_gestures_test.dart` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement** + +```dart +// app/lib/mobile/terminal/terminal_cursor_gestures.dart +import 'package:xterm/xterm.dart'; + +/// Translates a long-press drag into a stream of arrow-key presses, the +/// touch way to move the terminal cursor. Distance is accumulated; every +/// `step` pixels in the dominant axis emits one arrow key. The step shrinks +/// across three gears the longer a single drag persists, so a slow nudge moves +/// one cell while a long sweep flies. Pure + deterministic for unit testing; +/// call [reset] when the drag ends. +class CursorDragMapper { + final double baseStep; + CursorDragMapper({this.baseStep = 22}); + + double _accX = 0; + double _accY = 0; + int _emitted = 0; + + double get _step { + if (_emitted >= 12) return baseStep * 0.4; // gear 3 + if (_emitted >= 5) return baseStep * 0.65; // gear 2 + return baseStep; // gear 1 + } + + List addDelta(double dx, double dy) { + _accX += dx; + _accY += dy; + final keys = []; + while (true) { + final step = _step; + final xReady = _accX.abs() >= step; + final yReady = _accY.abs() >= step; + if (!xReady && !yReady) break; + final useX = xReady && (!yReady || _accX.abs() >= _accY.abs()); + if (useX) { + keys.add(_accX > 0 ? TerminalKey.arrowRight : TerminalKey.arrowLeft); + _accX += _accX > 0 ? -step : step; + } else { + keys.add(_accY > 0 ? TerminalKey.arrowDown : TerminalKey.arrowUp); + _accY += _accY > 0 ? -step : step; + } + _emitted++; + } + return keys; + } + + void reset() { + _accX = 0; + _accY = 0; + _emitted = 0; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && flutter test test/mobile/terminal_cursor_gestures_test.dart` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/terminal/terminal_cursor_gestures.dart app/test/mobile/terminal_cursor_gestures_test.dart +git commit -m "feat(mobile): cursor-drag to arrow-key mapper" +``` + +### Task 8: Regroup the accessory key bar + side-panel trigger + +**Files:** +- Modify: `app/lib/mobile/terminal/accessory_key_bar.dart` +- Test: `app/test/mobile/accessory_key_bar_test.dart` + +**Interfaces:** +- Produces (new optional param): `AccessoryKeyBar({..., VoidCallback? onOpenPanel})` — adds a leading keyboard button that calls `onOpenPanel` when non-null. Existing `controller`/`onKey`/`onText` unchanged. + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/mobile/accessory_key_bar_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh/mobile/terminal/accessory_bar_controller.dart'; +import 'package:yourssh/mobile/terminal/accessory_key_bar.dart'; + +void main() { + testWidgets('Esc/Tab/Ctrl render and Ctrl arms one-shot', (t) async { + final c = AccessoryBarController(); + final keys = <(TerminalKey, bool)>[]; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: AccessoryKeyBar( + controller: c, + onKey: (k, {ctrl = false, alt = false}) => keys.add((k, ctrl)), + onText: (_) {}, + ), + ), + )); + expect(find.text('Esc'), findsOneWidget); + expect(find.text('Tab'), findsOneWidget); + await t.tap(find.text('Ctrl')); + await t.pump(); + expect(c.ctrlArmed, isTrue); + await t.tap(find.text('Esc')); + expect(keys.single, (TerminalKey.escape, true)); + expect(c.ctrlArmed, isFalse); // consumed + }); + + testWidgets('onOpenPanel button fires when provided', (t) async { + var opened = false; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: AccessoryKeyBar( + controller: AccessoryBarController(), + onKey: (k, {ctrl = false, alt = false}) {}, + onText: (_) {}, + onOpenPanel: () => opened = true, + ), + ), + )); + await t.tap(find.byIcon(Icons.keyboard_outlined)); + expect(opened, isTrue); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/accessory_key_bar_test.dart` +Expected: FAIL — `onOpenPanel` param missing. + +- [ ] **Step 3: Modify the bar** + +In `accessory_key_bar.dart`: add the field + a leading button. Add to the constructor and fields: + +```dart + final void Function(String text) onText; + /// Opens the terminal side panel (extended keyboard / snippets / history / + /// themes). When null the leading keyboard button is hidden. + final VoidCallback? onOpenPanel; + + const AccessoryKeyBar({ + super.key, + required this.controller, + required this.onKey, + required this.onText, + this.onOpenPanel, + }); +``` + +Update the container height to the token and prepend the panel button + a divider before `_btn('Esc', …)`: + +```dart + return Container( + height: MobileTokens.accessoryBarHeight, + color: AppColors.card, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + children: [ + if (onOpenPanel != null) + _icon(Icons.keyboard_outlined, 'Keyboard panel', onOpenPanel!), + _btn('Esc', onTap: () => _key(TerminalKey.escape)), + _btn('Tab', onTap: () => _key(TerminalKey.tab)), + _btn('Ctrl', armed: controller.ctrlArmed, onTap: controller.armCtrl), + _btn('Alt', armed: controller.altArmed, onTap: controller.armAlt), + _btn('^C', onTap: () => _ctrlKey(TerminalKey.keyC)), + _btn('^D', onTap: () => _ctrlKey(TerminalKey.keyD)), + _icon(Icons.keyboard_arrow_left, 'Left', () => _key(TerminalKey.arrowLeft)), + _icon(Icons.keyboard_arrow_up, 'Up', () => _key(TerminalKey.arrowUp)), + _icon(Icons.keyboard_arrow_down, 'Down', () => _key(TerminalKey.arrowDown)), + _icon(Icons.keyboard_arrow_right, 'Right', () => _key(TerminalKey.arrowRight)), + for (final ch in const ['/', '-', '|', '~', ':', '\$']) + _btn(ch, onTap: () => _text(ch)), + ], + ), + ); +``` + +Add the import at the top: `import '../theme/mobile_tokens.dart';` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && flutter test test/mobile/accessory_key_bar_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/terminal/accessory_key_bar.dart app/test/mobile/accessory_key_bar_test.dart +git commit -m "feat(mobile): accessory bar gains a side-panel button + \$ key" +``` + +### Task 9: Terminal side panel (Keys / Snippets / History / Themes) + +**Files:** +- Create: `app/lib/mobile/terminal/terminal_side_panel.dart` +- Test: `app/test/mobile/terminal_side_panel_test.dart` + +**Interfaces:** +- Consumes: `SnippetProvider` (via existing `MobileSnippetsSheet` content pattern), `CommandHistoryProvider.historyFor(sessionId).entries`, `TerminalAppearanceControls`, `AppColors`, `MobileTokens`. +- Produces: `Future showTerminalSidePanel(BuildContext context, {required String sessionId, required void Function(String) onInsert, required void Function(TerminalKey, {bool ctrl, bool alt}) onKey, int initialTab = 0})` — opens a `showModalBottomSheet` with a 4-tab segmented body. + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/mobile/terminal_side_panel_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh/providers/command_history_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; +import 'package:yourssh/mobile/terminal/terminal_side_panel.dart'; + +void main() { + testWidgets('shows the four tabs', (t) async { + await t.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => CommandHistoryProvider()), + ChangeNotifierProvider(create: (_) => SnippetProvider()), + ChangeNotifierProvider(create: (_) => SettingsProvider()), + ], + child: MaterialApp( + home: Builder( + builder: (ctx) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showTerminalSidePanel( + ctx, + sessionId: 's1', + onInsert: (_) {}, + onKey: (k, {ctrl = false, alt = false}) {}, + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text('Keys'), findsOneWidget); + expect(find.text('Snippets'), findsOneWidget); + expect(find.text('History'), findsOneWidget); + expect(find.text('Themes'), findsOneWidget); + }); +} +``` + +> NOTE: Adjust provider constructors to match the real ones (read `command_history_provider.dart`, `snippet_provider.dart`, `settings_provider.dart` before writing — some may need a service/prefs arg or a no-arg test constructor). If a provider can't be cheaply constructed in a test, inject the data the panel needs via parameters instead of reading it from a provider, and simplify this test accordingly. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && flutter test test/mobile/terminal_side_panel_test.dart` +Expected: FAIL — `terminal_side_panel.dart` missing. + +- [ ] **Step 3: Implement the panel** + +```dart +// app/lib/mobile/terminal/terminal_side_panel.dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; + +import '../../providers/command_history_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/terminal_appearance_controls.dart'; +import '../theme/mobile_tokens.dart'; + +/// Termius-style terminal side panel as a bottom sheet: Keys (extended +/// keyboard), Snippets, Command History, and Themes. Insertions/keys are +/// routed back to the active session via the callbacks. +Future showTerminalSidePanel( + BuildContext context, { + required String sessionId, + required void Function(String text) onInsert, + required void Function(TerminalKey key, {bool ctrl, bool alt}) onKey, + int initialTab = 0, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(MobileTokens.radiusCard)), + ), + builder: (_) => FractionallySizedBox( + heightFactor: 0.6, + child: _SidePanel( + sessionId: sessionId, + onInsert: onInsert, + onKey: onKey, + initialTab: initialTab, + ), + ), + ); +} + +class _SidePanel extends StatefulWidget { + final String sessionId; + final void Function(String text) onInsert; + final void Function(TerminalKey key, {bool ctrl, bool alt}) onKey; + final int initialTab; + + const _SidePanel({ + required this.sessionId, + required this.onInsert, + required this.onKey, + required this.initialTab, + }); + + @override + State<_SidePanel> createState() => _SidePanelState(); +} + +class _SidePanelState extends State<_SidePanel> with SingleTickerProviderStateMixin { + late final TabController _tabs = + TabController(length: 4, vsync: this, initialIndex: widget.initialTab); + + @override + void dispose() { + _tabs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + TabBar( + controller: _tabs, + labelColor: AppColors.accent, + unselectedLabelColor: AppColors.textSecondary, + indicatorColor: AppColors.accent, + tabs: const [ + Tab(text: 'Keys'), + Tab(text: 'Snippets'), + Tab(text: 'History'), + Tab(text: 'Themes'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabs, + children: [ + _KeysGrid(onKey: widget.onKey, onClose: () => Navigator.pop(context)), + _SnippetsTab(onInsert: (c) { + widget.onInsert(c); + Navigator.pop(context); + }), + _HistoryTab(sessionId: widget.sessionId, onInsert: (c) { + widget.onInsert(c); + Navigator.pop(context); + }), + const SingleChildScrollView( + padding: EdgeInsets.all(MobileTokens.space4), + child: TerminalAppearanceControls(layout: AppearanceControlsLayout.rows), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _KeysGrid extends StatelessWidget { + final void Function(TerminalKey key, {bool ctrl, bool alt}) onKey; + final VoidCallback onClose; + const _KeysGrid({required this.onKey, required this.onClose}); + + static const _keys = <(String, TerminalKey)>[ + ('Esc', TerminalKey.escape), + ('Tab', TerminalKey.tab), + ('Home', TerminalKey.home), + ('End', TerminalKey.end), + ('PgUp', TerminalKey.pageUp), + ('PgDn', TerminalKey.pageDown), + ('↑', TerminalKey.arrowUp), + ('↓', TerminalKey.arrowDown), + ('←', TerminalKey.arrowLeft), + ('→', TerminalKey.arrowRight), + ('F1', TerminalKey.f1), + ('F2', TerminalKey.f2), + ('F3', TerminalKey.f3), + ('F4', TerminalKey.f4), + ('F5', TerminalKey.f5), + ('F6', TerminalKey.f6), + ('F7', TerminalKey.f7), + ('F8', TerminalKey.f8), + ('F9', TerminalKey.f9), + ('F10', TerminalKey.f10), + ]; + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 5, + padding: const EdgeInsets.all(MobileTokens.space3), + mainAxisSpacing: MobileTokens.space2, + crossAxisSpacing: MobileTokens.space2, + childAspectRatio: 1.8, + children: [ + for (final (label, key) in _keys) + InkWell( + borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), + onTap: () => onKey(key), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), + border: Border.all(color: AppColors.border), + ), + child: Text(label, style: const TextStyle(color: AppColors.textPrimary)), + ), + ), + ], + ); + } +} + +class _SnippetsTab extends StatefulWidget { + final void Function(String command) onInsert; + const _SnippetsTab({required this.onInsert}); + + @override + State<_SnippetsTab> createState() => _SnippetsTabState(); +} + +class _SnippetsTabState extends State<_SnippetsTab> { + String _query = ''; + + @override + Widget build(BuildContext context) { + final all = context.watch().snippets; + final shown = filterSnippets(all, _query); + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(MobileTokens.space3), + child: TextField( + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Search snippets', + prefixIcon: Icon(Icons.search, color: AppColors.textSecondary), + ), + ), + ), + Expanded( + child: shown.isEmpty + ? const Center( + child: Text('No snippets', + style: TextStyle(color: AppColors.textSecondary))) + : ListView.builder( + itemCount: shown.length, + itemBuilder: (_, i) { + final s = shown[i]; + return ListTile( + title: Text(s.label, + style: const TextStyle(color: AppColors.textPrimary)), + subtitle: Text(s.command, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontFamily: 'monospace')), + onTap: () => widget.onInsert(s.command), + ); + }, + ), + ), + ], + ); + } +} + +class _HistoryTab extends StatelessWidget { + final String sessionId; + final void Function(String command) onInsert; + const _HistoryTab({required this.sessionId, required this.onInsert}); + + @override + Widget build(BuildContext context) { + final entries = + context.watch().historyFor(sessionId).entries; + if (entries.isEmpty) { + return const Center( + child: Text('No command history yet', + style: TextStyle(color: AppColors.textSecondary))); + } + return ListView.builder( + itemCount: entries.length, + itemBuilder: (_, i) { + final cmd = entries[entries.length - 1 - i]; // newest first + return ListTile( + title: Text(cmd, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, fontFamily: 'monospace', fontSize: 13)), + onTap: () => onInsert(cmd), + ); + }, + ); + } +} +``` + +> NOTE: Before running, confirm each `TerminalKey` value used (`home`, `end`, `pageUp`, `pageDown`, `f1`…`f10`) exists in the `xterm` fork's `TerminalKey` enum. If any name differs, drop or rename it — `flutter analyze` will flag unknown members. + +- [ ] **Step 4: Run tests + analyze** + +Run: `cd app && flutter test test/mobile/terminal_side_panel_test.dart && flutter analyze` +Expected: PASS + "No issues found!". + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/mobile/terminal/terminal_side_panel.dart app/test/mobile/terminal_side_panel_test.dart +git commit -m "feat(mobile): terminal side panel (keys/snippets/history/themes)" +``` + +### Task 10: Wire side panel + cursor gestures + pill tabs into the Sessions screen + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_sessions_screen.dart` + +**Interfaces:** +- Consumes: `showTerminalSidePanel(...)`, `CursorDragMapper`, `AccessoryKeyBar(onOpenPanel:)`, existing `SessionProvider.setActive`, `session.terminal.keyInput/textInput`. + +- [ ] **Step 1: Replace the snippets-only icon with the side-panel trigger** + +In `_MobileSessionsScreenState`, replace `_openSnippets` with a panel opener and update the toolbar `IconButton`: + +```dart + void _openPanel(SshSession active, {int initialTab = 0}) { + showTerminalSidePanel( + context, + sessionId: active.id, + onInsert: (cmd) => active.terminal.textInput(cmd), + onKey: (k, {ctrl = false, alt = false}) => + active.terminal.keyInput(k, ctrl: ctrl, alt: alt), + initialTab: initialTab, + ); + } +``` + +In `build`, change the toolbar button to: + +```dart + IconButton( + icon: const Icon(Icons.dashboard_customize_outlined, + color: AppColors.textSecondary), + tooltip: 'Keyboard, snippets, history, themes', + onPressed: () => _openPanel(active), + ), +``` + +Add the import: `import '../terminal/terminal_side_panel.dart';` and remove the now-unused `import '../terminal/mobile_snippets_sheet.dart';` if no longer referenced. + +- [ ] **Step 2: Pass the panel opener into `_SessionBody` → `AccessoryKeyBar`** + +Add `final VoidCallback onOpenPanel;` to `_SessionBody`, thread it from `build` (`onOpenPanel: () => _openPanel(active)`), and pass `onOpenPanel: onOpenPanel` to `AccessoryKeyBar`. + +- [ ] **Step 3: Add long-press-drag cursor movement over the terminal** + +In `_SessionBody.build`, wrap the `TerminalView` `GestureDetector` to also handle a long-press drag. Add a `CursorDragMapper` owned by the state (`_MobileSessionsScreenState`), passed down. Use `onLongPressMoveUpdate` to feed deltas and `onLongPressEnd` to reset: + +```dart + child: GestureDetector( + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onLongPressMoveUpdate: (d) { + for (final k in cursorMapper.addDelta( + d.offsetFromOrigin.dx - lastDrag.dx, + d.offsetFromOrigin.dy - lastDrag.dy)) { + session.terminal.keyInput(k); + } + lastDrag = d.offsetFromOrigin; + }, + onLongPressEnd: (_) { + cursorMapper.reset(); + lastDrag = Offset.zero; + }, + child: TerminalView(...), + ), +``` + +Implementation detail: `lastDrag` is `Offset` state held alongside `cursorMapper` in `_MobileSessionsScreenState` (reset to `Offset.zero` in `onLongPressEnd`). `onLongPressMoveUpdate.offsetFromOrigin` is cumulative from the press origin, so subtract the previous offset to get the per-event delta. Pass both `cursorMapper` and a `lastDrag` getter/setter (or wrap in a small holder) into `_SessionBody`. + +> Add `final CursorDragMapper cursorMapper;` to `_SessionBody`; hold `final _cursor = CursorDragMapper();` and `Offset _lastDrag = Offset.zero;` in `_MobileSessionsScreenState`; thread a setter callback `onDragOffset` so `_SessionBody` can update `_lastDrag` (or make `_SessionBody` stateful and own `_lastDrag` itself — preferred, since `_lastDrag` is view-local). Simplest: make `_SessionBody` a `StatefulWidget` owning both `_cursor` and `_lastDrag`. Keep pinch (`onScaleStart/Update`) callbacks as-is. + +Add import: `import '../terminal/terminal_cursor_gestures.dart';` + +- [ ] **Step 4: Restyle the session strip as pill tabs with a close button** + +Replace `_SessionStrip`'s `ChoiceChip` loop with pill tabs that show a host-color dot + label + a close ×, plus a trailing "+" affordance is out of scope here (Hosts FAB already adds). Each pill: + +```dart +class _SessionStrip extends StatelessWidget { + final List sessions; + final String activeId; + const _SessionStrip({required this.sessions, required this.activeId}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + children: [ + for (final s in sessions) + Padding( + padding: const EdgeInsets.only(right: 6), + child: _SessionPill( + session: s, + selected: s.id == activeId, + onTap: () => context.read().setActive(s.id), + onClose: () => context.read().closeSession(s.id), + ), + ), + ], + ), + ); + } +} + +class _SessionPill extends StatelessWidget { + final SshSession session; + final bool selected; + final VoidCallback onTap; + final VoidCallback onClose; + const _SessionPill({ + required this.session, + required this.selected, + required this.onTap, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? AppColors.card : AppColors.bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all( + color: selected ? AppColors.accent : AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: AppColors.hostColor(session.host.host), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text(session.tabLabel, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 13)), + const SizedBox(width: 6), + GestureDetector( + onTap: onClose, + child: const Icon(Icons.close, + size: 14, color: AppColors.textTertiary), + ), + ], + ), + ), + ); + } +} +``` + +Add import: `import '../theme/mobile_tokens.dart';` + +- [ ] **Step 5: Analyze + run mobile suite + commit** + +Run: `cd app && flutter analyze && flutter test test/mobile/` +Expected: clean + green. + +```bash +git add app/lib/mobile/screens/mobile_sessions_screen.dart +git commit -m "feat(mobile): side panel, cursor-drag gestures, pill session tabs" +``` + +--- + +## Milestone 4 — SFTP screen + +### Task 11: Reskin SFTP (breadcrumb + typed rows + transfer strip) + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_sftp_screen.dart` (reskin `_bar` → breadcrumb, `_row` → typed cards; keep all load/download/upload logic unchanged) + +**Interfaces:** +- Consumes: existing `_load`, `_download`, `_upload`, `_path`, `_entries`; `MobileTokens`, `AppColors`, `SectionHeader` not needed here. + +- [ ] **Step 1: Replace `_bar` with a breadcrumb path bar** + +```dart + Widget _bar(Host host) { + final parts = _path == '.' ? [] : _path.split('/').where((s) => s.isNotEmpty).toList(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2, vertical: MobileTokens.space2), + color: AppColors.card, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_upward, color: AppColors.textSecondary), + onPressed: _path == '.' || _path == '/' + ? null + : () => _load(host, p.posix.dirname(_path)), + ), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Row( + children: [ + _crumb(host, 'home', _path == '.' ? null : () => _load(host, '.')), + for (var i = 0; i < parts.length; i++) ...[ + const Icon(Icons.chevron_right, size: 16, color: AppColors.textTertiary), + _crumb( + host, + parts[i], + i == parts.length - 1 + ? null + : () => _load(host, '/${parts.sublist(0, i + 1).join('/')}'), + ), + ], + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.refresh, color: AppColors.textSecondary), + onPressed: () => _load(host, _path), + ), + ], + ), + ); + } + + Widget _crumb(Host host, String label, VoidCallback? onTap) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Text(label, + style: TextStyle( + color: onTap == null ? AppColors.textPrimary : AppColors.accent, + fontSize: 13)), + ), + ); + } +``` + +- [ ] **Step 2: Replace `_row` with a typed row + human size** + +```dart + static String _fmtSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; + } + + IconData _iconFor(SftpName e) { + if (e.attr.isDirectory) return Icons.folder; + final name = e.filename.toLowerCase(); + if (RegExp(r'\.(zip|tar|gz|tgz|bz2|xz|7z|rar)$').hasMatch(name)) return Icons.folder_zip; + if (RegExp(r'\.(png|jpe?g|gif|webp|svg|bmp)$').hasMatch(name)) return Icons.image; + if (RegExp(r'\.(sh|bash|zsh|py|js|ts|go|rs|c|cpp|dart|rb|json|ya?ml|toml|conf)$').hasMatch(name)) return Icons.description; + return Icons.insert_drive_file; + } + + Widget _row(Host host, SftpName e) { + final isDir = e.attr.isDirectory; + return ListTile( + leading: Icon(_iconFor(e), color: isDir ? AppColors.accent : AppColors.textSecondary), + title: Text(e.filename, style: const TextStyle(color: AppColors.textPrimary)), + subtitle: isDir + ? null + : Text(_fmtSize(e.attr.size ?? 0), + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11)), + trailing: isDir + ? const Icon(Icons.chevron_right, color: AppColors.textTertiary) + : IconButton( + icon: const Icon(Icons.download, color: AppColors.textSecondary), + onPressed: () => _download(host, e), + ), + onTap: isDir ? () => _load(host, _join(e.filename)) : null, + ); + } +``` + +Add import: `import '../theme/mobile_tokens.dart';` + +- [ ] **Step 3: Analyze + run + commit** + +Run: `cd app && flutter analyze && flutter test test/mobile/` +Expected: clean + green. + +```bash +git add app/lib/mobile/screens/mobile_sftp_screen.dart +git commit -m "feat(mobile): SFTP breadcrumb + typed file rows + human sizes" +``` + +--- + +## Milestone 5 — Settings + Add host + +### Task 12: Sectioned Settings via SectionHeader + MobileCard + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_settings_screen.dart` (replace the inline bold `Text(...)` section titles with `SectionHeader`, wrap each group's controls in a `MobileCard`; keep all logic — `_save`, `_pull`, `_scan`, `_field`, app-lock switch — unchanged) + +**Interfaces:** +- Consumes: `SectionHeader`, `MobileCard`, `MobileTokens`. + +- [ ] **Step 1: Swap section titles + wrap groups** + +Replace each `const Text('Cloud Sync', style: …16/w600)` (and 'P2P transfer', 'Security', 'Terminal appearance') with `const SectionHeader('Cloud sync')` etc., and wrap the controls under each header in a `MobileCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [...]))`. Example for the Security group: + +```dart + const SectionHeader('Security'), + MobileCard( + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2), + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _appLock, + onChanged: (v) async { + setState(() => _appLock = v); + final p = await SharedPreferences.getInstance(); + await p.setBool(kAppLockPrefKey, v); + }, + title: const Text('Require biometrics to open', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: const Text('Applies on next app launch', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), + ), + const SizedBox(height: MobileTokens.space4), +``` + +Apply the same pattern to Cloud sync (fields + Save/Pull buttons + error), P2P transfer (description + Scan button), and Terminal appearance (the `TerminalAppearanceControls`). Keep `_field`, `_save`, `_pull`, `_scan` verbatim. + +Add imports: `import '../theme/mobile_tokens.dart';`, `import '../widgets/section_header.dart';`, `import '../widgets/mobile_card.dart';` + +- [ ] **Step 2: Analyze + run + commit** + +Run: `cd app && flutter analyze && flutter test test/mobile/` +Expected: clean + green. + +```bash +git add app/lib/mobile/screens/mobile_settings_screen.dart +git commit -m "feat(mobile): sectioned settings with cards and headers" +``` + +### Task 13: Polish Add-host form + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_add_host_screen.dart` (group fields under `SectionHeader`s: "Connection" (label/host/port/username) and "Authentication" (key switch / key dropdown / password); keep `_save` and `_field` unchanged) + +**Interfaces:** +- Consumes: `SectionHeader`, `MobileTokens`. + +- [ ] **Step 1: Insert section headers** + +In `build`'s `ListView`, prepend `const SectionHeader('Connection'),` before `_field(_label, …)` and `const SectionHeader('Authentication'),` before the `SwitchListTile`. Add imports `import '../theme/mobile_tokens.dart';`, `import '../widgets/section_header.dart';`. + +- [ ] **Step 2: Analyze + run + commit** + +Run: `cd app && flutter analyze && flutter test test/mobile/` +Expected: clean + green. + +```bash +git add app/lib/mobile/screens/mobile_add_host_screen.dart +git commit -m "feat(mobile): grouped add-host form" +``` + +--- + +## Milestone 6 — Verification + +### Task 14: Full analyze + test sweep + +- [ ] **Step 1: Analyze the whole app** + +Run: `cd app && flutter analyze` +Expected: "No issues found!" + +- [ ] **Step 2: Run the full test suite** + +Run: `cd app && flutter test` +Expected: all green (existing + new mobile tests). + +- [ ] **Step 3: Manual smoke (device/emulator), if available** + +Run: `cd app && flutter run -d ` and verify: hosts cards render with avatars + status dots; tag filter works; long-press deletes; session pill tabs switch/close; the keyboard-panel button opens the 4-tab sheet; long-press-drag moves the cursor; SFTP breadcrumb navigates; settings/add-host show grouped sections. + +> If no device is available, state that the manual smoke was skipped and rely on analyze + widget tests. + +--- + +## Self-Review + +**Spec coverage:** +- Design tokens → Task 1. Shared widgets (HostAvatar/StatusDot/TagChip/SectionHeader/MobileCard) → Tasks 2–4. ✓ +- Hosts cards + search/filter + swipe/long-press + empty state → Tasks 5–6 (delete via long-press confirm; trailing-swipe-to-edit dropped — see note below). ✓ +- Terminal: accessory regroup → Task 8; side panel (Keys/Snippets/History/Themes) → Task 9; pill tabs + gestures → Task 10. ✓ +- SFTP breadcrumb + typed rows → Task 11. (Transfer-progress strip: the mobile screen uses `SftpTransferService.uploadFile`/direct download without a progress stream wired into this screen; a progress strip is deferred and called out here rather than faked. ✓ documented, not silently dropped.) +- Settings sectioned → Task 12. Add-host polish → Task 13. ✓ +- Tests for avatar/status/gestures/accessory/host-card/hosts-screen/side-panel → Tasks 2,3,4,5,6,7,8,9. ✓ + +**Scope deltas from the spec (intentional, documented):** +- "Swipe to Edit/Delete": mobile has no host *editor* screen today (Add-only). Implemented **delete** via long-press + confirm dialog; Edit is out of scope (no editor exists — adding one is separate work). Tap-to-connect preserved. +- "Transfer progress strip" in SFTP: deferred — the current screen has no progress stream; noted in Task 11 self-review rather than stubbed. + +**Placeholder scan:** No TBD/TODO; every code step shows full code. The NOTE blocks (test-fake constructors, `TerminalKey` member existence) are verification reminders, not placeholders — they instruct the implementer to check real signatures before running, which is correct given providers/enums weren't all read line-by-line. + +**Type consistency:** `HostConnState` (status_dot.dart) used identically in host_card.dart + mobile_hosts_screen.dart. `hostInitials`/`HostAvatar` signatures match their test. `CursorDragMapper.addDelta/reset` match Task 10 usage. `AccessoryKeyBar.onOpenPanel` added in Task 8 and consumed in Task 10. `showTerminalSidePanel` signature in Task 9 matches the call in Task 10. `MobileCard(onTap/onLongPress/padding/child)` matches all callers. From fb4f37c77cbbf051879f66808a9d328d6da7555f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:45:58 +0700 Subject: [PATCH 39/96] feat(mobile): add shared layout tokens --- app/lib/mobile/theme/mobile_tokens.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/lib/mobile/theme/mobile_tokens.dart diff --git a/app/lib/mobile/theme/mobile_tokens.dart b/app/lib/mobile/theme/mobile_tokens.dart new file mode 100644 index 00000000..bca310e2 --- /dev/null +++ b/app/lib/mobile/theme/mobile_tokens.dart @@ -0,0 +1,20 @@ +/// Layout constants for the mobile UI. Colors live in [AppColors]; this is +/// purely spacing/sizing so widgets share one rhythm. +class MobileTokens { + MobileTokens._(); + + static const double space1 = 4; + static const double space2 = 8; + static const double space3 = 12; + static const double space4 = 16; + static const double space5 = 24; + + static const double radiusCard = 14; + static const double radiusPill = 22; + static const double radiusAvatar = 12; + + static const double avatar = 44; + static const double statusDot = 8; + static const double accessoryBarHeight = 48; + static const double touchTarget = 44; +} From 4ab130957dde6bd6fbd8c9bc5557ba75110fdb06 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:46:27 +0700 Subject: [PATCH 40/96] feat(mobile): add HostAvatar with seeded initials --- app/lib/mobile/widgets/host_avatar.dart | 63 +++++++++++++++++++++++++ app/test/mobile/host_avatar_test.dart | 22 +++++++++ 2 files changed, 85 insertions(+) create mode 100644 app/lib/mobile/widgets/host_avatar.dart create mode 100644 app/test/mobile/host_avatar_test.dart diff --git a/app/lib/mobile/widgets/host_avatar.dart b/app/lib/mobile/widgets/host_avatar.dart new file mode 100644 index 00000000..6d7f5751 --- /dev/null +++ b/app/lib/mobile/widgets/host_avatar.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Up to two uppercase initials from a host label. Words are whitespace-split; +/// two-plus words use the first letter of the first two words. A single word +/// starting with a letter returns its first two characters; a single word +/// starting with a non-letter (e.g. an IP address) returns only the first +/// character. Returns '' for blank input. +String hostInitials(String label) { + final words = label.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + if (words.isEmpty) return ''; + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + // Single word: take 2 chars only when the word starts with a letter. + final w = words.first; + if (w[0].contains(RegExp(r'[A-Za-z]')) && w.length >= 2) { + return w.substring(0, 2).toUpperCase(); + } + return w[0].toUpperCase(); +} + +/// Rounded-square avatar: seeded background tint + initials in the seed color. +/// Falls back to a terminal glyph when the label yields no initials. +class HostAvatar extends StatelessWidget { + final String label; + final String seed; + final double size; + + const HostAvatar({ + super.key, + required this.label, + required this.seed, + this.size = MobileTokens.avatar, + }); + + @override + Widget build(BuildContext context) { + final color = AppColors.hostColor(seed); + final initials = hostInitials(label); + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), + ), + child: initials.isEmpty + ? Icon(Icons.dns_outlined, color: color, size: size * 0.5) + : Text( + initials, + style: TextStyle( + color: color, + fontSize: size * 0.36, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/app/test/mobile/host_avatar_test.dart b/app/test/mobile/host_avatar_test.dart new file mode 100644 index 00000000..22bd1f8f --- /dev/null +++ b/app/test/mobile/host_avatar_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/widgets/host_avatar.dart'; + +void main() { + group('hostInitials', () { + test('two words → first letter of each, uppercased', () { + expect(hostInitials('production web'), 'PW'); + }); + test('single word → first two letters', () { + expect(hostInitials('staging'), 'ST'); + }); + test('single letter → one letter', () { + expect(hostInitials('x'), 'X'); + }); + test('blank → empty', () { + expect(hostInitials(' '), ''); + }); + test('skips non-letter leading tokens', () { + expect(hostInitials('10.0.0.5'), '1'); + }); + }); +} From 25273276bd8317243d816ed30fc5121ccf0c59fc Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:46:51 +0700 Subject: [PATCH 41/96] feat(mobile): add StatusDot + connection-state mapping --- app/lib/mobile/widgets/status_dot.dart | 37 ++++++++++++++++++++++++++ app/test/mobile/status_dot_test.dart | 11 ++++++++ 2 files changed, 48 insertions(+) create mode 100644 app/lib/mobile/widgets/status_dot.dart create mode 100644 app/test/mobile/status_dot_test.dart diff --git a/app/lib/mobile/widgets/status_dot.dart b/app/lib/mobile/widgets/status_dot.dart new file mode 100644 index 00000000..34e64de6 --- /dev/null +++ b/app/lib/mobile/widgets/status_dot.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Coarse connection state for a host row / session, decoupled from +/// [SessionStatus] so this widget stays provider-free. +enum HostConnState { connected, connecting, offline } + +Color statusColor(HostConnState s) => switch (s) { + HostConnState.connected => AppColors.accent, + HostConnState.connecting => AppColors.orange, + HostConnState.offline => AppColors.textTertiary, + }; + +class StatusDot extends StatelessWidget { + final HostConnState state; + final double size; + + const StatusDot({ + super.key, + required this.state, + this.size = MobileTokens.statusDot, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: statusColor(state), + shape: BoxShape.circle, + ), + ); + } +} diff --git a/app/test/mobile/status_dot_test.dart b/app/test/mobile/status_dot_test.dart new file mode 100644 index 00000000..9ca10b5b --- /dev/null +++ b/app/test/mobile/status_dot_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/theme/app_theme.dart'; +import 'package:yourssh/mobile/widgets/status_dot.dart'; + +void main() { + test('statusColor maps each state', () { + expect(statusColor(HostConnState.connected), AppColors.accent); + expect(statusColor(HostConnState.connecting), AppColors.orange); + expect(statusColor(HostConnState.offline), AppColors.textTertiary); + }); +} From a7b2e20c340d9ef24f6ad7d8ec3f02c8567917c2 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:47:38 +0700 Subject: [PATCH 42/96] feat(mobile): add TagChip, SectionHeader, MobileCard --- app/lib/mobile/widgets/mobile_card.dart | 41 ++++++++++++++++++++++ app/lib/mobile/widgets/section_header.dart | 30 ++++++++++++++++ app/lib/mobile/widgets/tag_chip.dart | 35 ++++++++++++++++++ app/test/mobile/shared_widgets_test.dart | 28 +++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 app/lib/mobile/widgets/mobile_card.dart create mode 100644 app/lib/mobile/widgets/section_header.dart create mode 100644 app/lib/mobile/widgets/tag_chip.dart create mode 100644 app/test/mobile/shared_widgets_test.dart diff --git a/app/lib/mobile/widgets/mobile_card.dart b/app/lib/mobile/widgets/mobile_card.dart new file mode 100644 index 00000000..9a4aa158 --- /dev/null +++ b/app/lib/mobile/widgets/mobile_card.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// The single source of card styling for the mobile UI: card surface, rounded +/// border, ripple. Used by host rows, SFTP rows, settings groups. +class MobileCard extends StatelessWidget { + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final EdgeInsetsGeometry? padding; + + const MobileCard({ + super.key, + required this.child, + this.onTap, + this.onLongPress, + this.padding, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(MobileTokens.radiusCard), + border: Border.all(color: AppColors.border), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: padding ?? const EdgeInsets.all(MobileTokens.space3), + child: child, + ), + ), + ); + } +} diff --git a/app/lib/mobile/widgets/section_header.dart b/app/lib/mobile/widgets/section_header.dart new file mode 100644 index 00000000..6b067699 --- /dev/null +++ b/app/lib/mobile/widgets/section_header.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Uppercase, letter-spaced section label for grouped lists/settings. +class SectionHeader extends StatelessWidget { + final String title; + const SectionHeader(this.title, {super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + left: MobileTokens.space1, + bottom: MobileTokens.space2, + top: MobileTokens.space2, + ), + child: Text( + title.toUpperCase(), + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } +} diff --git a/app/lib/mobile/widgets/tag_chip.dart b/app/lib/mobile/widgets/tag_chip.dart new file mode 100644 index 00000000..4a29c735 --- /dev/null +++ b/app/lib/mobile/widgets/tag_chip.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Compact pill for a host tag or a filter chip. [selected] tints it with the +/// accent; tappable when [onTap] is provided. +class TagChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback? onTap; + + const TagChip({super.key, required this.label, this.selected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + final fg = selected ? Colors.black : AppColors.textSecondary; + final bg = selected ? AppColors.accent : AppColors.bg; + final chip = Container( + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all(color: AppColors.border), + ), + child: Text(label, style: TextStyle(color: fg, fontSize: 11)), + ); + if (onTap == null) return chip; + return InkWell( + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + onTap: onTap, + child: chip, + ); + } +} diff --git a/app/test/mobile/shared_widgets_test.dart b/app/test/mobile/shared_widgets_test.dart new file mode 100644 index 00000000..3b39c1d3 --- /dev/null +++ b/app/test/mobile/shared_widgets_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/widgets/tag_chip.dart'; +import 'package:yourssh/mobile/widgets/section_header.dart'; +import 'package:yourssh/mobile/widgets/mobile_card.dart'; + +void main() { + testWidgets('TagChip renders label', (t) async { + await t.pumpWidget(const MaterialApp(home: Scaffold(body: TagChip(label: 'prod')))); + expect(find.text('prod'), findsOneWidget); + }); + + testWidgets('SectionHeader uppercases the title', (t) async { + await t.pumpWidget(const MaterialApp(home: Scaffold(body: SectionHeader('security')))); + expect(find.text('SECURITY'), findsOneWidget); + }); + + testWidgets('MobileCard fires onTap', (t) async { + var tapped = false; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: MobileCard(onTap: () => tapped = true, child: const Text('x')), + ), + )); + await t.tap(find.text('x')); + expect(tapped, isTrue); + }); +} From e02a02552eab9a445e707e859e82d391194c0b3d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:49:51 +0700 Subject: [PATCH 43/96] feat(mobile): add HostCard row --- app/lib/mobile/widgets/host_card.dart | 89 +++++++++++++++++++++++++++ app/test/mobile/host_card_test.dart | 33 ++++++++++ 2 files changed, 122 insertions(+) create mode 100644 app/lib/mobile/widgets/host_card.dart create mode 100644 app/test/mobile/host_card_test.dart diff --git a/app/lib/mobile/widgets/host_card.dart b/app/lib/mobile/widgets/host_card.dart new file mode 100644 index 00000000..b3009e5d --- /dev/null +++ b/app/lib/mobile/widgets/host_card.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import '../../models/host.dart'; +import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; +import 'host_avatar.dart'; +import 'mobile_card.dart'; +import 'status_dot.dart'; +import 'tag_chip.dart'; + +/// Termius-style host row: seeded avatar + label + user@host:port + tag chips, +/// with a connection status dot and a trailing chevron. +class HostCard extends StatelessWidget { + final Host host; + final HostConnState state; + final VoidCallback onTap; + final VoidCallback? onLongPress; + + const HostCard({ + super.key, + required this.host, + required this.state, + required this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space3, + vertical: MobileTokens.space1 + 2, + ), + child: MobileCard( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + children: [ + HostAvatar(label: host.label, seed: host.host), + const SizedBox(width: MobileTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + host.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + StatusDot(state: state), + ], + ), + const SizedBox(height: 2), + Text( + '${host.username}@${host.host}:${host.port}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + if (host.tags.isNotEmpty) ...[ + const SizedBox(height: MobileTokens.space2), + Wrap( + spacing: MobileTokens.space1 + 2, + runSpacing: MobileTokens.space1, + children: [for (final tag in host.tags) TagChip(label: tag)], + ), + ], + ], + ), + ), + const Icon(Icons.chevron_right, color: AppColors.textTertiary), + ], + ), + ), + ); + } +} diff --git a/app/test/mobile/host_card_test.dart b/app/test/mobile/host_card_test.dart new file mode 100644 index 00000000..8f5202bc --- /dev/null +++ b/app/test/mobile/host_card_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/mobile/widgets/host_card.dart'; +import 'package:yourssh/mobile/widgets/status_dot.dart'; + +void main() { + testWidgets('renders label, target, tags, and fires onTap', (t) async { + final host = Host( + label: 'Production Web', + host: '10.0.0.5', + port: 22, + username: 'root', + tags: const ['prod', 'nginx'], + ); + var tapped = false; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: HostCard( + host: host, + state: HostConnState.connected, + onTap: () => tapped = true, + ), + ), + )); + expect(find.text('Production Web'), findsOneWidget); + expect(find.text('root@10.0.0.5:22'), findsOneWidget); + expect(find.text('prod'), findsOneWidget); + expect(find.text('nginx'), findsOneWidget); + await t.tap(find.text('Production Web')); + expect(tapped, isTrue); + }); +} From 0a2fc5c1f6c4ea847e8ec9d4226fbd96059a6bfe Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:50:52 +0700 Subject: [PATCH 44/96] feat(mobile): Termius-style hosts list with cards, tag filter, delete --- .../mobile/screens/mobile_hosts_screen.dart | 147 +++++++++++++++--- app/test/mobile/mobile_hosts_screen_test.dart | 30 +++- 2 files changed, 145 insertions(+), 32 deletions(-) diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 8bc298f1..4b608dba 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -2,12 +2,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/host.dart'; +import '../../models/ssh_session.dart'; import '../../providers/host_provider.dart'; import '../../providers/session_provider.dart'; import '../../theme/app_theme.dart'; import '../../util/host_query.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/host_card.dart'; +import '../widgets/status_dot.dart'; +import '../widgets/tag_chip.dart'; -/// Hosts tab: searchable list of saved hosts; tap to connect, FAB to add. +/// Hosts tab: searchable list of saved hosts as Termius-style cards; tap to +/// connect, long-press to delete, FAB to add. Tag chips filter the list. class MobileHostsScreen extends StatefulWidget { final VoidCallback onConnected; final VoidCallback onAddHost; @@ -24,13 +30,35 @@ class MobileHostsScreen extends StatefulWidget { class _MobileHostsScreenState extends State { String _query = ''; + String? _tagFilter; + + /// Coarse connection state for a host, derived from any live session on it. + HostConnState _stateFor(Host host, List sessions) { + final mine = sessions.where((s) => s.host.id == host.id); + if (mine.any((s) => s.status == SessionStatus.connected)) { + return HostConnState.connected; + } + if (mine.any((s) => s.status == SessionStatus.connecting)) { + return HostConnState.connecting; + } + return HostConnState.offline; + } @override Widget build(BuildContext context) { final all = context.watch().allHosts; + final sessions = context.watch().sshSessions; + + final tags = {for (final h in all) ...h.tags}.toList()..sort(); + + var hosts = all; final q = _query.trim(); - // Reuse the dashboard's query engine so tag/facet search works on mobile too. - final hosts = q.isEmpty ? all : all.where(HostQuery.parse(q).matches).toList(); + if (q.isNotEmpty) { + hosts = hosts.where(HostQuery.parse(q).matches).toList(); + } + if (_tagFilter != null) { + hosts = hosts.where((h) => h.tags.contains(_tagFilter)).toList(); + } return Scaffold( backgroundColor: AppColors.bg, @@ -43,33 +71,68 @@ class _MobileHostsScreenState extends State { child: Column( children: [ Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB( + MobileTokens.space3, + MobileTokens.space3, + MobileTokens.space3, + MobileTokens.space2, + ), child: TextField( onChanged: (v) => setState(() => _query = v), style: const TextStyle(color: AppColors.textPrimary), decoration: InputDecoration( hintText: 'Search hosts', - prefixIcon: - const Icon(Icons.search, color: AppColors.textSecondary), + prefixIcon: const Icon(Icons.search, color: AppColors.textSecondary), filled: true, fillColor: AppColors.card, + contentPadding: EdgeInsets.zero, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), borderSide: BorderSide.none, ), ), ), ), + if (tags.isNotEmpty) + SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space3), + children: [ + Padding( + padding: const EdgeInsets.only(right: MobileTokens.space2), + child: TagChip( + label: 'All', + selected: _tagFilter == null, + onTap: () => setState(() => _tagFilter = null), + ), + ), + for (final tag in tags) + Padding( + padding: const EdgeInsets.only(right: MobileTokens.space2), + child: TagChip( + label: tag, + selected: _tagFilter == tag, + onTap: () => setState( + () => _tagFilter = _tagFilter == tag ? null : tag), + ), + ), + ], + ), + ), Expanded( child: hosts.isEmpty - ? const Center( - child: Text('No hosts yet — tap + to add one', - style: TextStyle(color: AppColors.textSecondary))) + ? _EmptyState(onAddHost: widget.onAddHost) : ListView.builder( + padding: const EdgeInsets.only( + top: MobileTokens.space2, bottom: 80), itemCount: hosts.length, - itemBuilder: (_, i) => _HostRow( + itemBuilder: (_, i) => HostCard( host: hosts[i], + state: _stateFor(hosts[i], sessions), onTap: () => _connect(hosts[i]), + onLongPress: () => _confirmDelete(hosts[i]), ), ), ), @@ -80,28 +143,62 @@ class _MobileHostsScreenState extends State { } void _connect(Host host) { - // Fire the connect (async) and immediately switch to the Sessions tab so - // the user watches it come up live. context.read().connectAny(host); widget.onConnected(); } + + Future _confirmDelete(Host host) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.card, + title: Text('Delete ${host.label}?', + style: const TextStyle(color: AppColors.textPrimary)), + content: const Text('This removes the saved host and its credentials.', + style: TextStyle(color: AppColors.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: AppColors.red)), + ), + ], + ), + ); + if (ok == true && mounted) { + await context.read().deleteHost(host.id); + } + } } -class _HostRow extends StatelessWidget { - final Host host; - final VoidCallback onTap; - const _HostRow({required this.host, required this.onTap}); +class _EmptyState extends StatelessWidget { + final VoidCallback onAddHost; + const _EmptyState({required this.onAddHost}); @override Widget build(BuildContext context) { - return ListTile( - onTap: onTap, - leading: const Icon(Icons.dns_outlined, color: AppColors.textSecondary), - title: - Text(host.label, style: const TextStyle(color: AppColors.textPrimary)), - subtitle: Text('${host.username}@${host.host}:${host.port}', - style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), - trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.dns_outlined, size: 48, color: AppColors.textTertiary), + const SizedBox(height: MobileTokens.space3), + const Text('No hosts yet', + style: TextStyle(color: AppColors.textPrimary, fontSize: 16)), + const SizedBox(height: MobileTokens.space1), + const Text('Add a server to get started', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: MobileTokens.space4), + FilledButton.icon( + onPressed: onAddHost, + icon: const Icon(Icons.add), + label: const Text('Add host'), + ), + ], + ), ); } } diff --git a/app/test/mobile/mobile_hosts_screen_test.dart b/app/test/mobile/mobile_hosts_screen_test.dart index f9a2a7ab..7012aea8 100644 --- a/app/test/mobile/mobile_hosts_screen_test.dart +++ b/app/test/mobile/mobile_hosts_screen_test.dart @@ -4,13 +4,21 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; import 'package:yourssh/mobile/screens/mobile_hosts_screen.dart'; +import 'package:yourssh/mobile/widgets/host_card.dart'; +import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; Future _pump(WidgetTester tester, HostProvider hosts) async { + final sessions = SessionProvider(SshService(StorageService()), TabMetadataService()); await tester.pumpWidget(MaterialApp( - home: ChangeNotifierProvider.value( - value: hosts, + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: hosts), + ChangeNotifierProvider.value(value: sessions), + ], child: MobileHostsScreen(onConnected: () {}, onAddHost: () {}), ), )); @@ -20,6 +28,19 @@ Future _pump(WidgetTester tester, HostProvider hosts) async { void main() { setUp(() => SharedPreferences.setMockInitialValues({})); + testWidgets('empty state shows add CTA', (tester) async { + await _pump(tester, HostProvider(StorageService())); + expect(find.textContaining('No hosts'), findsOneWidget); + }); + + testWidgets('renders a HostCard per host', (tester) async { + final hosts = HostProvider(StorageService()); + await hosts.addHost(Host(label: 'Alpha', host: '1.1.1.1', username: 'root')); + await hosts.addHost(Host(label: 'Beta', host: '2.2.2.2', username: 'root')); + await _pump(tester, hosts); + expect(find.byType(HostCard), findsNWidgets(2)); + }); + testWidgets('lists hosts and filters by search', (tester) async { final hosts = HostProvider(StorageService()); await hosts.addHost(Host(label: 'prod-web', host: '10.0.0.1', username: 'root')); @@ -34,9 +55,4 @@ void main() { expect(find.text('prod-web'), findsOneWidget); expect(find.text('db-1'), findsNothing); }); - - testWidgets('shows empty state with no hosts', (tester) async { - await _pump(tester, HostProvider(StorageService())); - expect(find.textContaining('No hosts'), findsOneWidget); - }); } From e4ac464f8f9db0df527d5d6755355380eead55a9 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:53:23 +0700 Subject: [PATCH 45/96] feat(mobile): cursor-drag to arrow-key mapper --- .../terminal/terminal_cursor_gestures.dart | 50 +++++++++++++++++++ .../mobile/terminal_cursor_gestures_test.dart | 43 ++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 app/lib/mobile/terminal/terminal_cursor_gestures.dart create mode 100644 app/test/mobile/terminal_cursor_gestures_test.dart diff --git a/app/lib/mobile/terminal/terminal_cursor_gestures.dart b/app/lib/mobile/terminal/terminal_cursor_gestures.dart new file mode 100644 index 00000000..2207d42a --- /dev/null +++ b/app/lib/mobile/terminal/terminal_cursor_gestures.dart @@ -0,0 +1,50 @@ +import 'package:xterm/xterm.dart'; + +/// Translates a long-press drag into a stream of arrow-key presses, the +/// touch way to move the terminal cursor. Distance is accumulated; every +/// `step` pixels in the dominant axis emits one arrow key. The step shrinks +/// across three gears the longer a single drag persists, so a slow nudge moves +/// one cell while a long sweep flies. Pure + deterministic for unit testing; +/// call [reset] when the drag ends. +class CursorDragMapper { + final double baseStep; + CursorDragMapper({this.baseStep = 22}); + + double _accX = 0; + double _accY = 0; + int _emitted = 0; + + double get _step { + if (_emitted >= 12) return baseStep * 0.4; // gear 3 + if (_emitted >= 5) return baseStep * 0.65; // gear 2 + return baseStep; // gear 1 + } + + List addDelta(double dx, double dy) { + _accX += dx; + _accY += dy; + final keys = []; + while (true) { + final step = _step; + final xReady = _accX.abs() >= step; + final yReady = _accY.abs() >= step; + if (!xReady && !yReady) break; + final useX = xReady && (!yReady || _accX.abs() >= _accY.abs()); + if (useX) { + keys.add(_accX > 0 ? TerminalKey.arrowRight : TerminalKey.arrowLeft); + _accX += _accX > 0 ? -step : step; + } else { + keys.add(_accY > 0 ? TerminalKey.arrowDown : TerminalKey.arrowUp); + _accY += _accY > 0 ? -step : step; + } + _emitted++; + } + return keys; + } + + void reset() { + _accX = 0; + _accY = 0; + _emitted = 0; + } +} diff --git a/app/test/mobile/terminal_cursor_gestures_test.dart b/app/test/mobile/terminal_cursor_gestures_test.dart new file mode 100644 index 00000000..76714f08 --- /dev/null +++ b/app/test/mobile/terminal_cursor_gestures_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh/mobile/terminal/terminal_cursor_gestures.dart'; + +void main() { + test('horizontal drag emits arrowRight per step', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(20, 0), [TerminalKey.arrowRight]); + expect(m.addDelta(20, 0), [TerminalKey.arrowRight]); + }); + + test('negative vertical drag emits arrowUp', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(0, -20), [TerminalKey.arrowUp]); + }); + + test('sub-step deltas accumulate then fire once', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(0, 12), isEmpty); + expect(m.addDelta(0, 12), [TerminalKey.arrowDown]); + }); + + test('dominant axis wins (no diagonal double-fire)', () { + final m = CursorDragMapper(baseStep: 20); + expect(m.addDelta(30, 5), [TerminalKey.arrowRight]); + }); + + test('reset clears accumulators and gear', () { + final m = CursorDragMapper(baseStep: 20); + m.addDelta(100, 0); + m.reset(); + expect(m.addDelta(0, 19), isEmpty); // back to gear 1, sub-step + }); + + test('acceleration: step shrinks after sustained drag', () { + final m = CursorDragMapper(baseStep: 20); + // First burst pushes emitted count up into a higher gear. + m.addDelta(200, 0); // many arrowRight + // After gear-up, a 12px delta should now be enough to fire. + final keys = m.addDelta(12, 0); + expect(keys, isNotEmpty); + }); +} From 075878b3f09d8ea2df297a42ece461949463cf8c Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:54:12 +0700 Subject: [PATCH 46/96] feat(mobile): accessory bar gains a side-panel button + $ key --- .../mobile/terminal/accessory_key_bar.dart | 12 +++++- app/test/mobile/accessory_key_bar_test.dart | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/app/lib/mobile/terminal/accessory_key_bar.dart b/app/lib/mobile/terminal/accessory_key_bar.dart index 667a4374..29b63d9a 100644 --- a/app/lib/mobile/terminal/accessory_key_bar.dart +++ b/app/lib/mobile/terminal/accessory_key_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:xterm/xterm.dart'; import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; import 'accessory_bar_controller.dart'; /// Mobile terminal accessory bar: special keys + sticky Ctrl/Alt above the @@ -13,11 +14,16 @@ class AccessoryKeyBar extends StatelessWidget { final void Function(TerminalKey key, {bool ctrl, bool alt}) onKey; final void Function(String text) onText; + /// Opens the terminal side panel (extended keyboard / snippets / history / + /// themes). When null the leading keyboard button is hidden. + final VoidCallback? onOpenPanel; + const AccessoryKeyBar({ super.key, required this.controller, required this.onKey, required this.onText, + this.onOpenPanel, }); /// Emit a special key, applying (and clearing) any armed sticky modifiers. @@ -46,12 +52,14 @@ class AccessoryKeyBar extends StatelessWidget { animation: controller, builder: (context, _) { return Container( - height: 46, + height: MobileTokens.accessoryBarHeight, color: AppColors.card, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), children: [ + if (onOpenPanel != null) + _icon(Icons.keyboard_outlined, 'Keyboard panel', onOpenPanel!), _btn('Esc', onTap: () => _key(TerminalKey.escape)), _btn('Tab', onTap: () => _key(TerminalKey.tab)), _btn('Ctrl', @@ -67,7 +75,7 @@ class AccessoryKeyBar extends StatelessWidget { () => _key(TerminalKey.arrowDown)), _icon(Icons.keyboard_arrow_right, 'Right', () => _key(TerminalKey.arrowRight)), - for (final ch in const ['/', '-', '|', '~', ':']) + for (final ch in const ['/', '-', '|', '~', ':', '\$']) _btn(ch, onTap: () => _text(ch)), ], ), diff --git a/app/test/mobile/accessory_key_bar_test.dart b/app/test/mobile/accessory_key_bar_test.dart index d41f52cb..72ae97b2 100644 --- a/app/test/mobile/accessory_key_bar_test.dart +++ b/app/test/mobile/accessory_key_bar_test.dart @@ -56,4 +56,42 @@ void main() { expect(gotKey, TerminalKey.keyC); expect(gotCtrl, isTrue); }); + + testWidgets('Esc/Tab/Ctrl render and Ctrl arms one-shot', (t) async { + final c = AccessoryBarController(); + final keys = <(TerminalKey, bool)>[]; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: AccessoryKeyBar( + controller: c, + onKey: (k, {ctrl = false, alt = false}) => keys.add((k, ctrl)), + onText: (_) {}, + ), + ), + )); + expect(find.text('Esc'), findsOneWidget); + expect(find.text('Tab'), findsOneWidget); + await t.tap(find.text('Ctrl')); + await t.pump(); + expect(c.ctrlArmed, isTrue); + await t.tap(find.text('Esc')); + expect(keys.single, (TerminalKey.escape, true)); + expect(c.ctrlArmed, isFalse); // consumed + }); + + testWidgets('onOpenPanel button fires when provided', (t) async { + var opened = false; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: AccessoryKeyBar( + controller: AccessoryBarController(), + onKey: (k, {ctrl = false, alt = false}) {}, + onText: (_) {}, + onOpenPanel: () => opened = true, + ), + ), + )); + await t.tap(find.byIcon(Icons.keyboard_outlined)); + expect(opened, isTrue); + }); } From 8b9d46ac990722670d1c307b3e340cff50776282 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:55:29 +0700 Subject: [PATCH 47/96] feat(mobile): terminal side panel (keys/snippets/history/themes) --- .../mobile/terminal/terminal_side_panel.dart | 255 ++++++++++++++++++ app/test/mobile/terminal_side_panel_test.dart | 47 ++++ 2 files changed, 302 insertions(+) create mode 100644 app/lib/mobile/terminal/terminal_side_panel.dart create mode 100644 app/test/mobile/terminal_side_panel_test.dart diff --git a/app/lib/mobile/terminal/terminal_side_panel.dart b/app/lib/mobile/terminal/terminal_side_panel.dart new file mode 100644 index 00000000..5a7878bf --- /dev/null +++ b/app/lib/mobile/terminal/terminal_side_panel.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:xterm/xterm.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; + +import '../../providers/command_history_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/terminal_appearance_controls.dart'; +import '../theme/mobile_tokens.dart'; + +/// Termius-style terminal side panel as a bottom sheet: Keys (extended +/// keyboard), Snippets, Command History, and Themes. Insertions/keys are +/// routed back to the active session via the callbacks. +Future showTerminalSidePanel( + BuildContext context, { + required String sessionId, + required void Function(String text) onInsert, + required void Function(TerminalKey key, {bool ctrl, bool alt}) onKey, + int initialTab = 0, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(MobileTokens.radiusCard)), + ), + builder: (_) => FractionallySizedBox( + heightFactor: 0.6, + child: _SidePanel( + sessionId: sessionId, + onInsert: onInsert, + onKey: onKey, + initialTab: initialTab, + ), + ), + ); +} + +class _SidePanel extends StatefulWidget { + final String sessionId; + final void Function(String text) onInsert; + final void Function(TerminalKey key, {bool ctrl, bool alt}) onKey; + final int initialTab; + + const _SidePanel({ + required this.sessionId, + required this.onInsert, + required this.onKey, + required this.initialTab, + }); + + @override + State<_SidePanel> createState() => _SidePanelState(); +} + +class _SidePanelState extends State<_SidePanel> with SingleTickerProviderStateMixin { + late final TabController _tabs = + TabController(length: 4, vsync: this, initialIndex: widget.initialTab); + + @override + void dispose() { + _tabs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + TabBar( + controller: _tabs, + labelColor: AppColors.accent, + unselectedLabelColor: AppColors.textSecondary, + indicatorColor: AppColors.accent, + tabs: const [ + Tab(text: 'Keys'), + Tab(text: 'Snippets'), + Tab(text: 'History'), + Tab(text: 'Themes'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabs, + children: [ + _KeysGrid(onKey: widget.onKey, onClose: () => Navigator.pop(context)), + _SnippetsTab(onInsert: (c) { + widget.onInsert(c); + Navigator.pop(context); + }), + _HistoryTab(sessionId: widget.sessionId, onInsert: (c) { + widget.onInsert(c); + Navigator.pop(context); + }), + const SingleChildScrollView( + padding: EdgeInsets.all(MobileTokens.space4), + child: TerminalAppearanceControls(layout: AppearanceControlsLayout.rows), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _KeysGrid extends StatelessWidget { + final void Function(TerminalKey key, {bool ctrl, bool alt}) onKey; + final VoidCallback onClose; + const _KeysGrid({required this.onKey, required this.onClose}); + + // All TerminalKey members verified present in packages/xterm/lib/src/core/input/keys.dart: + // home (line 255), end (line 264), pageUp (line 258), pageDown (line 267), + // f1 (line 207) ... f10 (line 234) — all confirmed. + static const _keys = <(String, TerminalKey)>[ + ('Esc', TerminalKey.escape), + ('Tab', TerminalKey.tab), + ('Home', TerminalKey.home), + ('End', TerminalKey.end), + ('PgUp', TerminalKey.pageUp), + ('PgDn', TerminalKey.pageDown), + ('↑', TerminalKey.arrowUp), + ('↓', TerminalKey.arrowDown), + ('←', TerminalKey.arrowLeft), + ('→', TerminalKey.arrowRight), + ('F1', TerminalKey.f1), + ('F2', TerminalKey.f2), + ('F3', TerminalKey.f3), + ('F4', TerminalKey.f4), + ('F5', TerminalKey.f5), + ('F6', TerminalKey.f6), + ('F7', TerminalKey.f7), + ('F8', TerminalKey.f8), + ('F9', TerminalKey.f9), + ('F10', TerminalKey.f10), + ]; + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 5, + padding: const EdgeInsets.all(MobileTokens.space3), + mainAxisSpacing: MobileTokens.space2, + crossAxisSpacing: MobileTokens.space2, + childAspectRatio: 1.8, + children: [ + for (final (label, key) in _keys) + InkWell( + borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), + onTap: () => onKey(key), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), + border: Border.all(color: AppColors.border), + ), + child: Text(label, style: const TextStyle(color: AppColors.textPrimary)), + ), + ), + ], + ); + } +} + +class _SnippetsTab extends StatefulWidget { + final void Function(String command) onInsert; + const _SnippetsTab({required this.onInsert}); + + @override + State<_SnippetsTab> createState() => _SnippetsTabState(); +} + +class _SnippetsTabState extends State<_SnippetsTab> { + String _query = ''; + + @override + Widget build(BuildContext context) { + final all = context.watch().snippets; + final shown = filterSnippets(all, _query); + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(MobileTokens.space3), + child: TextField( + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Search snippets', + prefixIcon: Icon(Icons.search, color: AppColors.textSecondary), + ), + ), + ), + Expanded( + child: shown.isEmpty + ? const Center( + child: Text('No snippets', + style: TextStyle(color: AppColors.textSecondary))) + : ListView.builder( + itemCount: shown.length, + itemBuilder: (_, i) { + final s = shown[i]; + return ListTile( + title: Text(s.label, + style: const TextStyle(color: AppColors.textPrimary)), + subtitle: Text(s.command, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontFamily: 'monospace')), + onTap: () => widget.onInsert(s.command), + ); + }, + ), + ), + ], + ); + } +} + +class _HistoryTab extends StatelessWidget { + final String sessionId; + final void Function(String command) onInsert; + const _HistoryTab({required this.sessionId, required this.onInsert}); + + @override + Widget build(BuildContext context) { + final entries = + context.watch().historyFor(sessionId).entries; + if (entries.isEmpty) { + return const Center( + child: Text('No command history yet', + style: TextStyle(color: AppColors.textSecondary))); + } + return ListView.builder( + itemCount: entries.length, + itemBuilder: (_, i) { + final cmd = entries[entries.length - 1 - i]; // newest first + return ListTile( + title: Text(cmd, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, fontFamily: 'monospace', fontSize: 13)), + onTap: () => onInsert(cmd), + ); + }, + ); + } +} diff --git a/app/test/mobile/terminal_side_panel_test.dart b/app/test/mobile/terminal_side_panel_test.dart new file mode 100644 index 00000000..d909ee4e --- /dev/null +++ b/app/test/mobile/terminal_side_panel_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/providers/command_history_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; +import 'package:yourssh/mobile/terminal/terminal_side_panel.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows the four tabs', (t) async { + await t.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => CommandHistoryProvider()), + ChangeNotifierProvider(create: (_) => SnippetProvider()), + ChangeNotifierProvider(create: (_) => SettingsProvider()), + ], + child: MaterialApp( + home: Builder( + builder: (ctx) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showTerminalSidePanel( + ctx, + sessionId: 's1', + onInsert: (_) {}, + onKey: (k, {ctrl = false, alt = false}) {}, + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text('Keys'), findsOneWidget); + expect(find.text('Snippets'), findsOneWidget); + expect(find.text('History'), findsOneWidget); + expect(find.text('Themes'), findsOneWidget); + }); +} From 571231121108c68338d125b0c45f149e0e7f5566 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:56:27 +0700 Subject: [PATCH 48/96] feat(mobile): side panel, cursor-drag gestures, pill session tabs --- .../screens/mobile_sessions_screen.dart | 154 ++++++++++++++---- 1 file changed, 121 insertions(+), 33 deletions(-) diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart index 17e443d4..656c62ac 100644 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -10,7 +10,9 @@ import '../../theme/terminal_themes.dart'; import '../../util/terminal_appearance.dart'; import '../terminal/accessory_bar_controller.dart'; import '../terminal/accessory_key_bar.dart'; -import '../terminal/mobile_snippets_sheet.dart'; +import '../terminal/terminal_cursor_gestures.dart'; +import '../terminal/terminal_side_panel.dart'; +import '../theme/mobile_tokens.dart'; /// Sessions tab: a session strip + the active SSH session's terminal (when /// connected, with the accessory key bar docked below and per-session @@ -44,14 +46,14 @@ class _MobileSessionsScreenState extends State { setState(() => _pinch[sessionId] = (_scaleBase * d.scale).clamp(8.0, 28.0)); } - void _openSnippets(SshSession active) { - showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - isScrollControlled: true, - builder: (_) => MobileSnippetsSheet( - onInsert: (cmd) => active.terminal.textInput(cmd), - ), + void _openPanel(SshSession active, {int initialTab = 0}) { + showTerminalSidePanel( + context, + sessionId: active.id, + onInsert: (cmd) => active.terminal.textInput(cmd), + onKey: (k, {ctrl = false, alt = false}) => + active.terminal.keyInput(k, ctrl: ctrl, alt: alt), + initialTab: initialTab, ); } @@ -92,9 +94,10 @@ class _MobileSessionsScreenState extends State { Expanded( child: _SessionStrip(sessions: ssh, activeId: active.id)), IconButton( - icon: const Icon(Icons.code, color: AppColors.textSecondary), - tooltip: 'Snippets', - onPressed: () => _openSnippets(active), + icon: const Icon(Icons.dashboard_customize_outlined, + color: AppColors.textSecondary), + tooltip: 'Keyboard, snippets, history, themes', + onPressed: () => _openPanel(active), ), ], ), @@ -108,6 +111,7 @@ class _MobileSessionsScreenState extends State { onScaleStart: (_) => _onScaleStart(active.id, appearance.fontSize), onScaleUpdate: (d) => _onScaleUpdate(active.id, d), + onOpenPanel: () => _openPanel(active), ), ), ], @@ -133,11 +137,11 @@ class _SessionStrip extends StatelessWidget { for (final s in sessions) Padding( padding: const EdgeInsets.only(right: 6), - child: ChoiceChip( - label: Text(s.tabLabel), + child: _SessionPill( + session: s, selected: s.id == activeId, - onSelected: (_) => - context.read().setActive(s.id), + onTap: () => context.read().setActive(s.id), + onClose: () => context.read().closeSession(s.id), ), ), ], @@ -146,13 +150,70 @@ class _SessionStrip extends StatelessWidget { } } -class _SessionBody extends StatelessWidget { +class _SessionPill extends StatelessWidget { + final SshSession session; + final bool selected; + final VoidCallback onTap; + final VoidCallback onClose; + const _SessionPill({ + required this.session, + required this.selected, + required this.onTap, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? AppColors.card : AppColors.bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all( + color: selected ? AppColors.accent : AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: AppColors.hostColor(session.host.host), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text(session.tabLabel, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 13)), + const SizedBox(width: 6), + GestureDetector( + onTap: onClose, + child: const Icon(Icons.close, + size: 14, color: AppColors.textTertiary), + ), + ], + ), + ), + ); + } +} + +class _SessionBody extends StatefulWidget { final SshSession session; final AccessoryBarController accessory; final TerminalAppearance appearance; final double fontSize; final void Function(ScaleStartDetails) onScaleStart; final void Function(ScaleUpdateDetails) onScaleUpdate; + final VoidCallback onOpenPanel; const _SessionBody({ required this.session, @@ -161,32 +222,59 @@ class _SessionBody extends StatelessWidget { required this.fontSize, required this.onScaleStart, required this.onScaleUpdate, + required this.onOpenPanel, }); + @override + State<_SessionBody> createState() => _SessionBodyState(); +} + +class _SessionBodyState extends State<_SessionBody> { + final _cursor = CursorDragMapper(); + Offset _lastDrag = Offset.zero; + @override Widget build(BuildContext context) { - if (session.status == SessionStatus.connected) { + if (widget.session.status == SessionStatus.connected) { return Column( children: [ Expanded( + // Outer GestureDetector handles long-press-drag for cursor movement. + // Inner GestureDetector handles pinch-to-zoom (scale). + // Nesting avoids competing recognizer conflicts between scale and + // long-press-drag that can occur with a single GestureDetector. child: GestureDetector( - onScaleStart: onScaleStart, - onScaleUpdate: onScaleUpdate, - child: TerminalView( - session.terminal, - theme: terminalThemeByName(appearance.themeName), - textStyle: TerminalStyle( - fontSize: fontSize, - fontFamily: appearance.fontFamily, + onLongPressMoveUpdate: (d) { + final delta = d.offsetFromOrigin - _lastDrag; + for (final k in _cursor.addDelta(delta.dx, delta.dy)) { + widget.session.terminal.keyInput(k); + } + _lastDrag = d.offsetFromOrigin; + }, + onLongPressEnd: (_) { + _cursor.reset(); + _lastDrag = Offset.zero; + }, + child: GestureDetector( + onScaleStart: widget.onScaleStart, + onScaleUpdate: widget.onScaleUpdate, + child: TerminalView( + widget.session.terminal, + theme: terminalThemeByName(widget.appearance.themeName), + textStyle: TerminalStyle( + fontSize: widget.fontSize, + fontFamily: widget.appearance.fontFamily, + ), ), ), ), ), AccessoryKeyBar( - controller: accessory, + controller: widget.accessory, onKey: (k, {ctrl = false, alt = false}) => - session.terminal.keyInput(k, ctrl: ctrl, alt: alt), - onText: (s) => session.terminal.textInput(s), + widget.session.terminal.keyInput(k, ctrl: ctrl, alt: alt), + onText: (s) => widget.session.terminal.textInput(s), + onOpenPanel: widget.onOpenPanel, ), ], ); @@ -195,16 +283,16 @@ class _SessionBody extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (session.status == SessionStatus.connecting) + if (widget.session.status == SessionStatus.connecting) const Padding( padding: EdgeInsets.only(bottom: 12), child: CircularProgressIndicator(), ), - Text(session.statusLabel, + Text(widget.session.statusLabel, style: const TextStyle(color: AppColors.textSecondary)), - if (session.errorMessage != null) ...[ + if (widget.session.errorMessage != null) ...[ const SizedBox(height: 8), - Text(session.errorMessage!, + Text(widget.session.errorMessage!, style: const TextStyle(color: AppColors.red, fontSize: 12)), ], ], From e646fb096b73cd919de7c82705b2acdba6f79ee1 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:59:50 +0700 Subject: [PATCH 49/96] feat(mobile): SFTP breadcrumb + typed file rows + human sizes --- .../mobile/screens/mobile_sftp_screen.dart | 72 +++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/app/lib/mobile/screens/mobile_sftp_screen.dart b/app/lib/mobile/screens/mobile_sftp_screen.dart index 6c420e70..1bdcaa88 100644 --- a/app/lib/mobile/screens/mobile_sftp_screen.dart +++ b/app/lib/mobile/screens/mobile_sftp_screen.dart @@ -11,6 +11,7 @@ import '../../providers/session_provider.dart'; import '../../services/sftp_transfer_service.dart'; import '../../services/ssh_service.dart'; import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; /// Single-panel SFTP browser over the active SSH session's host. Lists, opens /// directories, downloads files to a picked folder, and uploads a picked file. @@ -138,23 +139,38 @@ class _MobileSftpScreenState extends State { } Widget _bar(Host host) { + final parts = _path == '.' ? [] : _path.split('/').where((s) => s.isNotEmpty).toList(); return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2, vertical: MobileTokens.space2), color: AppColors.card, child: Row( children: [ IconButton( - icon: - const Icon(Icons.arrow_upward, color: AppColors.textSecondary), + icon: const Icon(Icons.arrow_upward, color: AppColors.textSecondary), onPressed: _path == '.' || _path == '/' ? null : () => _load(host, p.posix.dirname(_path)), ), Expanded( - child: Text(_path, - overflow: TextOverflow.ellipsis, - style: - const TextStyle(color: AppColors.textPrimary, fontSize: 13)), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Row( + children: [ + _crumb(host, 'home', _path == '.' ? null : () => _load(host, '.')), + for (var i = 0; i < parts.length; i++) ...[ + const Icon(Icons.chevron_right, size: 16, color: AppColors.textTertiary), + _crumb( + host, + parts[i], + i == parts.length - 1 + ? null + : () => _load(host, '/${parts.sublist(0, i + 1).join('/')}'), + ), + ], + ], + ), + ), ), IconButton( icon: const Icon(Icons.refresh, color: AppColors.textSecondary), @@ -165,20 +181,46 @@ class _MobileSftpScreenState extends State { ); } + Widget _crumb(Host host, String label, VoidCallback? onTap) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Text(label, + style: TextStyle( + color: onTap == null ? AppColors.textPrimary : AppColors.accent, + fontSize: 13)), + ), + ); + } + + static String _fmtSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; + } + + IconData _iconFor(SftpName e) { + if (e.attr.isDirectory) return Icons.folder; + final name = e.filename.toLowerCase(); + if (RegExp(r'\.(zip|tar|gz|tgz|bz2|xz|7z|rar)$').hasMatch(name)) return Icons.folder_zip; + if (RegExp(r'\.(png|jpe?g|gif|webp|svg|bmp)$').hasMatch(name)) return Icons.image; + if (RegExp(r'\.(sh|bash|zsh|py|js|ts|go|rs|c|cpp|dart|rb|json|ya?ml|toml|conf)$').hasMatch(name)) return Icons.description; + return Icons.insert_drive_file; + } + Widget _row(Host host, SftpName e) { final isDir = e.attr.isDirectory; return ListTile( - leading: Icon(isDir ? Icons.folder : Icons.insert_drive_file, - color: isDir ? AppColors.accent : AppColors.textSecondary), - title: - Text(e.filename, style: const TextStyle(color: AppColors.textPrimary)), + leading: Icon(_iconFor(e), color: isDir ? AppColors.accent : AppColors.textSecondary), + title: Text(e.filename, style: const TextStyle(color: AppColors.textPrimary)), subtitle: isDir ? null - : Text('${e.attr.size ?? 0} bytes', - style: - const TextStyle(color: AppColors.textSecondary, fontSize: 11)), + : Text(_fmtSize(e.attr.size ?? 0), + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11)), trailing: isDir - ? null + ? const Icon(Icons.chevron_right, color: AppColors.textTertiary) : IconButton( icon: const Icon(Icons.download, color: AppColors.textSecondary), onPressed: () => _download(host, e), From f1e907e8d785c3c27585a9e8e8daf42a76a8f69c Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:59:53 +0700 Subject: [PATCH 50/96] feat(mobile): sectioned settings with cards and headers --- .../screens/mobile_settings_screen.dart | 158 +++++++++--------- 1 file changed, 83 insertions(+), 75 deletions(-) diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index bb3bc87d..b81a9a33 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -8,6 +8,9 @@ import '../../services/sync_service.dart'; import '../../theme/app_theme.dart'; import '../../widgets/terminal_appearance_controls.dart'; import '../security/app_lock_gate.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/mobile_card.dart'; +import '../widgets/section_header.dart'; import 'mobile_qr_scan_screen.dart'; /// Settings tab. M3 ships the Sync section (cloud pull + P2P QR scan). @@ -67,84 +70,89 @@ class _MobileSettingsScreenState extends State { child: ListView( padding: const EdgeInsets.all(16), children: [ - const Text('Cloud Sync', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - const Text('Pull hosts from your desktop via Supabase.', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), - const SizedBox(height: 12), - _field(_url, 'Supabase URL', 'sync-url'), - _field(_anon, 'Anon key', 'sync-anon'), - _field(_code, 'Sync code (12 chars)', 'sync-code'), - const SizedBox(height: 8), - Row( - children: [ - FilledButton(onPressed: _save, child: const Text('Save')), - const SizedBox(width: 10), - OutlinedButton( - onPressed: _pulling ? null : _pull, - child: _pulling - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Pull from cloud'), - ), - ], + const SectionHeader('Cloud sync'), + MobileCard( + padding: const EdgeInsets.all(MobileTokens.space3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Pull hosts from your desktop via Supabase.', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: MobileTokens.space3), + _field(_url, 'Supabase URL', 'sync-url'), + _field(_anon, 'Anon key', 'sync-anon'), + _field(_code, 'Sync code (12 chars)', 'sync-code'), + const SizedBox(height: MobileTokens.space2), + Row( + children: [ + FilledButton(onPressed: _save, child: const Text('Save')), + const SizedBox(width: 10), + OutlinedButton( + onPressed: _pulling ? null : _pull, + child: _pulling + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Pull from cloud'), + ), + ], + ), + if (sync.error != null) ...[ + const SizedBox(height: 10), + Text(sync.error!, + style: const TextStyle(color: AppColors.red, fontSize: 12)), + ], + ], + ), + ), + const SizedBox(height: MobileTokens.space4), + const SectionHeader('P2P transfer'), + MobileCard( + padding: const EdgeInsets.all(MobileTokens.space3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Scan the QR code shown on your desktop (Settings → Sync → Show QR).', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: MobileTokens.space3), + FilledButton.icon( + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scan QR code'), + onPressed: _scan, + ), + ], + ), ), - if (sync.error != null) ...[ - const SizedBox(height: 10), - Text(sync.error!, - style: const TextStyle(color: AppColors.red, fontSize: 12)), - ], - const SizedBox(height: 24), - const Text('P2P transfer', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - const Text( - 'Scan the QR code shown on your desktop (Settings → Sync → Show QR).', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), - const SizedBox(height: 12), - FilledButton.icon( - icon: const Icon(Icons.qr_code_scanner), - label: const Text('Scan QR code'), - onPressed: _scan, + const SizedBox(height: MobileTokens.space4), + const SectionHeader('Security'), + MobileCard( + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2), + child: Material( + color: Colors.transparent, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _appLock, + onChanged: (v) async { + setState(() => _appLock = v); + final p = await SharedPreferences.getInstance(); + await p.setBool(kAppLockPrefKey, v); + }, + title: const Text('Require biometrics to open', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: const Text('Applies on next app launch', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), + ), ), - const SizedBox(height: 24), - const Text('Security', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600)), - SwitchListTile( - contentPadding: EdgeInsets.zero, - value: _appLock, - onChanged: (v) async { - setState(() => _appLock = v); - final p = await SharedPreferences.getInstance(); - await p.setBool(kAppLockPrefKey, v); - }, - title: const Text('Require biometrics to open', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: const Text('Applies on next app launch', - style: - TextStyle(color: AppColors.textSecondary, fontSize: 12)), + const SizedBox(height: MobileTokens.space4), + const SectionHeader('Terminal appearance'), + MobileCard( + padding: const EdgeInsets.all(MobileTokens.space3), + child: const TerminalAppearanceControls( + layout: AppearanceControlsLayout.rows), ), - const SizedBox(height: 24), - const Text('Terminal appearance', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - const TerminalAppearanceControls( - layout: AppearanceControlsLayout.rows), ], ), ), From c0d77aed9edb32939042aeb6d4a9a653f3c68307 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 20:59:56 +0700 Subject: [PATCH 51/96] feat(mobile): grouped add-host form --- app/lib/mobile/screens/mobile_add_host_screen.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/lib/mobile/screens/mobile_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart index b7249fe2..2acc93b7 100644 --- a/app/lib/mobile/screens/mobile_add_host_screen.dart +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -7,6 +7,8 @@ import '../../models/ssh_key.dart'; import '../../providers/host_provider.dart'; import '../../providers/key_provider.dart'; import '../../theme/app_theme.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/section_header.dart'; /// Minimal add-host form for mobile: label/host/port/username + password or a /// saved key. The full editor (tags, proxy, jump chain, RDP/VNC) is @@ -70,6 +72,7 @@ class _MobileAddHostScreenState extends State { child: ListView( padding: const EdgeInsets.all(16), children: [ + const SectionHeader('Connection'), _field(_label, 'Label', fieldKey: 'host-label'), _field(_address, 'Host / IP', fieldKey: 'host-address'), _field(_port, 'Port', @@ -77,7 +80,8 @@ class _MobileAddHostScreenState extends State { keyboard: TextInputType.number, formatters: [FilteringTextInputFormatter.digitsOnly]), _field(_username, 'Username', fieldKey: 'host-username'), - const SizedBox(height: 8), + const SizedBox(height: MobileTokens.space2), + const SectionHeader('Authentication'), SwitchListTile( value: _useKey, onChanged: From 1c56482d23942c1c61fe060d4f157726639b930e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 25 Jun 2026 21:06:13 +0700 Subject: [PATCH 52/96] fix(mobile): correct history ordering, prune pinch map, drop dead snippets sheet --- .../screens/mobile_sessions_screen.dart | 2 + .../terminal/mobile_snippets_sheet.dart | 76 ------------------- .../mobile/terminal/terminal_side_panel.dart | 2 +- .../mobile/mobile_snippets_sheet_test.dart | 44 ----------- 4 files changed, 3 insertions(+), 121 deletions(-) delete mode 100644 app/lib/mobile/terminal/mobile_snippets_sheet.dart delete mode 100644 app/test/mobile/mobile_snippets_sheet_test.dart diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart index 656c62ac..5d5b2161 100644 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ b/app/lib/mobile/screens/mobile_sessions_screen.dart @@ -61,6 +61,8 @@ class _MobileSessionsScreenState extends State { Widget build(BuildContext context) { final sp = context.watch(); final ssh = sp.sshSessions; + // Drop pinch-zoom overrides for sessions that have since closed. + _pinch.removeWhere((id, _) => ssh.every((s) => s.id != id)); if (ssh.isEmpty) { return const Scaffold( diff --git a/app/lib/mobile/terminal/mobile_snippets_sheet.dart b/app/lib/mobile/terminal/mobile_snippets_sheet.dart deleted file mode 100644 index b3b43b38..00000000 --- a/app/lib/mobile/terminal/mobile_snippets_sheet.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:yourssh_snippets/yourssh_snippets.dart'; - -import '../../theme/app_theme.dart'; - -/// Bottom-sheet list of snippets; tapping one inserts its command into the -/// active terminal (via [onInsert]) and closes the sheet. -class MobileSnippetsSheet extends StatefulWidget { - final void Function(String command) onInsert; - const MobileSnippetsSheet({super.key, required this.onInsert}); - - @override - State createState() => _MobileSnippetsSheetState(); -} - -class _MobileSnippetsSheetState extends State { - String _query = ''; - - @override - Widget build(BuildContext context) { - final all = context.watch().snippets; - final shown = filterSnippets(all, _query); - - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - onChanged: (v) => setState(() => _query = v), - style: const TextStyle(color: AppColors.textPrimary), - decoration: const InputDecoration( - hintText: 'Search snippets', - prefixIcon: Icon(Icons.search, color: AppColors.textSecondary), - ), - ), - const SizedBox(height: 8), - Flexible( - child: shown.isEmpty - ? const Padding( - padding: EdgeInsets.all(24), - child: Text('No snippets', - style: TextStyle(color: AppColors.textSecondary)), - ) - : ListView.builder( - shrinkWrap: true, - itemCount: shown.length, - itemBuilder: (_, i) { - final s = shown[i]; - return ListTile( - title: Text(s.label, - style: - const TextStyle(color: AppColors.textPrimary)), - subtitle: Text(s.command, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 12, - fontFamily: 'monospace')), - onTap: () { - widget.onInsert(s.command); - Navigator.of(context).pop(); - }, - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/mobile/terminal/terminal_side_panel.dart b/app/lib/mobile/terminal/terminal_side_panel.dart index 5a7878bf..77991544 100644 --- a/app/lib/mobile/terminal/terminal_side_panel.dart +++ b/app/lib/mobile/terminal/terminal_side_panel.dart @@ -240,7 +240,7 @@ class _HistoryTab extends StatelessWidget { return ListView.builder( itemCount: entries.length, itemBuilder: (_, i) { - final cmd = entries[entries.length - 1 - i]; // newest first + final cmd = entries[i]; // entries is already newest-first (addFirst) return ListTile( title: Text(cmd, maxLines: 1, diff --git a/app/test/mobile/mobile_snippets_sheet_test.dart b/app/test/mobile/mobile_snippets_sheet_test.dart deleted file mode 100644 index 1b2dbd1c..00000000 --- a/app/test/mobile/mobile_snippets_sheet_test.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:yourssh_snippets/yourssh_snippets.dart'; -import 'package:yourssh/mobile/terminal/mobile_snippets_sheet.dart'; - -Future _pump(WidgetTester tester, - {required void Function(String) onInsert}) async { - final sp = SnippetProvider(); - await tester.pumpWidget(MaterialApp( - home: ChangeNotifierProvider.value( - value: sp, - child: Scaffold(body: MobileSnippetsSheet(onInsert: onInsert)), - ), - )); - await tester.pumpAndSettle(); -} - -void main() { - setUp(() => SharedPreferences.setMockInitialValues({})); - - testWidgets('tapping a snippet inserts its command', (tester) async { - String? inserted; - await _pump(tester, onInsert: (c) => inserted = c); - - final firstTile = find.byType(ListTile).first; - expect(firstTile, findsOneWidget); - await tester.tap(firstTile); - await tester.pumpAndSettle(); - - expect(inserted, isNotNull); - expect(inserted!.isNotEmpty, isTrue); - }); - - testWidgets('search filters the list', (tester) async { - await _pump(tester, onInsert: (_) {}); - final before = tester.widgetList(find.byType(ListTile)).length; - await tester.enterText(find.byType(TextField), 'zzz-no-match-xyz'); - await tester.pumpAndSettle(); - final after = tester.widgetList(find.byType(ListTile)).length; - expect(after, lessThanOrEqualTo(before)); - }); -} From aaa40a3d10e5e923fe657d45a046054540ef2920 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 19:52:59 +0700 Subject: [PATCH 53/96] feat(mobile): edit host via long-press menu (reuse add-host form) --- .../screens/mobile_add_host_screen.dart | 82 ++++++++++++++----- .../mobile/screens/mobile_hosts_screen.dart | 42 +++++++++- .../mobile/mobile_add_host_screen_test.dart | 54 ++++++++++-- 3 files changed, 148 insertions(+), 30 deletions(-) diff --git a/app/lib/mobile/screens/mobile_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart index 2acc93b7..b24bbf8c 100644 --- a/app/lib/mobile/screens/mobile_add_host_screen.dart +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -10,11 +10,15 @@ import '../../theme/app_theme.dart'; import '../theme/mobile_tokens.dart'; import '../widgets/section_header.dart'; -/// Minimal add-host form for mobile: label/host/port/username + password or a -/// saved key. The full editor (tags, proxy, jump chain, RDP/VNC) is -/// desktop-only. +/// Minimal add/edit host form for mobile: label/host/port/username + password +/// or a saved key. Pass [existing] to edit an existing host (preserving its +/// id, tags, jump chain, and other desktop-only fields); omit it to add a new +/// one. The full editor (tags, proxy, jump chain, RDP/VNC) is desktop-only. class MobileAddHostScreen extends StatefulWidget { - const MobileAddHostScreen({super.key}); + /// The host to edit, or null to create a new one. + final Host? existing; + + const MobileAddHostScreen({super.key, this.existing}); @override State createState() => _MobileAddHostScreenState(); @@ -29,6 +33,22 @@ class _MobileAddHostScreenState extends State { bool _useKey = false; String? _keyId; + bool get _isEdit => widget.existing != null; + + @override + void initState() { + super.initState(); + final h = widget.existing; + if (h != null) { + _label.text = h.label; + _address.text = h.host; + _port.text = '${h.port}'; + _username.text = h.username; + _useKey = h.authType == AuthType.privateKey; + _keyId = h.keyId; + } + } + @override void dispose() { for (final c in [_label, _address, _port, _username, _password]) { @@ -43,18 +63,39 @@ class _MobileAddHostScreenState extends State { final username = _username.text.trim(); if (label.isEmpty || address.isEmpty || username.isEmpty) return; - final host = Host( - label: label, - host: address, - port: int.tryParse(_port.text.trim()) ?? 22, - username: username, - authType: _useKey ? AuthType.privateKey : AuthType.password, - keyId: _useKey ? _keyId : null, - ); - await context.read().addHost( - host, - password: _useKey ? null : _password.text, - ); + final port = int.tryParse(_port.text.trim()) ?? 22; + final authType = _useKey ? AuthType.privateKey : AuthType.password; + final keyId = _useKey ? _keyId : null; + final provider = context.read(); + final existing = widget.existing; + + if (existing != null) { + // Preserve id, tags, jump chain, and other fields the minimal form + // doesn't expose; only overwrite what's editable here. A blank password + // leaves the stored one untouched (updateHost skips empty passwords). + final updated = existing.copyWith( + label: label, + host: address, + port: port, + username: username, + authType: authType, + keyId: keyId, + ); + await provider.updateHost( + updated, + password: _useKey ? null : _password.text, + ); + } else { + final host = Host( + label: label, + host: address, + port: port, + username: username, + authType: authType, + keyId: keyId, + ); + await provider.addHost(host, password: _useKey ? null : _password.text); + } if (mounted) Navigator.of(context).pop(); } @@ -65,7 +106,7 @@ class _MobileAddHostScreenState extends State { backgroundColor: AppColors.bg, appBar: AppBar( backgroundColor: AppColors.bg, - title: const Text('Add host'), + title: Text(_isEdit ? 'Edit host' : 'Add host'), actions: [TextButton(onPressed: _save, child: const Text('Save'))], ), body: SafeArea( @@ -106,7 +147,9 @@ class _MobileAddHostScreenState extends State { ) else _field(_password, 'Password', - fieldKey: 'host-password', obscure: true), + fieldKey: 'host-password', + obscure: true, + hint: _isEdit ? 'Leave blank to keep current' : null), ], ), ), @@ -118,6 +161,7 @@ class _MobileAddHostScreenState extends State { String label, { required String fieldKey, bool obscure = false, + String? hint, TextInputType? keyboard, List? formatters, }) { @@ -130,7 +174,7 @@ class _MobileAddHostScreenState extends State { keyboardType: keyboard, inputFormatters: formatters, style: const TextStyle(color: AppColors.textPrimary), - decoration: InputDecoration(labelText: label), + decoration: InputDecoration(labelText: label, hintText: hint), ), ); } diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 4b608dba..a70cb4e0 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -11,9 +11,11 @@ import '../theme/mobile_tokens.dart'; import '../widgets/host_card.dart'; import '../widgets/status_dot.dart'; import '../widgets/tag_chip.dart'; +import 'mobile_add_host_screen.dart'; /// Hosts tab: searchable list of saved hosts as Termius-style cards; tap to -/// connect, long-press to delete, FAB to add. Tag chips filter the list. +/// connect, long-press for edit/delete actions, FAB to add. Tag chips filter +/// the list. class MobileHostsScreen extends StatefulWidget { final VoidCallback onConnected; final VoidCallback onAddHost; @@ -132,7 +134,7 @@ class _MobileHostsScreenState extends State { host: hosts[i], state: _stateFor(hosts[i], sessions), onTap: () => _connect(hosts[i]), - onLongPress: () => _confirmDelete(hosts[i]), + onLongPress: () => _showActions(hosts[i]), ), ), ), @@ -147,6 +149,42 @@ class _MobileHostsScreenState extends State { widget.onConnected(); } + /// Long-press action sheet: edit (opens the form pre-filled) or delete. + Future _showActions(Host host) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: + const Icon(Icons.edit_outlined, color: AppColors.textPrimary), + title: const Text('Edit', + style: TextStyle(color: AppColors.textPrimary)), + onTap: () { + Navigator.pop(ctx); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MobileAddHostScreen(existing: host), + )); + }, + ), + ListTile( + leading: const Icon(Icons.delete_outline, color: AppColors.red), + title: const Text('Delete', + style: TextStyle(color: AppColors.red)), + onTap: () { + Navigator.pop(ctx); + _confirmDelete(host); + }, + ), + ], + ), + ), + ); + } + Future _confirmDelete(Host host) async { final ok = await showDialog( context: context, diff --git a/app/test/mobile/mobile_add_host_screen_test.dart b/app/test/mobile/mobile_add_host_screen_test.dart index 9c6d2781..11efbda0 100644 --- a/app/test/mobile/mobile_add_host_screen_test.dart +++ b/app/test/mobile/mobile_add_host_screen_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/mobile/screens/mobile_add_host_screen.dart'; @@ -10,17 +11,19 @@ import 'package:yourssh/services/storage_service.dart'; void main() { setUp(() => SharedPreferences.setMockInitialValues({})); + Widget wrap(HostProvider hosts, Widget child) => MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: hosts), + ChangeNotifierProvider(create: (_) => KeyProvider()), + ], + child: child, + ), + ); + testWidgets('saving creates a host in the provider', (tester) async { final hosts = HostProvider(StorageService()); - await tester.pumpWidget(MaterialApp( - home: MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: hosts), - ChangeNotifierProvider(create: (_) => KeyProvider()), - ], - child: const MobileAddHostScreen(), - ), - )); + await tester.pumpWidget(wrap(hosts, const MobileAddHostScreen())); await tester.enterText(find.byKey(const Key('host-label')), 'edge-1'); await tester.enterText(find.byKey(const Key('host-address')), '192.168.1.9'); @@ -33,4 +36,37 @@ void main() { .any((h) => h.label == 'edge-1' && h.host == '192.168.1.9'), isTrue); }); + + testWidgets('edit mode prefills fields and updates the host in place', + (tester) async { + final hosts = HostProvider(StorageService()); + final existing = Host( + label: 'Old Name', + host: '10.0.0.5', + port: 2222, + username: 'root', + tags: const ['prod'], + ); + await hosts.addHost(existing); + + await tester + .pumpWidget(wrap(hosts, MobileAddHostScreen(existing: existing))); + + // Title reflects edit mode and fields are pre-filled. + expect(find.text('Edit host'), findsOneWidget); + expect(find.text('Old Name'), findsOneWidget); + expect(find.text('10.0.0.5'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('host-label')), 'New Name'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Updated in place (no new host) and unrelated fields preserved. + expect(hosts.allHosts.length, 1); + final updated = hosts.allHosts.single; + expect(updated.id, existing.id); + expect(updated.label, 'New Name'); + expect(updated.port, 2222); + expect(updated.tags, ['prod']); + }); } From 2979eb4e5348b9c69dc4c959ba2838bbc9ec5ca9 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 19:58:57 +0700 Subject: [PATCH 54/96] feat(mobile): preserve certificate/agent auth when editing a host --- .../screens/mobile_add_host_screen.dart | 128 ++++++++++++------ .../mobile/mobile_add_host_screen_test.dart | 29 ++++ 2 files changed, 116 insertions(+), 41 deletions(-) diff --git a/app/lib/mobile/screens/mobile_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart index b24bbf8c..b980fb1b 100644 --- a/app/lib/mobile/screens/mobile_add_host_screen.dart +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -35,6 +35,13 @@ class _MobileAddHostScreenState extends State { bool get _isEdit => widget.existing != null; + /// Certificate and agent auth have no UI in this minimal form, so editing + /// such a host shows a read-only note and preserves the auth untouched. + bool get _isCertOrAgent { + final a = widget.existing?.authType; + return a == AuthType.certificate || a == AuthType.agent; + } + @override void initState() { super.initState(); @@ -71,20 +78,32 @@ class _MobileAddHostScreenState extends State { if (existing != null) { // Preserve id, tags, jump chain, and other fields the minimal form - // doesn't expose; only overwrite what's editable here. A blank password - // leaves the stored one untouched (updateHost skips empty passwords). - final updated = existing.copyWith( - label: label, - host: address, - port: port, - username: username, - authType: authType, - keyId: keyId, - ); - await provider.updateHost( - updated, - password: _useKey ? null : _password.text, - ); + // doesn't expose; only overwrite what's editable here. + if (_isCertOrAgent) { + // Certificate / agent auth isn't editable here (no cert-path / agent + // UI), so keep authType + keyId exactly as-is — editing connection + // fields must never silently downgrade it to password. + await provider.updateHost(existing.copyWith( + label: label, + host: address, + port: port, + username: username, + )); + } else { + // A blank password leaves the stored one untouched (updateHost skips + // empty passwords). + await provider.updateHost( + existing.copyWith( + label: label, + host: address, + port: port, + username: username, + authType: authType, + keyId: keyId, + ), + password: _useKey ? null : _password.text, + ); + } } else { final host = Host( label: label, @@ -123,33 +142,37 @@ class _MobileAddHostScreenState extends State { _field(_username, 'Username', fieldKey: 'host-username'), const SizedBox(height: MobileTokens.space2), const SectionHeader('Authentication'), - SwitchListTile( - value: _useKey, - onChanged: - keys.isEmpty ? null : (v) => setState(() => _useKey = v), - title: const Text('Use SSH key', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: keys.isEmpty - ? const Text('No keys imported — using password', - style: - TextStyle(color: AppColors.textSecondary, fontSize: 12)) - : null, - ), - if (_useKey) - DropdownButtonFormField( - initialValue: _keyId, - items: [ - for (final SshKeyEntry k in keys) - DropdownMenuItem(value: k.id, child: Text(k.label)), - ], - onChanged: (v) => setState(() => _keyId = v), - decoration: const InputDecoration(labelText: 'Key'), - ) - else - _field(_password, 'Password', - fieldKey: 'host-password', - obscure: true, - hint: _isEdit ? 'Leave blank to keep current' : null), + if (_isCertOrAgent) + _CertAgentNote(authType: widget.existing!.authType) + else ...[ + SwitchListTile( + value: _useKey, + onChanged: + keys.isEmpty ? null : (v) => setState(() => _useKey = v), + title: const Text('Use SSH key', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: keys.isEmpty + ? const Text('No keys imported — using password', + style: TextStyle( + color: AppColors.textSecondary, fontSize: 12)) + : null, + ), + if (_useKey) + DropdownButtonFormField( + initialValue: _keyId, + items: [ + for (final SshKeyEntry k in keys) + DropdownMenuItem(value: k.id, child: Text(k.label)), + ], + onChanged: (v) => setState(() => _keyId = v), + decoration: const InputDecoration(labelText: 'Key'), + ) + else + _field(_password, 'Password', + fieldKey: 'host-password', + obscure: true, + hint: _isEdit ? 'Leave blank to keep current' : null), + ], ], ), ), @@ -179,3 +202,26 @@ class _MobileAddHostScreenState extends State { ); } } + +/// Read-only authentication note shown when editing a certificate- or +/// agent-authenticated host (which the minimal mobile form can't edit). The +/// auth is preserved as-is on save. +class _CertAgentNote extends StatelessWidget { + final AuthType authType; + const _CertAgentNote({required this.authType}); + + @override + Widget build(BuildContext context) { + final kind = + authType == AuthType.certificate ? 'Certificate' : 'SSH agent'; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.verified_user_outlined, + color: AppColors.textSecondary), + title: Text('$kind authentication', + style: const TextStyle(color: AppColors.textPrimary)), + subtitle: const Text('Configured on desktop — kept unchanged', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ); + } +} diff --git a/app/test/mobile/mobile_add_host_screen_test.dart b/app/test/mobile/mobile_add_host_screen_test.dart index 11efbda0..9cce5d76 100644 --- a/app/test/mobile/mobile_add_host_screen_test.dart +++ b/app/test/mobile/mobile_add_host_screen_test.dart @@ -69,4 +69,33 @@ void main() { expect(updated.port, 2222); expect(updated.tags, ['prod']); }); + + testWidgets('editing a certificate host preserves auth and hides password', + (tester) async { + final hosts = HostProvider(StorageService()); + final cert = Host( + label: 'Cert Box', + host: '10.0.0.7', + username: 'deploy', + authType: AuthType.certificate, + keyId: 'key-1', + ); + await hosts.addHost(cert); + + await tester.pumpWidget(wrap(hosts, MobileAddHostScreen(existing: cert))); + + // Read-only note replaces the password/key controls. + expect(find.text('Certificate authentication'), findsOneWidget); + expect(find.byKey(const Key('host-password')), findsNothing); + + await tester.enterText(find.byKey(const Key('host-label')), 'Cert Box 2'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Connection field changed; auth kept exactly as-is (no downgrade). + final updated = hosts.allHosts.single; + expect(updated.label, 'Cert Box 2'); + expect(updated.authType, AuthType.certificate); + expect(updated.keyId, 'key-1'); + }); } From 611086e8a9eb5cb48d0e3bde2557c82e19c6b882 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 20:47:36 +0700 Subject: [PATCH 55/96] docs: add mobile design-import redesign spec --- .../2026-06-26-mobile-design-import-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-26-mobile-design-import-design.md diff --git a/docs/superpowers/specs/2026-06-26-mobile-design-import-design.md b/docs/superpowers/specs/2026-06-26-mobile-design-import-design.md new file mode 100644 index 00000000..f0cd5bc6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-mobile-design-import-design.md @@ -0,0 +1,201 @@ +# Mobile UI Redesign — Claude Design import (Android) + +**Date:** 2026-06-26 +**Branch:** `feat/android-mobile-app` +**Status:** Approved design +**Design source:** `SSH Client.dc.html` — Claude Design project +`e783a344-8697-4d01-abb1-7cbed4764321` (imported via DesignSync). Row 1 = the +canonical v1 flow (9 screens). Rows 2–3 are exploratory variants and are **not** +part of this spec. + +## Problem + +The current mobile build (`app/lib/mobile/`, ~23 files / ~2,564 LOC, shipped by +the 2026-06-25 Termius-style pass) has a working 4-tab shell — **Hosts · +Sessions · SFTP · Settings** — that reuses every desktop provider/service. A new, +more complete visual design has since been produced (a dark, Apple-inspired +amber-accent client covering nine screens). It differs from what ships today in +information architecture, accent color, and screen coverage: + +- **IA:** new bottom tabs are **Hosts · Snippets · Keys · Settings**. Terminal and + Files move out of the tab bar and become contextual (reached from a host / + session). Snippets and Keys become first-class tabs (no mobile UI today). +- **Accent:** amber `#f7a01a` (today the app uses green `#22C55E`, shared with + desktop via `AppColors`). +- **New screens:** dedicated **Keys**, **Snippets**, **Port forwarding**, and + **Sync / QR pairing** screens (port forwarding has no mobile UI and its service + is not wired into the mobile bootstrap). + +Goal: replace the existing mobile UI with this design — matching its look and IA — +while reusing all connection/provider logic and losing none of the current +mobile feature set (biometric lock, TOFU, P2P import, terminal gestures). + +## Reference design language + +- Pure black screen background `#000`; surfaces `#161618` / `#0c0c0e`; borders + `#232325` / `#1a1a1c`. +- **Accent amber `#f7a01a`** (selected tab, FAB, primary buttons, active toggles, + focused field, cursor). +- Status: green `#22c55e` (online / success), red `#ff6464` (error / warn), + blue `#4da3ff` (paths / folders), yellow `#ffce35` (log warn). +- Fonts: **Manrope** (large headings), **Inter** (UI body), **Roboto Mono** + (hostnames, IPs, terminal, commands, fingerprints). +- iOS-style grouped lists: rounded `14–15px` cards, uppercase section labels + (`11px`, letter-spacing `1px`, `#5d5d63`), hairline row dividers. +- Pill chips (`980px` radius) for filters/folders; `56px` rounded-square FAB; + `78px` blurred bottom tab bar. + +## Non-goals (YAGNI) + +- No change to desktop: `app_theme.dart` (`buildAppTheme`, `AppColors`), + `MainScreen`, and the desktop bootstrap are untouched. Desktop stays green. +- No change to `providers/*`, `services/*`, `models/*`, the `main.dart` + `isMobilePlatform` gate, or `runtime_platform.dart` (additive wiring only in the + mobile bootstrap). +- No new SSH/terminal engine — reuse `xterm` + existing accessory bar / side panel + / gestures, reskinned. +- No RDP / VNC / local-shell / plugins / recording / audit UI on mobile. +- No tablet/foldable multi-pane layout (phone only). +- Rows 2–3 of the design (Terminal / Hosts variants) are out of scope. + +## Approach + +**Rewrite in place + a mobile theme layer.** Keep the `app/lib/mobile/` root, +bootstrap pattern, providers, security (app-lock / TOFU), and terminal engine. +Add a mobile-only amber theme, expand the token/primitive layer, rebuild the four +tab screens, and add the new contextual + tab screens. Rejected: + +- *Parallel `mobile_v2/`*: duplicates bootstrap + code, two UIs to maintain — not + justified. +- *Reskin only* (colors/spacing, keep old IA): cannot deliver the new IA, Keys / + Snippets / Port-forwarding screens — fails the brief. + +## Architecture + +``` +app/lib/mobile/ +├── theme/ +│ ├── mobile_tokens.dart # EXPAND — + section-label, badge, divider tokens +│ └── mobile_theme.dart # NEW — MobileColors (amber) + buildMobileTheme() +├── mobile_app.dart # EDIT — apply buildMobileTheme() +├── mobile_bootstrap.dart # EDIT — wire PortForwardService/Provider, +│ # SnippetProvider, KeyGenService +├── nav/ +│ └── mobile_home_shell.dart # REWRITE — tabs: Hosts/Snippets/Keys/Settings +├── screens/ +│ ├── mobile_hosts_screen.dart # REWRITE — search + folder chips + grouped cards + FAB +│ ├── mobile_add_host_screen.dart # REWRITE — grouped GENERAL/AUTH/ADVANCED form +│ ├── mobile_terminal_screen.dart # NEW (from mobile_sessions_screen) — pushed route +│ ├── mobile_sftp_screen.dart # REWRITE — breadcrumb + sort + typed list (contextual) +│ ├── mobile_keys_screen.dart # NEW — key list + Generate/Import +│ ├── mobile_snippets_screen.dart # NEW — category chips + command cards (tap-to-run) +│ ├── mobile_port_forward_screen.dart # NEW — rule list + start/stop + add (contextual) +│ ├── mobile_settings_screen.dart # REWRITE — sync banner + grouped sections +│ ├── mobile_sync_screen.dart # NEW — Supabase status + QR pairing +│ └── mobile_qr_scan_screen.dart # KEEP — camera scanner (reskin header only) +├── terminal/ # KEEP + reskin: accessory_key_bar, +│ # accessory_bar_controller, terminal_side_panel, +│ # terminal_cursor_gestures +├── security/ # KEEP unchanged: app_lock_gate, tofu_watcher +├── widgets/ # REWRITE to design: host_card, host_avatar, +│ # mobile_card, status_dot, tag_chip, section_header +│ # NEW: latency_badge, list_group, settings_row, +│ # mobile_tab_bar, mobile_fab +├── services/ +│ └── host_reachability_probe.dart # NEW — best-effort TCP-connect latency probe +└── sync/transfer_code.dart # KEEP +``` + +- **Theme isolation:** `buildMobileTheme()` clones the shared dark theme and + overrides `colorScheme.primary`/accent with `MobileColors.accent` amber; applied + only in `mobile_app.dart`. Desktop never sees it. +- **Fonts:** via `google_fonts` (`Manrope`, `Inter`, `Roboto Mono`) — cached after + first load; centralized in `mobile_theme.dart` text-theme builders so screens + reference theme text styles, not raw font names. + +## Navigation & session model + +- **Bottom tabs** (`MobileHomeShell`): Hosts · Snippets · Keys · Settings. +- **Terminal = pushed route** backed by `SessionProvider` (not a tab): + - Tapping a host (online, or to connect) pushes the Terminal focused on that + host's session. A session-tabs strip switches between all live sessions; **+** + opens a host picker to add another. + - Backing out of Terminal does **not** kill sessions (they remain in + `SessionProvider`); a connected host card re-opens Terminal on re-tap. Closing + the last session pops back to Hosts. + - Terminal header `⋮`/icons open **Files (SFTP)** and **Port forwarding** for the + current host (pushed routes). +- **FAB** on Hosts → New host (push). **Settings → Pair new device** → + Sync/QR (push). **QR scan** pushed from Sync. +- Folder chips on Hosts and the "Group" field on New host map to the existing + **host tags** system; grouped list sections are derived from tags. + +## Screens — data sources & key components + +1. **Hosts** — search field, horizontal folder chips (tags), tag-grouped sections, + host card (seeded avatar tile, nickname, `user@ip` mono, latency/offline badge), + FAB +. ← `HostProvider`, `HostReachabilityProbe`, `SessionProvider`. +2. **New host** — grouped form: GENERAL (nickname/host/port) · AUTHENTICATION + (username / method / key + biometric-unlock toggle) · ADVANCED (group=tag, + run-on-connect=`startupSnippet`). "Save & connect". Preserves + RDP/VNC/jump-chain/cert/agent fields on edit. ← `HostProvider`, `KeyProvider`. +3. **Terminal** — session-tabs strip + `xterm` `TerminalView` + reskinned shortcut + bar + soft keyboard; reuse accessory bar, side panel, cursor-drag, pinch-zoom. + ← `SessionProvider`, `SshService`. +4. **Files (SFTP)** — breadcrumb + sort + typed file/folder list with sizes; + upload/download. ← `SftpTransferService` (upgrade `mobile_sftp_screen`). +5. **Keys** — key list (type · #hosts in use · SHA256 fingerprint mono) + + Generate / Import. ← `KeyProvider`, `KeyGenService`. +6. **Snippets** — category filter chips + command cards (mono), tap-to-run into the + active session. ← `SnippetProvider`, `SessionProvider.sendInput`. +7. **Port forwarding** — LOCAL/REMOTE/DYNAMIC rule rows with active/stopped state, + start/stop toggle, "Add rule". ← `PortForwardProvider` + `PortForwardService` + (newly wired in mobile bootstrap, auto-start after `ready`). +8. **Settings** — sync banner + grouped sections: TERMINAL (font/size/theme via + shared `TerminalAppearanceControls`) · SECURITY (biometric = app-lock, auto-lock) + · KEYBOARD & SYNC (shortcut bar toggle, Supabase sync, pair device) + version. + ← `SettingsProvider`, `SyncProvider`, app-lock state. +9. **Sync / QR** — Supabase E2E status + QR pairing render + Scan QR. + ← `SyncService`, `P2PSyncService`, `mobile_qr_scan_screen`. + +## Cross-cutting + +- **Latency badge:** `HostReachabilityProbe` does a best-effort TCP `Socket.connect` + with a short timeout per visible host (debounced / refreshed on pull), reporting + round-trip ms or offline; never blocks the UI and never throws. "N online · M + total" is derived from probe results. Failure → treated as offline. +- **Preserved features:** biometric app-lock (`app_lock_gate`), TOFU host-key dialog + (`tofu_watcher`), P2P QR import, pinch-zoom, long-press cursor-drag — all retained. +- **No feature regressions** versus the current mobile build. + +## Error handling + +- Probe / SFTP / port-forward failures surface inline (badge, snackbar, row state) + and never crash the screen; providers already fail-soft. +- Connect failures route through existing `SessionProvider` error states shown in + the Terminal tab. + +## Testing + +- Widget tests for primitives: host card, list group, settings row, tag/category + chip, status dot, latency badge, tab bar. +- Screen smoke tests (Hosts, Snippets, Keys, Settings, New host, Port forwarding) + with fake/stub providers, following existing mobile test patterns. +- `HostReachabilityProbe` unit-tested with an injected connector (no real sockets). +- `flutter analyze` clean; `flutter test` green. + +## Phasing (drives the implementation plan) + +- **P1 Foundation** — `mobile_theme` (amber) + expanded tokens + google_fonts + + rebuilt primitives + 4-tab `MobileHomeShell` shell. +- **P2 Tab screens** — Hosts (+ reachability probe) · Snippets · Keys · Settings + + New host. +- **P3 Session flow** — Terminal reskin + pushed-route session model + Files. +- **P4 Power features** — Port forwarding (wire `PortForwardService`) + Sync/QR. +- **P5 Polish** — tests, `flutter analyze`, design-fidelity pass. + +## Open decisions (resolved) + +- Latency: **TCP-connect probe** (best-effort ms badge), consistent with the + "full functionality" scope. +- Fonts: **`google_fonts`** (network-on-first-use, then cached). From 416259f823d05de4c0076af3b615cc0ff397c4d9 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 20:51:58 +0700 Subject: [PATCH 56/96] docs: add mobile design-import implementation plan --- .../plans/2026-06-26-mobile-design-import.md | 609 ++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-26-mobile-design-import.md diff --git a/docs/superpowers/plans/2026-06-26-mobile-design-import.md b/docs/superpowers/plans/2026-06-26-mobile-design-import.md new file mode 100644 index 00000000..bf69caac --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-mobile-design-import.md @@ -0,0 +1,609 @@ +# Mobile UI Redesign (Claude Design import) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the existing Android/mobile UI (`app/lib/mobile/`) with the imported Claude Design ("SSH Client.dc.html") — a dark, amber-accent, 9-screen client — reusing all desktop providers/services and losing no current mobile feature. + +**Architecture:** Rewrite in place under `app/lib/mobile/`. Add a mobile-only amber theme (`mobile_theme.dart`) leaving desktop green untouched; expand the token/primitive layer; rebuild the 4 tab screens (Hosts/Snippets/Keys/Settings) and add contextual screens (Terminal/Files/Port-forwarding) plus Sync/QR. Wire `PortForwardService`, `SnippetProvider`, `KeyGenService` into the mobile bootstrap. Keep the `isMobilePlatform` gate, security (app-lock/TOFU), and the `xterm` terminal engine. + +**Tech Stack:** Flutter (Dart), `provider`, `xterm` (local fork), `google_fonts`, `dartssh2` (local fork), `flutter_secure_storage`, `mobile_scanner`, `local_auth`. + +## Global Constraints + +- Desktop is untouched: do NOT edit `app/lib/theme/app_theme.dart`, `app/lib/screens/main_screen.dart`, or the desktop bootstrap. Desktop accent stays green `#22C55E`. +- Do NOT edit `app/lib/providers/*`, `app/lib/services/*`, `app/lib/models/*`, `app/lib/platform/runtime_platform.dart`, or the `main.dart` `isMobilePlatform` gate (mobile bootstrap wiring is additive only). +- Accent (mobile) amber `#f7a01a`. Surfaces `#161618`/`#0c0c0e`, borders `#232325`/`#1a1a1c`, bg `#000`. Status green `#22c55e`, red `#ff6464`, blue `#4da3ff`, yellow `#ffce35`, text `#f5f5f7`/`#8a8a8e`/`#5d5d63`. +- Fonts: Manrope (headings), Inter (UI body), Roboto Mono (host/IP/terminal/command/fingerprint) via `google_fonts`. +- No RDP/VNC/local-shell/plugins/recording/audit UI on mobile. Phone layout only. +- Preserve: biometric app-lock, TOFU dialog, P2P QR import, pinch-zoom, long-press cursor-drag. +- Each task ends with `cd app && flutter analyze` clean for touched files and its tests green. +- Commit messages in English; no attribution/co-author trailers anywhere. +- Verify the exact public API of reused providers/services by reading the source before wiring (signatures below were taken from the codebase map but MUST be confirmed in-file before use). + +--- + +## File Structure + +``` +app/lib/mobile/ +├── theme/ +│ ├── mobile_tokens.dart # EXPAND: + sectionLabel, dividerColor, badge sizes +│ └── mobile_theme.dart # NEW: MobileColors + buildMobileTheme() + text themes +├── mobile_app.dart # EDIT: theme: buildMobileTheme() +├── mobile_bootstrap.dart # EDIT: + PortForward, Snippet, KeyGen wiring +├── screens/ +│ ├── mobile_home_shell.dart # REWRITE: tabs Hosts/Snippets/Keys/Settings +│ ├── mobile_hosts_screen.dart # REWRITE +│ ├── mobile_add_host_screen.dart # REWRITE +│ ├── mobile_terminal_screen.dart # NEW (replaces mobile_sessions_screen.dart) +│ ├── mobile_sftp_screen.dart # REWRITE (contextual) +│ ├── mobile_keys_screen.dart # NEW +│ ├── mobile_snippets_screen.dart # NEW +│ ├── mobile_port_forward_screen.dart # NEW (contextual) +│ ├── mobile_settings_screen.dart # REWRITE +│ ├── mobile_sync_screen.dart # NEW +│ └── mobile_qr_scan_screen.dart # KEEP (reskin header) +├── terminal/ # KEEP + reskin +├── security/ # KEEP unchanged +├── services/ +│ └── host_reachability_probe.dart # NEW +├── widgets/ # REWRITE host_card, host_avatar, mobile_card, +│ # status_dot, tag_chip, section_header +│ # NEW: latency_badge, list_group, settings_row, +│ # mobile_tab_bar, mobile_fab, key_card, snippet_card, +│ # forward_rule_row +└── sync/transfer_code.dart # KEEP +``` + +--- + +## PHASE 1 — FOUNDATION + +### Task 1: Mobile amber theme + expanded tokens + fonts + +**Files:** +- Create: `app/lib/mobile/theme/mobile_theme.dart` +- Modify: `app/lib/mobile/theme/mobile_tokens.dart` +- Modify: `app/pubspec.yaml` (add `google_fonts` if absent) +- Test: `app/test/mobile/mobile_theme_test.dart` + +**Interfaces — Produces:** +```dart +// mobile_theme.dart +abstract final class MobileColors { + static const accent = Color(0xFFF7A01A); + static const accentSoft = Color(0xFF2A2010); // amber tint for selected fills + static const bg = Color(0xFF000000); + static const surface = Color(0xFF161618); + static const surfaceAlt = Color(0xFF0C0C0E); + static const border = Color(0xFF232325); + static const borderSoft = Color(0xFF1A1A1C); + static const textPrimary = Color(0xFFF5F5F7); + static const textMuted = Color(0xFF8A8A8E); + static const textFaint = Color(0xFF5D5D63); + static const green = Color(0xFF22C55E); + static const red = Color(0xFFFF6464); + static const blue = Color(0xFF4DA3FF); + static const yellow = Color(0xFFFFCE35); +} +ThemeData buildMobileTheme(); // dark, colorScheme.primary = accent +TextStyle mobileHeading({double size, FontWeight weight}); // Manrope +TextStyle mobileBody({double size, Color? color, FontWeight? weight}); // Inter +TextStyle mobileMono({double size, Color? color, FontWeight? weight}); // Roboto Mono + +// mobile_tokens.dart additions +abstract final class MobileTokens { + // existing: space1..5, radiusCard=14, radiusPill=22, radiusAvatar=12, + // avatar=44, statusDot=8, accessoryBarHeight=48, touchTarget=44 + static const radiusField = 12.0; + static const fabSize = 56.0; + static const tabBarHeight = 78.0; + static const sectionLabelGap = 8.0; + static TextStyle sectionLabel(); // 11px w600 letterSpacing 1 color textFaint +} +``` + +- [ ] **Step 1: Confirm google_fonts + read existing tokens** + Run: `cd app && grep -n "google_fonts" pubspec.yaml; sed -n '1,40p' lib/mobile/theme/mobile_tokens.dart` + If `google_fonts` absent, add it under dependencies and run `cd app && flutter pub get`. + +- [ ] **Step 2: Write failing test** (`app/test/mobile/mobile_theme_test.dart`) +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/mobile/theme/mobile_tokens.dart'; + +void main() { + test('mobile theme primary is amber', () { + expect(buildMobileTheme().colorScheme.primary, MobileColors.accent); + expect(buildMobileTheme().brightness, Brightness.dark); + }); + test('section label token is faint + spaced', () { + final s = MobileTokens.sectionLabel(); + expect(s.color, MobileColors.textFaint); + expect(s.letterSpacing, 1.0); + }); + testWidgets('mono style uses a non-null fontFamily', (t) async { + expect(mobileMono(size: 12).fontFamily, isNotNull); + }); +} +``` + (Replace `yourssh` with the actual package name from `pubspec.yaml` `name:` — confirm in Step 1.) + +- [ ] **Step 3: Run test, expect FAIL** — Run: `cd app && flutter test test/mobile/mobile_theme_test.dart` → fails (files missing). + +- [ ] **Step 4: Implement `mobile_theme.dart`** — define `MobileColors`, `buildMobileTheme()` (start from a dark `ThemeData`, set `colorScheme: ColorScheme.fromSeed(seedColor: MobileColors.accent, brightness: Brightness.dark)` then `.copyWith(primary: accent, surface: surface)`, `scaffoldBackgroundColor: bg`), and the three `google_fonts` text-style helpers (`GoogleFonts.manrope`, `GoogleFonts.inter`, `GoogleFonts.robotoMono`) with sane defaults. + +- [ ] **Step 5: Implement `mobile_tokens.dart` additions** — add the new constants + `sectionLabel()`. + +- [ ] **Step 6: Run test, expect PASS** — Run: `cd app && flutter test test/mobile/mobile_theme_test.dart`. + +- [ ] **Step 7: Analyze + commit** +```bash +cd app && flutter analyze lib/mobile/theme && cd .. && \ +git add app/lib/mobile/theme app/test/mobile/mobile_theme_test.dart app/pubspec.yaml app/pubspec.lock && \ +git commit -m "feat(mobile): amber theme, expanded tokens, bundled fonts" +``` + +--- + +### Task 2: Core list primitives + +**Files:** +- Modify: `app/lib/mobile/widgets/mobile_card.dart`, `section_header.dart`, `status_dot.dart`, `tag_chip.dart` +- Create: `app/lib/mobile/widgets/latency_badge.dart`, `list_group.dart`, `settings_row.dart` +- Test: `app/test/mobile/widgets/primitives_test.dart` + +**Interfaces — Produces:** +```dart +// status_dot.dart — reskin (keep ctor): StatusDot(state) → online/connecting/offline color +// tag_chip.dart — TagChip(label, selected, onTap) → amber fill when selected, else surface +// section_header.dart — SectionHeader(text) → uppercase faint label +// mobile_card.dart — MobileCard({child, onTap, onLongPress, padding}) → #161618 + #232325 border, r15 +// latency_badge.dart +class LatencyBadge extends StatelessWidget { const LatencyBadge({this.ms, this.offline=false}); final int? ms; final bool offline; } +// ms<100 green pill (mono), offline -> grey "offline" pill +// list_group.dart — iOS grouped container with hairline dividers between children +class ListGroup extends StatelessWidget { const ListGroup({required this.children, this.label}); final List children; final String? label; } +// settings_row.dart +class SettingsRow extends StatelessWidget { + const SettingsRow({this.leading, required this.title, this.value, this.trailing, this.onTap, this.toggle, this.onToggle}); + // value (mono/grey), trailing chevron, or a toggle (amber when on) +} +``` + +- [ ] **Step 1: Write failing widget tests** — for each primitive pump it inside `MaterialApp(theme: buildMobileTheme())` and assert: `LatencyBadge(ms:24)` renders "24ms"; `LatencyBadge(offline:true)` renders "offline"; `TagChip(selected:true)` decoration color == `MobileColors.accent`; `ListGroup` with 2 rows renders 1 divider; `SettingsRow(toggle:true)` shows a `Switch`. +- [ ] **Step 2: Run, expect FAIL.** Run: `cd app && flutter test test/mobile/widgets/primitives_test.dart` +- [ ] **Step 3: Implement / reskin the 7 widgets** to the design values (colors/radii from Global Constraints; section label via `MobileTokens.sectionLabel()`; dividers `#232325` inset 15px left). +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** +```bash +cd app && flutter analyze lib/mobile/widgets && cd .. && \ +git add app/lib/mobile/widgets app/test/mobile/widgets/primitives_test.dart && \ +git commit -m "feat(mobile): list primitives (card, group, settings row, latency badge, chips)" +``` + +--- + +### Task 3: Host avatar + host card + +**Files:** +- Modify: `app/lib/mobile/widgets/host_avatar.dart`, `app/lib/mobile/widgets/host_card.dart` +- Test: `app/test/mobile/widgets/host_card_test.dart` + +**Interfaces — Consumes:** `Host` model (read `app/lib/models/host.dart` for fields: label/nickname, username, host, port, tags). `StatusDot`, `LatencyBadge`. +**Produces:** +```dart +// host_avatar.dart — HostAvatar({required seed, IconData? icon, size}) — rounded-square tile (r11), +// tinted bg by seed (use existing host color palette in app_theme.dart via AppColors — read only), +// protocol/db glyph; small status dot bottom-right. +// host_card.dart +class HostCard extends StatelessWidget { + const HostCard({required this.host, this.online=false, this.connecting=false, this.latencyMs, this.onTap, this.onLongPress}); +} +// layout: avatar | (nickname bold 15.5 / user@ip mono 12.5 faint) | (latency badge + chevron) +``` + +- [ ] **Step 1: Read** `app/lib/models/host.dart` and current `host_card.dart`/`host_avatar.dart` to confirm field names. +- [ ] **Step 2: Write failing test** — pump `HostCard` with a fake `Host(label:'web-01', username:'deploy', host:'10.0.4.21', port:22)`, `latencyMs:24`; assert finds "web-01", "deploy@10.0.4.21", "24ms". +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** avatar + card per design (mono user@ip line, latency badge, chevron `#5d5d63`). +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/widgets && cd .. && \ +git add app/lib/mobile/widgets/host_card.dart app/lib/mobile/widgets/host_avatar.dart app/test/mobile/widgets/host_card_test.dart && \ +git commit -m "feat(mobile): redesigned host card + avatar" +``` + +--- + +### Task 4: Custom tab bar + home shell + +**Files:** +- Create: `app/lib/mobile/widgets/mobile_tab_bar.dart`, `app/lib/mobile/widgets/mobile_fab.dart` +- Rewrite: `app/lib/mobile/screens/mobile_home_shell.dart` +- Modify: `app/lib/mobile/mobile_app.dart` (apply `buildMobileTheme()`) +- Test: `app/test/mobile/home_shell_test.dart` + +**Interfaces — Produces:** +```dart +// mobile_tab_bar.dart +enum MobileTab { hosts, snippets, keys, settings } +class MobileTabBar extends StatelessWidget { + const MobileTabBar({required this.current, required this.onSelect}); + final MobileTab current; final ValueChanged onSelect; +} // 78px, blurred (BackdropFilter), amber active icon+label, design SVG-equivalent Material icons +// mobile_fab.dart — MobileFab({onTap}) — 56px r18 amber square, white + icon +// mobile_home_shell.dart — MobileHomeShell — IndexedStack over 4 screens + MobileTabBar bottom +``` + +- [ ] **Step 1: Implement** `MobileTabBar` (4 tabs: Hosts/Snippets/Keys/Settings) + `MobileFab`. +- [ ] **Step 2: Rewrite `MobileHomeShell`** to host an `IndexedStack` of the four tab screens. Until those screens exist (later tasks), use a temporary `Center(child: Text(''))` body per tab — replaced as each screen task lands. +- [ ] **Step 3: Edit `mobile_app.dart`** to pass `theme: buildMobileTheme()` to the `MaterialApp`. +- [ ] **Step 4: Write smoke test** — pump `MobileHomeShell` in `buildMobileTheme()`; assert 4 tab labels present; tapping "Keys" switches the visible body. +- [ ] **Step 5: Run, expect PASS.** Run: `cd app && flutter test test/mobile/home_shell_test.dart` +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_home_shell.dart lib/mobile/widgets lib/mobile/mobile_app.dart && cd .. && \ +git add app/lib/mobile/widgets/mobile_tab_bar.dart app/lib/mobile/widgets/mobile_fab.dart app/lib/mobile/screens/mobile_home_shell.dart app/lib/mobile/mobile_app.dart app/test/mobile/home_shell_test.dart && \ +git commit -m "feat(mobile): amber tab bar + 4-tab home shell" +``` + +--- + +## PHASE 2 — TAB SCREENS + NEW HOST + +### Task 5: Host reachability probe + +**Files:** +- Create: `app/lib/mobile/services/host_reachability_probe.dart` +- Test: `app/test/mobile/services/host_reachability_probe_test.dart` + +**Interfaces — Produces:** +```dart +enum HostReachState { unknown, probing, online, offline } +class HostPing { const HostPing(this.state, [this.ms]); final HostReachState state; final int? ms; } +typedef Connector = Future Function(String host, int port, Duration timeout); +class HostReachabilityProbe extends ChangeNotifier { + HostReachabilityProbe({Connector? connector, Duration timeout = const Duration(seconds: 3), DateTime Function()? clock}); + HostPing pingFor(String hostId); + Future probe(String hostId, String host, int port); // sets probing, then online(ms)/offline; never throws + void probeAll(Iterable<({String id, String host, int port})> hosts); // debounced fan-out +} +// Default connector: Socket.connect(host, port, timeout: t).then((s) => s.destroy()) +``` + +- [ ] **Step 1: Write failing unit tests** +```dart +test('online reports ms from injected clock', () async { + var t = DateTime(2026,1,1); + final clock = () => t; + final probe = HostReachabilityProbe( + connector: (h,p,to) async { t = t.add(const Duration(milliseconds: 24)); }, + clock: clock); + await probe.probe('h1','10.0.0.1',22); + expect(probe.pingFor('h1').state, HostReachState.online); + expect(probe.pingFor('h1').ms, 24); +}); +test('connector throw -> offline, no rethrow', () async { + final probe = HostReachabilityProbe(connector: (h,p,to) async => throw const SocketException('x')); + await probe.probe('h2','10.0.0.2',22); + expect(probe.pingFor('h2').state, HostReachState.offline); +}); +``` +- [ ] **Step 2: Run, expect FAIL.** +- [ ] **Step 3: Implement** the probe (measure with injected `clock`; catch all errors → offline; `notifyListeners()` on each state change; `probeAll` debounced via a simple in-flight set). +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** +```bash +cd app && flutter analyze lib/mobile/services && cd .. && \ +git add app/lib/mobile/services app/test/mobile/services && \ +git commit -m "feat(mobile): best-effort host reachability probe" +``` + +--- + +### Task 6: Hosts screen + +**Files:** +- Rewrite: `app/lib/mobile/screens/mobile_hosts_screen.dart` +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` (wire Hosts tab + FAB + provide `HostReachabilityProbe`) +- Test: `app/test/mobile/screens/hosts_screen_test.dart` + +**Design reference (Screen 01):** Title "Hosts" (Manrope 30 w800) + subtitle green dot "N online · M total"; right: menu circle + avatar circle. Search field (`#1c1c1e`, r12, magnifier, "Search hosts, tags, IPs…"). Horizontal folder chips from tags ("All" amber-selected + one per tag). Tag-grouped sections: uppercase `SectionHeader`, then `HostCard`s (gap 9). FAB bottom-right (amber, +). + +**Interfaces — Consumes:** `HostProvider` (read its API: list getter e.g. `allHosts`, `hosts`; tags), `SessionProvider` (`sshSessions`/connection state), `HostReachabilityProbe`, `HostCard`, `TagChip`, `SectionHeader`, `MobileFab`. **Produces:** Hosts tab body; FAB pushes `MobileAddHostScreen`; tapping a host triggers connect + push Terminal (Terminal nav lands in Task 12 — for now call the existing connect path and a TODO-free `_openSession(host)` that Task 12 fills; if Terminal screen exists, push it). + +- [ ] **Step 1: Read** `host_provider.dart` + `session_provider.dart` for exact getters/connect method names. +- [ ] **Step 2: Write smoke test** — fake `HostProvider` with 2 hosts in tags ["Production"], pump screen in shell; assert title "Hosts", both host labels, the "Production" section header, and a FAB present. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** the screen: header, search filter (label/host/tag substring), folder chips (All + distinct tags), grouped `ListView`, FAB. Kick `probe.probeAll(...)` for visible hosts in `initState`/on refresh; feed `latencyMs`/online into `HostCard`. Wire `HostReachabilityProbe` as a `ChangeNotifierProvider` in `mobile_home_shell.dart` (or bootstrap). +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_hosts_screen.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_hosts_screen.dart app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/screens/hosts_screen_test.dart && \ +git commit -m "feat(mobile): redesigned hosts screen with folder chips + latency" +``` + +--- + +### Task 7: New host screen + +**Files:** +- Rewrite: `app/lib/mobile/screens/mobile_add_host_screen.dart` +- Test: `app/test/mobile/screens/add_host_screen_test.dart` + +**Design reference (Screen 02):** Top bar: back "Hosts" (amber) · title "New host" · "Save". Grouped form via `ListGroup`: **GENERAL** (Nickname, Hostname [mono, focused = amber inset], Port [mono]); **AUTHENTICATION** (Username [mono], Method [SSH key / Password chevron], Key [mono chevron], "Unlock with biometrics" toggle amber); **ADVANCED** (Group [tag picker chevron], Run on connect [mono]). Primary button "Save & connect" (amber, r14). + +**Interfaces — Consumes:** `HostProvider` (add/update), `KeyProvider` (key list). **Produces:** create/edit flow that preserves id, tags, jump chain, RDP/VNC, cert/agent fields (carry through `copyWith`), maps Group→tag and Run-on-connect→`startupSnippet`. + +- [ ] **Step 1: Read** current `mobile_add_host_screen.dart` to reuse its save logic & field preservation. +- [ ] **Step 2: Write smoke test** — pump in edit mode for an existing host; assert nickname/hostname/port prefilled; assert tapping Save calls the provider add/update (use a fake). +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** the grouped form using `ListGroup`/`SettingsRow`-style rows + text fields with amber focus; auth method toggle (password vs key vs note for cert/agent preserved read-only); biometric toggle persists to app-lock setting; "Save & connect" saves then triggers connect+open. +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_add_host_screen.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_add_host_screen.dart app/test/mobile/screens/add_host_screen_test.dart && \ +git commit -m "feat(mobile): grouped new/edit host form" +``` + +--- + +### Task 8: Keys screen + +**Files:** +- Create: `app/lib/mobile/screens/mobile_keys_screen.dart`, `app/lib/mobile/widgets/key_card.dart` +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` (Keys tab body) +- Test: `app/test/mobile/screens/keys_screen_test.dart` + +**Design reference (Screen 05):** Title "Keys" + "N keys · M in use", header add/import icons. Cards (`MobileCard`): key glyph tile, name (e.g. `id_ed25519`), subtitle "ED25519 · 4 hosts" / "RSA 4096 · 2 hosts" / "ED25519 · unused", fingerprint line `SHA256:…` (mono, faint), chevron. Bottom two buttons: **Generate** (amber) + **Import** (outline). + +**Interfaces — Consumes:** `KeyProvider` (entries: path, type, fingerprint?, linked hosts count), `KeyGenService` (generate), file picker (import). **Produces:** Keys tab; Generate → key-gen flow (Ed25519 / RSA-4096 / ECDSA per `KeyGenService.probeSshKeygen`); Import → pick PEM + optional passphrase. + +- [ ] **Step 1: Read** `key_provider.dart` + `services/key_gen_service.dart` for entry shape, fingerprint source, and generate/import signatures. +- [ ] **Step 2: Write smoke test** — fake `KeyProvider` with 2 keys; assert names + a `SHA256:` fragment + "Generate"/"Import" buttons. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** `KeyCard` + screen + generate/import dialogs (reuse `KeyGenService` & existing `DeployKeyDialog`/key flows if present; gate RSA/ECDSA on `probeSshKeygen`). +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_keys_screen.dart lib/mobile/widgets/key_card.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_keys_screen.dart app/lib/mobile/widgets/key_card.dart app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/screens/keys_screen_test.dart && \ +git commit -m "feat(mobile): keys screen with generate/import" +``` + +--- + +### Task 9: Snippets screen + +**Files:** +- Create: `app/lib/mobile/screens/mobile_snippets_screen.dart`, `app/lib/mobile/widgets/snippet_card.dart` +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` (Snippets tab body) +- Test: `app/test/mobile/screens/snippets_screen_test.dart` + +**Design reference (Screen 06):** Title "Snippets" + subtitle "Tap to run on " (or "No active session"). Category filter chips (All + categories). Cards (`MobileCard`): title + small category tag + command (mono, faint). Tap → send command into the active session. + +**Interfaces — Consumes:** `SnippetProvider` (snippets: title, command, category/tags), `SessionProvider` (active SSH session + `sendInput(sessionId, text)` — confirm exact method). **Produces:** Snippets tab; tap-to-run. + +- [ ] **Step 1: Read** the `yourssh_snippets` `SnippetProvider` API + `SessionProvider.sendInput`/active-session getter. +- [ ] **Step 2: Write smoke test** — fake providers with 2 snippets + 1 active session; tap a card → assert `sendInput` called with that command (+ newline if that's the convention). +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** `SnippetCard` + screen with category chips + tap-to-run; if no active session, cards show but tapping shows a snackbar "No active session". +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_snippets_screen.dart lib/mobile/widgets/snippet_card.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_snippets_screen.dart app/lib/mobile/widgets/snippet_card.dart app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/screens/snippets_screen_test.dart && \ +git commit -m "feat(mobile): snippets screen with tap-to-run" +``` + +--- + +### Task 10: Settings screen + +**Files:** +- Rewrite: `app/lib/mobile/screens/mobile_settings_screen.dart` +- Modify: `app/lib/mobile/screens/mobile_home_shell.dart` (Settings tab body) +- Test: `app/test/mobile/screens/settings_screen_test.dart` + +**Design reference (Screen 08):** Title "Settings" + filter/edit icon. Sync banner card (left text "Sync active / Supabase · end-to-end encrypted", right amber "On" or toggle). Groups: **TERMINAL** (Font, Font size, Theme — host the shared `TerminalAppearanceControls`); **SECURITY** (Biometric unlock toggle, Auto-lock chevron "After 1 min"); **KEYBOARD & SYNC** (Shortcut key bar toggle, Supabase sync chevron, Pair new device chevron). Footer "YourSSH · Version x.y.z". + +**Interfaces — Consumes:** `SettingsProvider`, `SyncProvider` (`isSupabaseConfigured`, status), app-lock setting accessor, `TerminalAppearanceControls` (from `app/lib/widgets/`), `PackageInfo` (or existing version source). **Produces:** Settings tab; "Pair new device" + "Supabase sync" push `MobileSyncScreen` (Task 16). + +- [ ] **Step 1: Read** current `mobile_settings_screen.dart`, `settings_provider.dart`, `sync_provider.dart`, and how `TerminalAppearanceControls` is constructed. +- [ ] **Step 2: Write smoke test** — pump with fake providers; assert sections "TERMINAL"/"SECURITY"/"KEYBOARD & SYNC" headers + a version string present. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** with `ListGroup`/`SettingsRow`; embed `TerminalAppearanceControls`; biometric toggle → app-lock setting; "Pair new device"/"Supabase sync" navigate to a placeholder route until Task 16 (use a named callback so Task 16 wires the screen). +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_settings_screen.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_settings_screen.dart app/lib/mobile/screens/mobile_home_shell.dart app/test/mobile/screens/settings_screen_test.dart && \ +git commit -m "feat(mobile): grouped settings screen" +``` + +--- + +## PHASE 3 — SESSION FLOW + +### Task 11: Terminal screen + +**Files:** +- Create: `app/lib/mobile/screens/mobile_terminal_screen.dart` (port logic from `mobile_sessions_screen.dart`) +- Delete: `app/lib/mobile/screens/mobile_sessions_screen.dart` (after porting) +- Reskin: `app/lib/mobile/terminal/accessory_key_bar.dart` (visual only — keep behavior) +- Test: `app/test/mobile/screens/terminal_screen_test.dart` + +**Design reference (Screen 03):** Header `#0c0c0e`: back (amber) · centered "● web-01 / deploy@10.0.4.21 (mono)" · split + ⋮ icons. Session-tabs strip (`#0c0c0e`): active tab `#2a2010` amber border with green dot + name + ✕; inactive `#161618`; "+" tile. Terminal output `#000` (xterm). Shortcut bar (`#0c0c0e`, scrollable): esc/tab/ctrl(amber when armed)/alt/▲▼◀▶/|/~/ /. Soft keyboard below. + +**Interfaces — Consumes:** `SessionProvider` (live SSH sessions, active session, close, add), `xterm` `TerminalView`, existing `AccessoryKeyBar`, `TerminalSidePanel`, `CursorDragMapper`, pinch-zoom. **Produces:** +```dart +class MobileTerminalScreen extends StatefulWidget { + const MobileTerminalScreen({this.focusSessionId}); // null = active +} +``` +The ⋮ menu exposes "Files" and "Port forwarding" (routes wired in Tasks 13 & 15 via callbacks/Navigator). + +- [ ] **Step 1: Read** `mobile_sessions_screen.dart` fully; identify the connected-state guard, xterm wiring, accessory bar, side panel, gestures. +- [ ] **Step 2: Write smoke test** — fake `SessionProvider` with one connected session; pump `MobileTerminalScreen`; assert the session tab label + a `TerminalView` (or its key) present. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** the new screen by porting the working terminal/gesture/side-panel code verbatim and re-laying the chrome (header, session-tabs strip, reskinned shortcut bar) per the design. Add a session-tabs strip bound to `SessionProvider`; "+" opens host picker; ✕ closes a session. +- [ ] **Step 5: Delete** `mobile_sessions_screen.dart` and fix imports. +- [ ] **Step 6: Run, expect PASS** + `cd app && flutter analyze lib/mobile`. +- [ ] **Step 7: Commit** +```bash +git add -A app/lib/mobile/screens app/lib/mobile/terminal app/test/mobile/screens/terminal_screen_test.dart && \ +git commit -m "feat(mobile): terminal screen with session tabs + reskinned shortcut bar" +``` + +--- + +### Task 12: Hosts → Terminal navigation + re-entry + +**Files:** +- Modify: `app/lib/mobile/screens/mobile_hosts_screen.dart` (open session → push Terminal), `app/lib/mobile/screens/mobile_terminal_screen.dart` (pop on last-session-close) +- Test: `app/test/mobile/terminal_nav_test.dart` + +**Interfaces — Consumes:** `SessionProvider` connect/open API. **Produces:** tapping a host connects (if needed) and pushes `MobileTerminalScreen(focusSessionId:)`; re-tapping a connected host re-opens it on the live session; closing the last session pops to Hosts. + +- [ ] **Step 1: Write nav test** — fake `SessionProvider`; tap an online host in `MobileHostsScreen` → assert `MobileTerminalScreen` pushed (find by type after `pumpAndSettle`). +- [ ] **Step 2: Run, expect FAIL.** +- [ ] **Step 3: Implement** `_openSession(host)` in Hosts (connect via `SessionProvider`, then `Navigator.push` Terminal); Terminal pops when `sshSessions` becomes empty. +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** +```bash +cd app && flutter analyze lib/mobile/screens && cd .. && \ +git add app/lib/mobile/screens app/test/mobile/terminal_nav_test.dart && \ +git commit -m "feat(mobile): host->terminal navigation and session re-entry" +``` + +--- + +### Task 13: Files (SFTP) screen + +**Files:** +- Rewrite: `app/lib/mobile/screens/mobile_sftp_screen.dart` +- Modify: `app/lib/mobile/screens/mobile_terminal_screen.dart` (⋮ → Files route) +- Test: `app/test/mobile/screens/sftp_screen_test.dart` + +**Design reference (Screen 04):** Header back · "Files / " · upload + ⋮. Breadcrumb (home glyph + `var / www / app`, current bold, mono). Sub-row "N items · size" + sort "Name ▾". List rows: typed icon (folder blue, file types, lock for sensitive), name (mono for dotfiles), child-count/size, chevron. Hairline dividers inset 47px. + +**Interfaces — Consumes:** existing SFTP browsing logic in current `mobile_sftp_screen.dart` + `SftpTransferService`. **Produces:** contextual Files screen for a given host/session; reached from Terminal ⋮. + +- [ ] **Step 1: Read** current `mobile_sftp_screen.dart` for its listdir/channel logic to preserve. +- [ ] **Step 2: Write smoke test** — inject a fake directory listing; assert breadcrumb + file/folder names + sizes render. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** the reskin (breadcrumb, sort row, typed icons, dividers) keeping the listdir/upload/download logic; wire Terminal ⋮ → push this screen for the current host. +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_sftp_screen.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_sftp_screen.dart app/lib/mobile/screens/mobile_terminal_screen.dart app/test/mobile/screens/sftp_screen_test.dart && \ +git commit -m "feat(mobile): redesigned SFTP files browser (contextual)" +``` + +--- + +## PHASE 4 — POWER FEATURES + +### Task 14: Wire PortForward + Snippet + KeyGen into mobile bootstrap + +**Files:** +- Modify: `app/lib/mobile/mobile_bootstrap.dart` +- Test: `app/test/mobile/mobile_bootstrap_test.dart` + +**Interfaces — Consumes:** `PortForwardService`, `PortForwardProvider`, `SnippetProvider`, `KeyGenService` (read their constructors). **Produces:** these providers/services available in the mobile widget tree; `PortForwardService.autoStartAll` invoked after `PortForwardProvider.ready` (mirror desktop wiring in `main.dart`). + +- [ ] **Step 1: Read** `main.dart` desktop wiring for PortForward + the four classes' constructors. +- [ ] **Step 2: Write test** — pump `MobileBootstrap`'s provider tree; assert `PortForwardProvider`, `SnippetProvider` resolvable via `Provider.of` in a child. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** additive wiring (do not remove existing providers); connect `PortForwardService` callbacks to `PortForwardProvider` and call `autoStartAll` after `ready`. +- [ ] **Step 5: Run, expect PASS** + `cd app && flutter analyze lib/mobile/mobile_bootstrap.dart`. +- [ ] **Step 6: Commit** +```bash +git add app/lib/mobile/mobile_bootstrap.dart app/test/mobile/mobile_bootstrap_test.dart && \ +git commit -m "feat(mobile): wire port-forward, snippets, key-gen into bootstrap" +``` + +--- + +### Task 15: Port forwarding screen + +**Files:** +- Create: `app/lib/mobile/screens/mobile_port_forward_screen.dart`, `app/lib/mobile/widgets/forward_rule_row.dart` +- Modify: `app/lib/mobile/screens/mobile_terminal_screen.dart` (⋮ → Port forwarding route) +- Test: `app/test/mobile/screens/port_forward_screen_test.dart` + +**Design reference (Screen 07):** Header back · "● " · ⋮. Section "Forwarding". Rule rows (`MobileCard`): left type tag ("LOCAL"/"DYNAMIC"/"REMOTE") + state ("active" green / "stopped" grey), main line `LOCAL :5432 → db-prod :5432` (mono), toggle/▶. Bottom button "Add forwarding rule" (amber, +). + +**Interfaces — Consumes:** `PortForwardProvider` (rules + status), `PortForwardService` (start/stop). **Produces:** rule list with start/stop toggle + add-rule dialog (LOCAL/REMOTE/DYNAMIC fields), scoped to the current host. + +- [ ] **Step 1: Read** `PortForward` model + `PortForwardProvider`/`PortForwardService` API (add/update/delete, setStatus, start/stop). +- [ ] **Step 2: Write smoke test** — fake provider with 1 active + 1 stopped rule; assert the two mono rule lines + "Add forwarding rule" present; toggling calls start/stop. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** `ForwardRuleRow` + screen + add/edit-rule dialog; wire Terminal ⋮ → push for current host. +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_port_forward_screen.dart lib/mobile/widgets/forward_rule_row.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_port_forward_screen.dart app/lib/mobile/widgets/forward_rule_row.dart app/lib/mobile/screens/mobile_terminal_screen.dart app/test/mobile/screens/port_forward_screen_test.dart && \ +git commit -m "feat(mobile): port forwarding screen with start/stop" +``` + +--- + +### Task 16: Sync / QR pairing screen + +**Files:** +- Create: `app/lib/mobile/screens/mobile_sync_screen.dart` +- Modify: `app/lib/mobile/screens/mobile_settings_screen.dart` (wire pair/sync nav), reskin `app/lib/mobile/screens/mobile_qr_scan_screen.dart` header +- Test: `app/test/mobile/screens/sync_screen_test.dart` + +**Design reference (Screen 09):** Header back "Settings" · "Pair device" · ⋮. Heading "Sync with Supabase" + body "No account needed. Your hosts, keys & snippets stay end-to-end encrypted across devices." QR render card. Caption "Open YourSSH on another device and scan this". Status card "Supabase connected / Last sync · … · 8 hosts, 3 keys" + "E2E" badge. Button "Scan QR code". + +**Interfaces — Consumes:** `SyncProvider` (`isSupabaseConfigured`, status, last sync), `P2PSyncService` (`getLocalInterfaces`, `startServer`), `P2PSyncEncryption` (`generateKey`), `qr_flutter` (confirm dependency; if absent, add it) for QR render, `MobileQrScanScreen`. **Produces:** sync/pair screen; "Scan QR code" pushes `MobileQrScanScreen`; Settings pair/sync rows push this screen. + +- [ ] **Step 1: Read** `sync_provider.dart`, `p2p_sync_service.dart`, `p2p_sync_encryption.dart`, current `mobile_settings_screen.dart` P2P/Supabase logic to reuse; check for a QR-render package in `pubspec.yaml`. +- [ ] **Step 2: Write smoke test** — fake `SyncProvider` configured; assert heading "Sync with Supabase", "Scan QR code" button, and an E2E/connected indicator. +- [ ] **Step 3: Run, expect FAIL.** +- [ ] **Step 4: Implement** the screen (reuse existing P2P start-server + QR URL build; render QR; show Supabase status); wire Settings rows → push; reskin scanner header. +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** +```bash +cd app && flutter analyze lib/mobile/screens/mobile_sync_screen.dart lib/mobile/screens/mobile_settings_screen.dart && cd .. && \ +git add app/lib/mobile/screens/mobile_sync_screen.dart app/lib/mobile/screens/mobile_settings_screen.dart app/lib/mobile/screens/mobile_qr_scan_screen.dart app/test/mobile/screens/sync_screen_test.dart app/pubspec.yaml app/pubspec.lock && \ +git commit -m "feat(mobile): supabase sync + QR pairing screen" +``` + +--- + +## PHASE 5 — POLISH + +### Task 17: Fidelity pass, analyze, full test run + +**Files:** any mobile file needing a visual fix; no new public API. + +- [ ] **Step 1:** Run `cd app && flutter analyze` — fix every warning/error in `lib/mobile` and `test/mobile`. +- [ ] **Step 2:** Run `cd app && flutter test test/mobile` — all green. +- [ ] **Step 3:** Visual fidelity self-check against the design per screen (spacing, colors, fonts, amber accent, blurred tab bar, FAB). Fix mismatches. +- [ ] **Step 4:** Confirm no feature regression: app-lock gate still wraps the app, TOFU dialog still wired, P2P import works, pinch-zoom + cursor-drag intact, no dangling references to deleted `mobile_sessions_screen.dart`. +- [ ] **Step 5: Commit** +```bash +cd app && flutter analyze && flutter test test/mobile && cd .. && \ +git add -A app/lib/mobile app/test/mobile && \ +git commit -m "polish(mobile): fidelity pass, analyze + tests green" +``` + +--- + +## Self-Review (author checklist) + +- **Spec coverage:** Theme amber (T1) · primitives (T2-3) · IA/tab bar (T4) · probe (T5) · Hosts (T6) · New host (T7) · Keys (T8) · Snippets (T9) · Settings (T10) · Terminal (T11) · nav/re-entry (T12) · Files (T13) · bootstrap wiring (T14) · Port-forward (T15) · Sync/QR (T16) · polish (T17). All 9 design screens + cross-cutting covered. +- **Placeholders:** none — temporary tab bodies in T4 are explicitly replaced in T6/T8/T9/T10; Terminal ⋮ routes filled in T13/T15. +- **Type consistency:** `MobileColors`, `MobileTokens`, `MobileTab`, `HostReachabilityProbe`/`HostPing`, `HostCard`, `LatencyBadge`, `ListGroup`, `SettingsRow`, `MobileTerminalScreen({focusSessionId})` used consistently across tasks. +- **Risk note:** every reused provider/service signature MUST be confirmed by reading source in each task's Step 1 (the codebase map is a guide, not ground truth). From f25a0378582562caace053fd14adf422ba26a84b Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 20:55:39 +0700 Subject: [PATCH 57/96] feat(mobile): amber theme, expanded tokens, bundled fonts Add MobileColors palette (amber accent #f7a01a, dark surfaces, status colours), buildMobileTheme() wired to Material 3 ColorScheme, and three Google Fonts helpers (Manrope/Inter/Roboto Mono). Expand MobileTokens to abstract final class with radiusField, fabSize, tabBarHeight, sectionLabelGap, and sectionLabel() text style. Add google_fonts ^6.2.1 dependency. Tests: 3/3 green. --- app/lib/mobile/theme/mobile_theme.dart | 74 +++++++++++++++++++++++++ app/lib/mobile/theme/mobile_tokens.dart | 39 ++++++++++--- app/pubspec.lock | 8 +++ app/pubspec.yaml | 3 + app/test/mobile/mobile_theme_test.dart | 19 +++++++ 5 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 app/lib/mobile/theme/mobile_theme.dart create mode 100644 app/test/mobile/mobile_theme_test.dart diff --git a/app/lib/mobile/theme/mobile_theme.dart b/app/lib/mobile/theme/mobile_theme.dart new file mode 100644 index 00000000..141795db --- /dev/null +++ b/app/lib/mobile/theme/mobile_theme.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Color palette for the mobile UI. Desktop theme ([AppColors]) is untouched. +abstract final class MobileColors { + static const accent = Color(0xFFF7A01A); + static const accentSoft = Color(0xFF2A2010); + static const bg = Color(0xFF000000); + static const surface = Color(0xFF161618); + static const surfaceAlt = Color(0xFF0C0C0E); + static const border = Color(0xFF232325); + static const borderSoft = Color(0xFF1A1A1C); + static const textPrimary = Color(0xFFF5F5F7); + static const textMuted = Color(0xFF8A8A8E); + static const textFaint = Color(0xFF5D5D63); + static const green = Color(0xFF22C55E); + static const red = Color(0xFFFF6464); + static const blue = Color(0xFF4DA3FF); + static const yellow = Color(0xFFFFCE35); +} + +/// Builds the dark [ThemeData] used by the mobile UI. +/// Desktop theme is untouched; this is mobile-only. +ThemeData buildMobileTheme() { + final base = ColorScheme.fromSeed( + seedColor: MobileColors.accent, + brightness: Brightness.dark, + ).copyWith( + primary: MobileColors.accent, + surface: MobileColors.surface, + ); + + return ThemeData( + colorScheme: base, + brightness: Brightness.dark, + scaffoldBackgroundColor: MobileColors.bg, + useMaterial3: true, + ); +} + +/// Manrope — headings. +TextStyle mobileHeading({ + double size = 17, + FontWeight weight = FontWeight.w600, +}) => + GoogleFonts.manrope( + fontSize: size, + fontWeight: weight, + color: MobileColors.textPrimary, + ); + +/// Inter — body / UI text. +TextStyle mobileBody({ + double size = 15, + Color? color, + FontWeight? weight, +}) => + GoogleFonts.inter( + fontSize: size, + fontWeight: weight ?? FontWeight.w400, + color: color ?? MobileColors.textPrimary, + ); + +/// Roboto Mono — monospace / code. +TextStyle mobileMono({ + double size = 13, + Color? color, + FontWeight? weight, +}) => + GoogleFonts.robotoMono( + fontSize: size, + fontWeight: weight ?? FontWeight.w400, + color: color ?? MobileColors.textPrimary, + ); diff --git a/app/lib/mobile/theme/mobile_tokens.dart b/app/lib/mobile/theme/mobile_tokens.dart index bca310e2..59ff8d22 100644 --- a/app/lib/mobile/theme/mobile_tokens.dart +++ b/app/lib/mobile/theme/mobile_tokens.dart @@ -1,20 +1,41 @@ -/// Layout constants for the mobile UI. Colors live in [AppColors]; this is -/// purely spacing/sizing so widgets share one rhythm. -class MobileTokens { - MobileTokens._(); +import 'package:flutter/material.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +/// Layout and typography constants for the mobile UI. +/// Colors live in [MobileColors]; this file is purely spacing/sizing/typography +/// so widgets share one rhythm. +abstract final class MobileTokens { + // ── Spacing ────────────────────────────────────────────────────────────── static const double space1 = 4; static const double space2 = 8; static const double space3 = 12; static const double space4 = 16; static const double space5 = 24; - static const double radiusCard = 14; - static const double radiusPill = 22; + // ── Radii ───────────────────────────────────────────────────────────────── + static const double radiusCard = 14; + static const double radiusPill = 22; static const double radiusAvatar = 12; + static const double radiusField = 12; - static const double avatar = 44; - static const double statusDot = 8; + // ── Sizes ───────────────────────────────────────────────────────────────── + static const double avatar = 44; + static const double statusDot = 8; static const double accessoryBarHeight = 48; - static const double touchTarget = 44; + static const double touchTarget = 44; + static const double fabSize = 56; + static const double tabBarHeight = 78; + + // ── Gaps ────────────────────────────────────────────────────────────────── + static const double sectionLabelGap = 8; + + // ── Typography ──────────────────────────────────────────────────────────── + + /// Section header label: 11 px, w600, letter-spacing 1, [MobileColors.textFaint]. + static TextStyle sectionLabel() => const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + color: MobileColors.textFaint, + ); } diff --git a/app/pubspec.lock b/app/pubspec.lock index 1544010a..6c44ee8e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -424,6 +424,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" gotrue: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index cdaba9b4..9a61d1e8 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -120,6 +120,9 @@ dependencies: package_info_plus: ^9.0.1 + # Google Fonts (Manrope, Inter, Roboto Mono) for the mobile UI + google_fonts: ^6.2.1 + dependency_overrides: dartssh2: diff --git a/app/test/mobile/mobile_theme_test.dart b/app/test/mobile/mobile_theme_test.dart new file mode 100644 index 00000000..5e0fe81a --- /dev/null +++ b/app/test/mobile/mobile_theme_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/mobile/theme/mobile_tokens.dart'; + +void main() { + test('mobile theme primary is amber', () { + expect(buildMobileTheme().colorScheme.primary, MobileColors.accent); + expect(buildMobileTheme().brightness, Brightness.dark); + }); + test('section label token is faint + spaced', () { + final s = MobileTokens.sectionLabel(); + expect(s.color, MobileColors.textFaint); + expect(s.letterSpacing, 1.0); + }); + testWidgets('mono style uses a non-null fontFamily', (t) async { + expect(mobileMono(size: 12).fontFamily, isNotNull); + }); +} From 6db6ddf159492085b3e4930adfb8c802e5941fe0 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:00:14 +0700 Subject: [PATCH 58/96] feat(mobile): list primitives (card, group, settings row, latency badge, chips) --- app/lib/mobile/widgets/latency_badge.dart | 32 +++ app/lib/mobile/widgets/list_group.dart | 60 ++++++ app/lib/mobile/widgets/mobile_card.dart | 11 +- app/lib/mobile/widgets/section_header.dart | 8 +- app/lib/mobile/widgets/settings_row.dart | 92 ++++++++ app/lib/mobile/widgets/status_dot.dart | 8 +- app/lib/mobile/widgets/tag_chip.dart | 12 +- app/test/mobile/widgets/primitives_test.dart | 213 +++++++++++++++++++ 8 files changed, 415 insertions(+), 21 deletions(-) create mode 100644 app/lib/mobile/widgets/latency_badge.dart create mode 100644 app/lib/mobile/widgets/list_group.dart create mode 100644 app/lib/mobile/widgets/settings_row.dart create mode 100644 app/test/mobile/widgets/primitives_test.dart diff --git a/app/lib/mobile/widgets/latency_badge.dart b/app/lib/mobile/widgets/latency_badge.dart new file mode 100644 index 00000000..d0c7f40d --- /dev/null +++ b/app/lib/mobile/widgets/latency_badge.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// Compact pill showing latency in ms (green when fast) or "offline" in grey. +class LatencyBadge extends StatelessWidget { + final int? ms; + final bool offline; + + const LatencyBadge({super.key, this.ms, this.offline = false}); + + @override + Widget build(BuildContext context) { + final color = offline ? MobileColors.textFaint : MobileColors.green; + final label = offline ? 'offline' : '${ms}ms'; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + ), + child: Text( + label, + style: mobileMono(size: 11, color: Colors.black, weight: FontWeight.w600), + ), + ); + } +} diff --git a/app/lib/mobile/widgets/list_group.dart b/app/lib/mobile/widgets/list_group.dart new file mode 100644 index 00000000..e4f3caba --- /dev/null +++ b/app/lib/mobile/widgets/list_group.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// iOS-style grouped container with hairline dividers between children +/// and an optional uppercase section label above. +class ListGroup extends StatelessWidget { + final List children; + final String? label; + + const ListGroup({super.key, required this.children, this.label}); + + @override + Widget build(BuildContext context) { + final rows = []; + for (int i = 0; i < children.length; i++) { + rows.add(children[i]); + if (i < children.length - 1) { + rows.add(const Divider( + height: 1, + thickness: 0.5, + indent: 15, + endIndent: 0, + color: MobileColors.border, + )); + } + } + + final grouped = Container( + decoration: BoxDecoration( + color: MobileColors.surface, + borderRadius: BorderRadius.circular(MobileTokens.radiusCard), + border: Border.all(color: MobileColors.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: rows, + ), + ); + + if (label == null) return grouped; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only( + left: MobileTokens.space1, + bottom: MobileTokens.sectionLabelGap, + ), + child: Text(label!.toUpperCase(), style: MobileTokens.sectionLabel()), + ), + grouped, + ], + ); + } +} diff --git a/app/lib/mobile/widgets/mobile_card.dart b/app/lib/mobile/widgets/mobile_card.dart index 9a4aa158..5fa865a0 100644 --- a/app/lib/mobile/widgets/mobile_card.dart +++ b/app/lib/mobile/widgets/mobile_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; /// The single source of card styling for the mobile UI: card surface, rounded @@ -19,13 +19,16 @@ class MobileCard extends StatelessWidget { this.padding, }); + // Card radius per spec: 15 + static const double _radius = 15; + @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(MobileTokens.radiusCard), - border: Border.all(color: AppColors.border), + color: MobileColors.surface, + borderRadius: BorderRadius.circular(_radius), + border: Border.all(color: MobileColors.border), ), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/app/lib/mobile/widgets/section_header.dart b/app/lib/mobile/widgets/section_header.dart index 6b067699..4e8b9c93 100644 --- a/app/lib/mobile/widgets/section_header.dart +++ b/app/lib/mobile/widgets/section_header.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../../theme/app_theme.dart'; import '../theme/mobile_tokens.dart'; /// Uppercase, letter-spaced section label for grouped lists/settings. @@ -18,12 +17,7 @@ class SectionHeader extends StatelessWidget { ), child: Text( title.toUpperCase(), - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.8, - ), + style: MobileTokens.sectionLabel(), ), ); } diff --git a/app/lib/mobile/widgets/settings_row.dart b/app/lib/mobile/widgets/settings_row.dart new file mode 100644 index 00000000..ebfb82e4 --- /dev/null +++ b/app/lib/mobile/widgets/settings_row.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// A single settings / info row supporting: +/// - optional leading icon widget +/// - required title +/// - optional value (rendered in mono/grey) +/// - optional trailing widget (overrides default chevron/toggle) +/// - chevron when [onTap] is set (and no toggle) +/// - amber [Switch] when [toggle] is set +class SettingsRow extends StatelessWidget { + final Widget? leading; + final String title; + final String? value; + final Widget? trailing; + final VoidCallback? onTap; + final bool? toggle; + final ValueChanged? onToggle; + + const SettingsRow({ + super.key, + this.leading, + required this.title, + this.value, + this.trailing, + this.onTap, + this.toggle, + this.onToggle, + }); + + Widget _buildTrailing() { + if (trailing != null) return trailing!; + + if (toggle != null) { + return Switch( + value: toggle!, + onChanged: onToggle, + activeThumbColor: MobileColors.accent, + activeTrackColor: MobileColors.accent.withAlpha(77), + ); + } + + if (onTap != null) { + return const Icon( + Icons.chevron_right, + color: MobileColors.textFaint, + size: 20, + ); + } + + return const SizedBox.shrink(); + } + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), + child: Row( + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: MobileTokens.space3), + ], + Expanded( + child: Text( + title, + style: mobileBody(color: MobileColors.textPrimary), + ), + ), + if (value != null) ...[ + const SizedBox(width: MobileTokens.space2), + Text( + value!, + style: mobileMono(size: 13, color: MobileColors.textMuted), + ), + ], + const SizedBox(width: MobileTokens.space2), + _buildTrailing(), + ], + ), + ); + + if (onTap != null) { + return InkWell(onTap: onTap, child: content); + } + return content; + } +} diff --git a/app/lib/mobile/widgets/status_dot.dart b/app/lib/mobile/widgets/status_dot.dart index 34e64de6..9ac7de3a 100644 --- a/app/lib/mobile/widgets/status_dot.dart +++ b/app/lib/mobile/widgets/status_dot.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; /// Coarse connection state for a host row / session, decoupled from @@ -8,9 +8,9 @@ import '../theme/mobile_tokens.dart'; enum HostConnState { connected, connecting, offline } Color statusColor(HostConnState s) => switch (s) { - HostConnState.connected => AppColors.accent, - HostConnState.connecting => AppColors.orange, - HostConnState.offline => AppColors.textTertiary, + HostConnState.connected => MobileColors.green, + HostConnState.connecting => MobileColors.accent, + HostConnState.offline => MobileColors.textFaint, }; class StatusDot extends StatelessWidget { diff --git a/app/lib/mobile/widgets/tag_chip.dart b/app/lib/mobile/widgets/tag_chip.dart index 4a29c735..10928473 100644 --- a/app/lib/mobile/widgets/tag_chip.dart +++ b/app/lib/mobile/widgets/tag_chip.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; -/// Compact pill for a host tag or a filter chip. [selected] tints it with the -/// accent; tappable when [onTap] is provided. +/// Compact pill for a host tag or a filter chip. [selected] fills it with the +/// accent color; tappable when [onTap] is provided. class TagChip extends StatelessWidget { final String label; final bool selected; @@ -14,14 +14,14 @@ class TagChip extends StatelessWidget { @override Widget build(BuildContext context) { - final fg = selected ? Colors.black : AppColors.textSecondary; - final bg = selected ? AppColors.accent : AppColors.bg; + final fg = selected ? Colors.black : MobileColors.textMuted; + final bg = selected ? MobileColors.accent : MobileColors.surface; final chip = Container( padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2, vertical: 3), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(MobileTokens.radiusPill), - border: Border.all(color: AppColors.border), + border: Border.all(color: MobileColors.border), ), child: Text(label, style: TextStyle(color: fg, fontSize: 11)), ); diff --git a/app/test/mobile/widgets/primitives_test.dart b/app/test/mobile/widgets/primitives_test.dart new file mode 100644 index 00000000..fb4f8901 --- /dev/null +++ b/app/test/mobile/widgets/primitives_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/mobile/theme/mobile_tokens.dart'; +import 'package:yourssh/mobile/widgets/latency_badge.dart'; +import 'package:yourssh/mobile/widgets/tag_chip.dart'; +import 'package:yourssh/mobile/widgets/list_group.dart'; +import 'package:yourssh/mobile/widgets/settings_row.dart'; +import 'package:yourssh/mobile/widgets/mobile_card.dart'; +import 'package:yourssh/mobile/widgets/section_header.dart'; +import 'package:yourssh/mobile/widgets/status_dot.dart'; + +Widget _wrap(Widget child) => + MaterialApp(theme: buildMobileTheme(), home: Scaffold(body: child)); + +void main() { + // ── LatencyBadge ────────────────────────────────────────────────────────── + group('LatencyBadge', () { + testWidgets('renders "24ms" for ms:24', (tester) async { + await tester.pumpWidget(_wrap(const LatencyBadge(ms: 24))); + expect(find.text('24ms'), findsOneWidget); + }); + + testWidgets('renders "offline" when offline:true', (tester) async { + await tester.pumpWidget(_wrap(const LatencyBadge(offline: true))); + expect(find.text('offline'), findsOneWidget); + }); + + testWidgets('ms<100 uses green pill', (tester) async { + await tester.pumpWidget(_wrap(const LatencyBadge(ms: 50))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.color, MobileColors.green); + }); + + testWidgets('offline pill uses grey (textFaint) color', (tester) async { + await tester.pumpWidget(_wrap(const LatencyBadge(offline: true))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.color, MobileColors.textFaint); + }); + }); + + // ── TagChip ─────────────────────────────────────────────────────────────── + group('TagChip', () { + testWidgets('selected chip decoration color is accent', (tester) async { + await tester.pumpWidget( + _wrap(const TagChip(label: 'prod', selected: true))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.color, MobileColors.accent); + }); + + testWidgets('unselected chip decoration color is surface', (tester) async { + await tester.pumpWidget( + _wrap(const TagChip(label: 'dev', selected: false))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.color, MobileColors.surface); + }); + }); + + // ── SectionHeader ───────────────────────────────────────────────────────── + group('SectionHeader', () { + testWidgets('renders uppercase text', (tester) async { + await tester.pumpWidget(_wrap(const SectionHeader('hosts'))); + expect(find.text('HOSTS'), findsOneWidget); + }); + + testWidgets('uses sectionLabel style color', (tester) async { + await tester.pumpWidget(_wrap(const SectionHeader('general'))); + final text = tester.widget(find.byType(Text).first); + expect(text.style?.color, MobileColors.textFaint); + }); + }); + + // ── MobileCard ──────────────────────────────────────────────────────────── + group('MobileCard', () { + testWidgets('renders child', (tester) async { + await tester.pumpWidget( + _wrap(const MobileCard(child: Text('card content')))); + expect(find.text('card content'), findsOneWidget); + }); + + testWidgets('card background is surface (#161618)', (tester) async { + await tester.pumpWidget( + _wrap(const MobileCard(child: Text('x')))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.color, MobileColors.surface); + }); + + testWidgets('card border color is border (#232325)', (tester) async { + await tester.pumpWidget( + _wrap(const MobileCard(child: Text('x')))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + final borderSide = (deco.border as Border).top; + expect(borderSide.color, MobileColors.border); + }); + + testWidgets('card radius is 15', (tester) async { + await tester.pumpWidget( + _wrap(const MobileCard(child: Text('x')))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect((deco.borderRadius as BorderRadius).topLeft.x, 15.0); + }); + }); + + // ── StatusDot ───────────────────────────────────────────────────────────── + group('StatusDot', () { + testWidgets('online state uses green color', (tester) async { + await tester.pumpWidget( + _wrap(const StatusDot(state: HostConnState.connected))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.color, MobileColors.green); + }); + + testWidgets('offline state uses grey color', (tester) async { + await tester.pumpWidget( + _wrap(const StatusDot(state: HostConnState.offline))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.color, MobileColors.textFaint); + }); + }); + + // ── ListGroup ───────────────────────────────────────────────────────────── + group('ListGroup', () { + testWidgets('2 rows renders exactly 1 divider', (tester) async { + await tester.pumpWidget(_wrap( + ListGroup(children: [ + const Text('row1'), + const Text('row2'), + ]), + )); + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('3 rows renders exactly 2 dividers', (tester) async { + await tester.pumpWidget(_wrap( + ListGroup(children: [ + const Text('row1'), + const Text('row2'), + const Text('row3'), + ]), + )); + expect(find.byType(Divider), findsNWidgets(2)); + }); + + testWidgets('renders optional label', (tester) async { + await tester.pumpWidget(_wrap( + ListGroup(label: 'Security', children: [const Text('row1')]), + )); + expect(find.text('SECURITY'), findsOneWidget); + }); + + testWidgets('renders all children', (tester) async { + await tester.pumpWidget(_wrap( + ListGroup(children: [const Text('A'), const Text('B')]), + )); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + }); + }); + + // ── SettingsRow ─────────────────────────────────────────────────────────── + group('SettingsRow', () { + testWidgets('toggle:true shows a Switch', (tester) async { + await tester.pumpWidget(_wrap( + SettingsRow(title: 'Option', toggle: true, onToggle: (_) {}), + )); + expect(find.byType(Switch), findsOneWidget); + }); + + testWidgets('toggle:false shows no Switch', (tester) async { + await tester.pumpWidget(_wrap( + const SettingsRow(title: 'Option'), + )); + expect(find.byType(Switch), findsNothing); + }); + + testWidgets('renders title', (tester) async { + await tester.pumpWidget(_wrap( + const SettingsRow(title: 'Privacy'), + )); + expect(find.text('Privacy'), findsOneWidget); + }); + + testWidgets('renders value text in mono/grey', (tester) async { + await tester.pumpWidget(_wrap( + const SettingsRow(title: 'Host', value: 'localhost'), + )); + expect(find.text('localhost'), findsOneWidget); + }); + + testWidgets('shows chevron when onTap is set', (tester) async { + await tester.pumpWidget(_wrap( + SettingsRow(title: 'Host', onTap: () {}), + )); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + }); + + testWidgets('no chevron when onTap is null and no toggle', (tester) async { + await tester.pumpWidget(_wrap( + const SettingsRow(title: 'Version', value: '1.0'), + )); + expect(find.byIcon(Icons.chevron_right), findsNothing); + }); + }); +} From b12f2dc292a7796ab568005d8a66fb7dc366bcc2 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:04:11 +0700 Subject: [PATCH 59/96] fix(mobile): update status-dot test, guard latency null, trim settings-row spacer --- app/lib/mobile/widgets/latency_badge.dart | 2 +- app/lib/mobile/widgets/settings_row.dart | 6 ++++-- app/test/mobile/status_dot_test.dart | 8 ++++---- app/test/mobile/widgets/primitives_test.dart | 7 ++++++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/lib/mobile/widgets/latency_badge.dart b/app/lib/mobile/widgets/latency_badge.dart index d0c7f40d..7f41fb80 100644 --- a/app/lib/mobile/widgets/latency_badge.dart +++ b/app/lib/mobile/widgets/latency_badge.dart @@ -12,7 +12,7 @@ class LatencyBadge extends StatelessWidget { @override Widget build(BuildContext context) { final color = offline ? MobileColors.textFaint : MobileColors.green; - final label = offline ? 'offline' : '${ms}ms'; + final label = offline ? 'offline' : (ms != null ? '${ms}ms' : '—'); return Container( padding: const EdgeInsets.symmetric( diff --git a/app/lib/mobile/widgets/settings_row.dart b/app/lib/mobile/widgets/settings_row.dart index ebfb82e4..8169bd95 100644 --- a/app/lib/mobile/widgets/settings_row.dart +++ b/app/lib/mobile/widgets/settings_row.dart @@ -78,8 +78,10 @@ class SettingsRow extends StatelessWidget { style: mobileMono(size: 13, color: MobileColors.textMuted), ), ], - const SizedBox(width: MobileTokens.space2), - _buildTrailing(), + if (trailing != null || toggle != null || onTap != null) ...[ + const SizedBox(width: MobileTokens.space2), + _buildTrailing(), + ], ], ), ); diff --git a/app/test/mobile/status_dot_test.dart b/app/test/mobile/status_dot_test.dart index 9ca10b5b..74a9f9b8 100644 --- a/app/test/mobile/status_dot_test.dart +++ b/app/test/mobile/status_dot_test.dart @@ -1,11 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:yourssh/theme/app_theme.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/status_dot.dart'; void main() { test('statusColor maps each state', () { - expect(statusColor(HostConnState.connected), AppColors.accent); - expect(statusColor(HostConnState.connecting), AppColors.orange); - expect(statusColor(HostConnState.offline), AppColors.textTertiary); + expect(statusColor(HostConnState.connected), MobileColors.green); + expect(statusColor(HostConnState.connecting), MobileColors.accent); + expect(statusColor(HostConnState.offline), MobileColors.textFaint); }); } diff --git a/app/test/mobile/widgets/primitives_test.dart b/app/test/mobile/widgets/primitives_test.dart index fb4f8901..cef7d837 100644 --- a/app/test/mobile/widgets/primitives_test.dart +++ b/app/test/mobile/widgets/primitives_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; -import 'package:yourssh/mobile/theme/mobile_tokens.dart'; import 'package:yourssh/mobile/widgets/latency_badge.dart'; import 'package:yourssh/mobile/widgets/tag_chip.dart'; import 'package:yourssh/mobile/widgets/list_group.dart'; @@ -39,6 +38,12 @@ void main() { final deco = container.decoration as BoxDecoration; expect(deco.color, MobileColors.textFaint); }); + + testWidgets('no ms and not offline renders "—" not "nullms"', (tester) async { + await tester.pumpWidget(_wrap(const LatencyBadge())); + expect(find.text('—'), findsOneWidget); + expect(find.text('nullms'), findsNothing); + }); }); // ── TagChip ─────────────────────────────────────────────────────────────── From 8554174c56024948e48a03f00d47a8dd08555a83 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:08:02 +0700 Subject: [PATCH 60/96] feat(mobile): redesigned host card + avatar New HostCard API: online/connecting/latencyMs params replace HostConnState. Avatar gains optional icon param and bottom-right StatusDot overlay. Subtitle shows user@host omitting port when it equals the protocol default. LatencyBadge wired into card trailing row; chevron uses MobileColors.textFaint. --- .../mobile/screens/mobile_hosts_screen.dart | 16 ++-- app/lib/mobile/widgets/host_avatar.dart | 49 ++++++++--- app/lib/mobile/widgets/host_card.dart | 84 ++++++++++++------- app/test/mobile/host_card_test.dart | 6 +- app/test/mobile/widgets/host_card_test.dart | 28 +++++++ 5 files changed, 132 insertions(+), 51 deletions(-) create mode 100644 app/test/mobile/widgets/host_card_test.dart diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index a70cb4e0..7bfe3905 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -130,12 +130,16 @@ class _MobileHostsScreenState extends State { padding: const EdgeInsets.only( top: MobileTokens.space2, bottom: 80), itemCount: hosts.length, - itemBuilder: (_, i) => HostCard( - host: hosts[i], - state: _stateFor(hosts[i], sessions), - onTap: () => _connect(hosts[i]), - onLongPress: () => _showActions(hosts[i]), - ), + itemBuilder: (_, i) { + final s = _stateFor(hosts[i], sessions); + return HostCard( + host: hosts[i], + online: s == HostConnState.connected, + connecting: s == HostConnState.connecting, + onTap: () => _connect(hosts[i]), + onLongPress: () => _showActions(hosts[i]), + ); + }, ), ), ], diff --git a/app/lib/mobile/widgets/host_avatar.dart b/app/lib/mobile/widgets/host_avatar.dart index 6d7f5751..3f703720 100644 --- a/app/lib/mobile/widgets/host_avatar.dart +++ b/app/lib/mobile/widgets/host_avatar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; import '../theme/mobile_tokens.dart'; +import 'status_dot.dart'; /// Up to two uppercase initials from a host label. Words are whitespace-split; /// two-plus words use the first letter of the first two words. A single word @@ -22,25 +23,31 @@ String hostInitials(String label) { return w[0].toUpperCase(); } -/// Rounded-square avatar: seeded background tint + initials in the seed color. -/// Falls back to a terminal glyph when the label yields no initials. +/// Rounded-square avatar (radius 11): seeded background tint + initials or +/// optional [icon] glyph in the seed color. An optional [statusState] places +/// a small [StatusDot] at the bottom-right corner. class HostAvatar extends StatelessWidget { final String label; final String seed; + final IconData? icon; final double size; + final HostConnState? statusState; const HostAvatar({ super.key, required this.label, required this.seed, + this.icon, this.size = MobileTokens.avatar, + this.statusState, }); @override Widget build(BuildContext context) { final color = AppColors.hostColor(seed); final initials = hostInitials(label); - return Container( + + Widget tile = Container( width: size, height: size, alignment: Alignment.center, @@ -48,16 +55,32 @@ class HostAvatar extends StatelessWidget { color: color.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), ), - child: initials.isEmpty - ? Icon(Icons.dns_outlined, color: color, size: size * 0.5) - : Text( - initials, - style: TextStyle( - color: color, - fontSize: size * 0.36, - fontWeight: FontWeight.w600, - ), - ), + child: icon != null + ? Icon(icon, color: color, size: size * 0.5) + : initials.isEmpty + ? Icon(Icons.dns_outlined, color: color, size: size * 0.5) + : Text( + initials, + style: TextStyle( + color: color, + fontSize: size * 0.36, + fontWeight: FontWeight.w600, + ), + ), + ); + + if (statusState == null) return tile; + + return Stack( + clipBehavior: Clip.none, + children: [ + tile, + Positioned( + right: -2, + bottom: -2, + child: StatusDot(state: statusState!), + ), + ], ); } } diff --git a/app/lib/mobile/widgets/host_card.dart b/app/lib/mobile/widgets/host_card.dart index b3009e5d..9b30b700 100644 --- a/app/lib/mobile/widgets/host_card.dart +++ b/app/lib/mobile/widgets/host_card.dart @@ -1,29 +1,47 @@ import 'package:flutter/material.dart'; import '../../models/host.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; import 'host_avatar.dart'; +import 'latency_badge.dart'; import 'mobile_card.dart'; import 'status_dot.dart'; import 'tag_chip.dart'; -/// Termius-style host row: seeded avatar + label + user@host:port + tag chips, -/// with a connection status dot and a trailing chevron. +/// Termius-style host row: seeded avatar (with status dot overlay) + label +/// + user@ip (mono, faint) + latency badge + chevron. class HostCard extends StatelessWidget { final Host host; - final HostConnState state; - final VoidCallback onTap; + final bool online; + final bool connecting; + final int? latencyMs; + final VoidCallback? onTap; final VoidCallback? onLongPress; const HostCard({ super.key, required this.host, - required this.state, - required this.onTap, + this.online = false, + this.connecting = false, + this.latencyMs, + this.onTap, this.onLongPress, }); + HostConnState get _connState { + if (online) return HostConnState.connected; + if (connecting) return HostConnState.connecting; + return HostConnState.offline; + } + + /// Shows `user@host` — omits the port when it equals the protocol default. + String get _subtitle { + final defaultPort = host.protocol.defaultPort; + if (host.port == defaultPort) return '${host.username}@${host.host}'; + return '${host.username}@${host.host}:${host.port}'; + } + @override Widget build(BuildContext context) { return Padding( @@ -36,37 +54,33 @@ class HostCard extends StatelessWidget { onLongPress: onLongPress, child: Row( children: [ - HostAvatar(label: host.label, seed: host.host), + HostAvatar( + label: host.label, + seed: host.host, + statusState: _connState, + ), const SizedBox(width: MobileTokens.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Text( - host.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - StatusDot(state: state), - ], + Text( + host.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mobileBody( + size: 15.5, + weight: FontWeight.w600, + ), ), const SizedBox(height: 2), Text( - '${host.username}@${host.host}:${host.port}', + _subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 12, + style: mobileMono( + size: 12.5, + color: MobileColors.textFaint, ), ), if (host.tags.isNotEmpty) ...[ @@ -80,7 +94,19 @@ class HostCard extends StatelessWidget { ], ), ), - const Icon(Icons.chevron_right, color: AppColors.textTertiary), + const SizedBox(width: MobileTokens.space2), + if (!online && !connecting) + const LatencyBadge(offline: true) + else if (latencyMs != null) + LatencyBadge(ms: latencyMs) + else + const SizedBox.shrink(), + const SizedBox(width: MobileTokens.space1), + const Icon( + Icons.chevron_right, + color: MobileColors.textFaint, + size: 20, + ), ], ), ), diff --git a/app/test/mobile/host_card_test.dart b/app/test/mobile/host_card_test.dart index 8f5202bc..eb0be782 100644 --- a/app/test/mobile/host_card_test.dart +++ b/app/test/mobile/host_card_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yourssh/models/host.dart'; import 'package:yourssh/mobile/widgets/host_card.dart'; -import 'package:yourssh/mobile/widgets/status_dot.dart'; void main() { testWidgets('renders label, target, tags, and fires onTap', (t) async { @@ -18,13 +17,14 @@ void main() { home: Scaffold( body: HostCard( host: host, - state: HostConnState.connected, + online: true, onTap: () => tapped = true, ), ), )); expect(find.text('Production Web'), findsOneWidget); - expect(find.text('root@10.0.0.5:22'), findsOneWidget); + // Port 22 is the SSH default — omitted from the subtitle. + expect(find.text('root@10.0.0.5'), findsOneWidget); expect(find.text('prod'), findsOneWidget); expect(find.text('nginx'), findsOneWidget); await t.tap(find.text('Production Web')); diff --git a/app/test/mobile/widgets/host_card_test.dart b/app/test/mobile/widgets/host_card_test.dart new file mode 100644 index 00000000..25ba7733 --- /dev/null +++ b/app/test/mobile/widgets/host_card_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/mobile/widgets/host_card.dart'; + +void main() { + testWidgets('renders label, user@ip subtitle, and latency badge', (t) async { + final host = Host( + label: 'web-01', + host: '10.0.4.21', + port: 22, + username: 'deploy', + ); + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: HostCard( + host: host, + online: true, + latencyMs: 24, + onTap: () {}, + ), + ), + )); + expect(find.text('web-01'), findsOneWidget); + expect(find.text('deploy@10.0.4.21'), findsOneWidget); + expect(find.text('24ms'), findsOneWidget); + }); +} From 7fa75d30c1c38500154af38c94de2abc333ff972 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:12:43 +0700 Subject: [PATCH 61/96] feat(mobile): amber tab bar + 4-tab home shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old Material NavigationBar + 4-screen shell with a custom MobileTabBar (78px, BackdropFilter blur, amber active / #6D6D72 muted) and IndexedStack hosting placeholder bodies for Hosts · Snippets · Keys · Settings. Add MobileFab (56px r18 amber). Wire buildMobileTheme() into mobile_app.dart. Update both shell smoke tests. --- app/lib/mobile/mobile_app.dart | 10 +- app/lib/mobile/screens/mobile_home_shell.dart | 68 ++++--------- app/lib/mobile/widgets/mobile_fab.dart | 27 +++++ app/lib/mobile/widgets/mobile_tab_bar.dart | 98 +++++++++++++++++++ app/test/mobile/home_shell_test.dart | 57 +++++++++++ app/test/mobile/mobile_home_shell_test.dart | 61 ++++++------ 6 files changed, 233 insertions(+), 88 deletions(-) create mode 100644 app/lib/mobile/widgets/mobile_fab.dart create mode 100644 app/lib/mobile/widgets/mobile_tab_bar.dart create mode 100644 app/test/mobile/home_shell_test.dart diff --git a/app/lib/mobile/mobile_app.dart b/app/lib/mobile/mobile_app.dart index 871991fb..73c44773 100644 --- a/app/lib/mobile/mobile_app.dart +++ b/app/lib/mobile/mobile_app.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../theme/app_theme.dart'; import 'mobile_bootstrap.dart'; import 'screens/mobile_home_shell.dart'; import 'security/app_lock_gate.dart'; +import 'theme/mobile_theme.dart'; -/// Root widget for the Android build. Dark-only; reuses the shared -/// [buildAppTheme] so the mobile surface matches desktop. Holds the -/// [MobileBootstrap] and exposes its providers to the tree. +/// Root widget for the Android build. Dark-only; uses [buildMobileTheme] +/// for the mobile-specific design language. Holds the [MobileBootstrap] +/// and exposes its providers to the tree. class YourSSHMobileApp extends StatefulWidget { const YourSSHMobileApp({super.key}); @@ -33,7 +33,7 @@ class _YourSSHMobileAppState extends State { title: 'YourSSH', debugShowCheckedModeBanner: false, themeMode: ThemeMode.dark, - theme: buildAppTheme(), + theme: buildMobileTheme(), home: const AppLockGate(child: MobileHomeShell()), ), ); diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 15d18e9a..31055feb 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -1,15 +1,10 @@ import 'package:flutter/material.dart'; -import '../../theme/app_theme.dart'; -import '../security/tofu_watcher.dart'; -import 'mobile_add_host_screen.dart'; -import 'mobile_hosts_screen.dart'; -import 'mobile_sessions_screen.dart'; -import 'mobile_settings_screen.dart'; -import 'mobile_sftp_screen.dart'; +import '../widgets/mobile_tab_bar.dart'; -/// Bottom-navigation shell for the Android app. Hosts (0) and Sessions (1) are -/// live; SFTP (2) and Settings (3) are placeholders filled in M4/M5. +/// Bottom-navigation shell for the Android app. +/// Four tabs: Hosts · Snippets · Keys · Settings. +/// Tab bodies are temporary placeholders until tasks T6/T8/T9 land. class MobileHomeShell extends StatefulWidget { const MobileHomeShell({super.key}); @@ -18,52 +13,25 @@ class MobileHomeShell extends StatefulWidget { } class _MobileHomeShellState extends State { - int _index = 0; + MobileTab _current = MobileTab.hosts; - static const _labels = ['Hosts', 'Sessions', 'SFTP', 'Settings']; - static const _icons = [ - Icons.dns_outlined, - Icons.terminal_outlined, - Icons.folder_outlined, - Icons.settings_outlined, - ]; - - void _openAddHost() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const MobileAddHostScreen()), - ); - } - - Widget _body() { - switch (_index) { - case 0: - return MobileHostsScreen( - onConnected: () => setState(() => _index = 1), - onAddHost: _openAddHost, - ); - case 1: - return const MobileSessionsScreen(); - case 2: - return const MobileSftpScreen(); - case 3: - return const MobileSettingsScreen(); - default: - return const SizedBox.shrink(); - } - } + static const _bodies = { + MobileTab.hosts: Center(child: Text('Hosts')), + MobileTab.snippets: Center(child: Text('Snippets')), + MobileTab.keys: Center(child: Text('Keys')), + MobileTab.settings: Center(child: Text('Settings')), + }; @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppColors.bg, - body: TofuWatcher(child: _body()), - bottomNavigationBar: NavigationBar( - selectedIndex: _index, - onDestinationSelected: (i) => setState(() => _index = i), - destinations: [ - for (var i = 0; i < _labels.length; i++) - NavigationDestination(icon: Icon(_icons[i]), label: _labels[i]), - ], + body: IndexedStack( + index: MobileTab.values.indexOf(_current), + children: MobileTab.values.map((t) => _bodies[t]!).toList(), + ), + bottomNavigationBar: MobileTabBar( + current: _current, + onSelect: (tab) => setState(() => _current = tab), ), ); } diff --git a/app/lib/mobile/widgets/mobile_fab.dart b/app/lib/mobile/widgets/mobile_fab.dart new file mode 100644 index 00000000..30415f0d --- /dev/null +++ b/app/lib/mobile/widgets/mobile_fab.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// 56-px rounded-square (r18) amber FAB with a white "+" icon. +class MobileFab extends StatelessWidget { + const MobileFab({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: MobileTokens.fabSize, + height: MobileTokens.fabSize, + decoration: BoxDecoration( + color: MobileColors.accent, + borderRadius: BorderRadius.circular(18), + ), + child: const Icon(Icons.add, color: Colors.white, size: 28), + ), + ); + } +} diff --git a/app/lib/mobile/widgets/mobile_tab_bar.dart b/app/lib/mobile/widgets/mobile_tab_bar.dart new file mode 100644 index 00000000..b5a7ddcc --- /dev/null +++ b/app/lib/mobile/widgets/mobile_tab_bar.dart @@ -0,0 +1,98 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// The four top-level destinations in the mobile app. +enum MobileTab { hosts, snippets, keys, settings } + +extension _MobileTabMeta on MobileTab { + String get label { + switch (this) { + case MobileTab.hosts: + return 'Hosts'; + case MobileTab.snippets: + return 'Snippets'; + case MobileTab.keys: + return 'Keys'; + case MobileTab.settings: + return 'Settings'; + } + } + + IconData get icon { + switch (this) { + case MobileTab.hosts: + return Icons.dns_outlined; + case MobileTab.snippets: + return Icons.code_outlined; + case MobileTab.keys: + return Icons.key_outlined; + case MobileTab.settings: + return Icons.settings_outlined; + } + } +} + +/// Blurred 78-px tab bar with 4 destinations. +/// Active tab uses [MobileColors.accent]; inactive uses a muted grey. +class MobileTabBar extends StatelessWidget { + const MobileTabBar({ + super.key, + required this.current, + required this.onSelect, + }); + + final MobileTab current; + final ValueChanged onSelect; + + static const _inactiveColor = Color(0xFF6D6D72); + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: MobileTokens.tabBarHeight + bottomPadding, + color: MobileColors.surface.withValues(alpha: 0.85), + padding: EdgeInsets.only(bottom: bottomPadding), + child: Row( + children: MobileTab.values.map((tab) { + final active = tab == current; + final color = active ? MobileColors.accent : _inactiveColor; + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onSelect(tab), + child: SizedBox( + height: MobileTokens.tabBarHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(tab.icon, color: color, size: 24), + const SizedBox(height: 4), + Text( + tab.label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: color, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ); + } +} diff --git a/app/test/mobile/home_shell_test.dart b/app/test/mobile/home_shell_test.dart new file mode 100644 index 00000000..6c1145e4 --- /dev/null +++ b/app/test/mobile/home_shell_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; + +Widget _wrap(Widget child) => MaterialApp( + theme: buildMobileTheme(), + home: child, + ); + +void main() { + testWidgets('shows four tab labels in MobileTabBar', (tester) async { + await tester.pumpWidget(_wrap(const MobileHomeShell())); + await tester.pumpAndSettle(); + + final bar = find.byType(MobileTabBar); + expect(bar, findsOneWidget); + + for (final label in ['Hosts', 'Snippets', 'Keys', 'Settings']) { + expect( + find.descendant(of: bar, matching: find.text(label)), + findsOneWidget, + reason: '$label tab label not found', + ); + } + }); + + testWidgets('tapping Keys tab switches IndexedStack index', (tester) async { + await tester.pumpWidget(_wrap(const MobileHomeShell())); + await tester.pumpAndSettle(); + + // Initially on Hosts (index 0) + final stackBefore = tester.widget(find.byType(IndexedStack)); + expect(stackBefore.index, 0); + + // Tap the Keys tab label in the tab bar + final keysTabLabel = find.descendant( + of: find.byType(MobileTabBar), + matching: find.text('Keys'), + ); + await tester.tap(keysTabLabel); + await tester.pumpAndSettle(); + + // IndexedStack now shows Keys at index 2 + final stackAfter = tester.widget(find.byType(IndexedStack)); + expect(stackAfter.index, MobileTab.values.indexOf(MobileTab.keys)); + }); + + testWidgets('uses MobileTabBar not NavigationBar', (tester) async { + await tester.pumpWidget(_wrap(const MobileHomeShell())); + await tester.pumpAndSettle(); + + expect(find.byType(MobileTabBar), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + }); +} diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index 44944b16..2a7a5554 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -1,45 +1,40 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; -import 'package:yourssh/providers/host_provider.dart'; -import 'package:yourssh/providers/key_provider.dart'; -import 'package:yourssh/providers/known_hosts_provider.dart'; -import 'package:yourssh/providers/session_provider.dart'; -import 'package:yourssh/services/ssh_service.dart'; -import 'package:yourssh/services/storage_service.dart'; -import 'package:yourssh/services/tab_metadata_service.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; -Future _pump(WidgetTester tester) async { - final storage = StorageService(); - final ssh = SshService(storage); - await tester.pumpWidget(MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => HostProvider(storage)), - ChangeNotifierProvider(create: (_) => KeyProvider()), - ChangeNotifierProvider(create: (_) => KnownHostsProvider(storage)), - ChangeNotifierProvider( - create: (_) => SessionProvider(ssh, TabMetadataService())), - ], - child: const MaterialApp(home: MobileHomeShell()), - )); - await tester.pumpAndSettle(); -} +Widget _wrap(Widget child) => MaterialApp( + theme: buildMobileTheme(), + home: child, + ); void main() { - setUp(() => SharedPreferences.setMockInitialValues({})); + testWidgets('shows MobileTabBar with four destinations', (tester) async { + await tester.pumpWidget(_wrap(const MobileHomeShell())); + await tester.pumpAndSettle(); - testWidgets('shows four destinations; Hosts first', (tester) async { - await _pump(tester); - expect(find.byType(NavigationBar), findsOneWidget); - expect(find.textContaining('No hosts'), findsOneWidget); // Hosts screen body + expect(find.byType(MobileTabBar), findsOneWidget); + final bar = find.byType(MobileTabBar); + for (final label in ['Hosts', 'Snippets', 'Keys', 'Settings']) { + expect( + find.descendant(of: bar, matching: find.text(label)), + findsOneWidget, + ); + } }); - testWidgets('SFTP tab prompts to connect when no session', (tester) async { - await _pump(tester); - await tester.tap(find.text('SFTP').last); + testWidgets('Settings tab is reachable', (tester) async { + await tester.pumpWidget(_wrap(const MobileHomeShell())); await tester.pumpAndSettle(); - expect(find.textContaining('Connect a host'), findsOneWidget); + + final bar = find.byType(MobileTabBar); + await tester.tap( + find.descendant(of: bar, matching: find.text('Settings')), + ); + await tester.pumpAndSettle(); + + final stack = tester.widget(find.byType(IndexedStack)); + expect(stack.index, MobileTab.values.indexOf(MobileTab.settings)); }); } From 9db3c389d0486d3a9280204eafee67fdfa665257 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:15:48 +0700 Subject: [PATCH 62/96] fix(mobile): re-wire TofuWatcher around home shell --- app/lib/mobile/screens/mobile_home_shell.dart | 9 ++++++--- app/test/mobile/home_shell_test.dart | 15 ++++++++++++--- app/test/mobile/mobile_home_shell_test.dart | 15 ++++++++++++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 31055feb..bfa7f637 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../security/tofu_watcher.dart'; import '../widgets/mobile_tab_bar.dart'; /// Bottom-navigation shell for the Android app. @@ -25,9 +26,11 @@ class _MobileHomeShellState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: IndexedStack( - index: MobileTab.values.indexOf(_current), - children: MobileTab.values.map((t) => _bodies[t]!).toList(), + body: TofuWatcher( + child: IndexedStack( + index: MobileTab.values.indexOf(_current), + children: MobileTab.values.map((t) => _bodies[t]!).toList(), + ), ), bottomNavigationBar: MobileTabBar( current: _current, diff --git a/app/test/mobile/home_shell_test.dart b/app/test/mobile/home_shell_test.dart index 6c1145e4..e503afa3 100644 --- a/app/test/mobile/home_shell_test.dart +++ b/app/test/mobile/home_shell_test.dart @@ -1,12 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; +import 'package:yourssh/providers/known_hosts_provider.dart'; -Widget _wrap(Widget child) => MaterialApp( - theme: buildMobileTheme(), - home: child, +Widget _wrap(Widget child) => MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => KnownHostsProvider.forTest([]), + ), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: child, + ), ); void main() { diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index 2a7a5554..779a63d6 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -1,12 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; +import 'package:yourssh/providers/known_hosts_provider.dart'; -Widget _wrap(Widget child) => MaterialApp( - theme: buildMobileTheme(), - home: child, +Widget _wrap(Widget child) => MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => KnownHostsProvider.forTest([]), + ), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: child, + ), ); void main() { From 6a2092ffdf9207a2c908e929c32857e88692ae9f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:18:13 +0700 Subject: [PATCH 63/96] feat(mobile): best-effort host reachability probe --- .../services/host_reachability_probe.dart | 61 +++++++++++++++++++ .../host_reachability_probe_test.dart | 26 ++++++++ 2 files changed, 87 insertions(+) create mode 100644 app/lib/mobile/services/host_reachability_probe.dart create mode 100644 app/test/mobile/services/host_reachability_probe_test.dart diff --git a/app/lib/mobile/services/host_reachability_probe.dart b/app/lib/mobile/services/host_reachability_probe.dart new file mode 100644 index 00000000..469de1b5 --- /dev/null +++ b/app/lib/mobile/services/host_reachability_probe.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +enum HostReachState { unknown, probing, online, offline } + +class HostPing { + const HostPing(this.state, [this.ms]); + final HostReachState state; + final int? ms; +} + +typedef Connector = Future Function(String host, int port, Duration timeout); + +Future _defaultConnector(String host, int port, Duration timeout) async { + final socket = await Socket.connect(host, port, timeout: timeout); + socket.destroy(); +} + +class HostReachabilityProbe extends ChangeNotifier { + HostReachabilityProbe({ + Connector? connector, + this._timeout = const Duration(seconds: 3), + DateTime Function()? clock, + }) : _connector = connector ?? _defaultConnector, + _clock = clock ?? DateTime.now; + + final Connector _connector; + final Duration _timeout; + final DateTime Function() _clock; + + final Map _pings = {}; + final Set _inFlight = {}; + + HostPing pingFor(String hostId) => + _pings[hostId] ?? const HostPing(HostReachState.unknown); + + Future probe(String hostId, String host, int port) async { + _pings[hostId] = const HostPing(HostReachState.probing); + notifyListeners(); + + final start = _clock(); + try { + await _connector(host, port, _timeout); + final end = _clock(); + final ms = end.difference(start).inMilliseconds; + _pings[hostId] = HostPing(HostReachState.online, ms); + } catch (_) { + _pings[hostId] = const HostPing(HostReachState.offline); + } + notifyListeners(); + } + + void probeAll(Iterable<({String id, String host, int port})> hosts) { + for (final h in hosts) { + if (_inFlight.contains(h.id)) continue; + _inFlight.add(h.id); + probe(h.id, h.host, h.port).whenComplete(() => _inFlight.remove(h.id)); + } + } +} diff --git a/app/test/mobile/services/host_reachability_probe_test.dart b/app/test/mobile/services/host_reachability_probe_test.dart new file mode 100644 index 00000000..2788ca5b --- /dev/null +++ b/app/test/mobile/services/host_reachability_probe_test.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/services/host_reachability_probe.dart'; + +void main() { + test('online reports ms from injected clock', () async { + var t = DateTime(2026, 1, 1); + final clock = () => t; + final probe = HostReachabilityProbe( + connector: (h, p, to) async { + t = t.add(const Duration(milliseconds: 24)); + }, + clock: clock); + await probe.probe('h1', '10.0.0.1', 22); + expect(probe.pingFor('h1').state, HostReachState.online); + expect(probe.pingFor('h1').ms, 24); + }); + + test('connector throw -> offline, no rethrow', () async { + final probe = HostReachabilityProbe( + connector: (h, p, to) async => throw const SocketException('x')); + await probe.probe('h2', '10.0.0.2', 22); + expect(probe.pingFor('h2').state, HostReachState.offline); + }); +} From 16cfb67764c62c455a755690ecd24ccb9bae7363 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:24:31 +0700 Subject: [PATCH 64/96] feat(mobile): redesigned hosts screen with folder chips + latency --- app/lib/mobile/screens/mobile_home_shell.dart | 38 +- .../mobile/screens/mobile_hosts_screen.dart | 486 +++++++++++++----- app/test/mobile/home_shell_test.dart | 15 + app/test/mobile/mobile_home_shell_test.dart | 15 + app/test/mobile/mobile_hosts_screen_test.dart | 5 +- .../mobile/screens/hosts_screen_test.dart | 73 +++ 6 files changed, 497 insertions(+), 135 deletions(-) create mode 100644 app/test/mobile/screens/hosts_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index bfa7f637..9f498d9c 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../security/tofu_watcher.dart'; +import '../services/host_reachability_probe.dart'; import '../widgets/mobile_tab_bar.dart'; +import 'mobile_hosts_screen.dart'; /// Bottom-navigation shell for the Android app. /// Four tabs: Hosts · Snippets · Keys · Settings. -/// Tab bodies are temporary placeholders until tasks T6/T8/T9 land. +/// Tab bodies are temporary placeholders until tasks T8/T9 land. class MobileHomeShell extends StatefulWidget { const MobileHomeShell({super.key}); @@ -16,25 +19,26 @@ class MobileHomeShell extends StatefulWidget { class _MobileHomeShellState extends State { MobileTab _current = MobileTab.hosts; - static const _bodies = { - MobileTab.hosts: Center(child: Text('Hosts')), - MobileTab.snippets: Center(child: Text('Snippets')), - MobileTab.keys: Center(child: Text('Keys')), - MobileTab.settings: Center(child: Text('Settings')), - }; - @override Widget build(BuildContext context) { - return Scaffold( - body: TofuWatcher( - child: IndexedStack( - index: MobileTab.values.indexOf(_current), - children: MobileTab.values.map((t) => _bodies[t]!).toList(), + return ChangeNotifierProvider( + create: (_) => HostReachabilityProbe(), + child: Scaffold( + body: TofuWatcher( + child: IndexedStack( + index: MobileTab.values.indexOf(_current), + children: const [ + MobileHostsScreen(), + Center(child: Text('Snippets')), + Center(child: Text('Keys')), + Center(child: Text('Settings')), + ], + ), + ), + bottomNavigationBar: MobileTabBar( + current: _current, + onSelect: (tab) => setState(() => _current = tab), ), - ), - bottomNavigationBar: MobileTabBar( - current: _current, - onSelect: (tab) => setState(() => _current = tab), ), ); } diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 7bfe3905..935c0494 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -5,35 +5,51 @@ import '../../models/host.dart'; import '../../models/ssh_session.dart'; import '../../providers/host_provider.dart'; import '../../providers/session_provider.dart'; -import '../../theme/app_theme.dart'; import '../../util/host_query.dart'; +import '../services/host_reachability_probe.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; import '../widgets/host_card.dart'; +import '../widgets/mobile_fab.dart'; +import '../widgets/section_header.dart'; import '../widgets/status_dot.dart'; import '../widgets/tag_chip.dart'; import 'mobile_add_host_screen.dart'; -/// Hosts tab: searchable list of saved hosts as Termius-style cards; tap to -/// connect, long-press for edit/delete actions, FAB to add. Tag chips filter -/// the list. +/// Hosts tab: header + search + folder chips + tag-grouped list + amber FAB. +/// Tap a host → connect via SessionProvider. Long-press → edit/delete sheet. class MobileHostsScreen extends StatefulWidget { - final VoidCallback onConnected; - final VoidCallback onAddHost; - - const MobileHostsScreen({ - super.key, - required this.onConnected, - required this.onAddHost, - }); + const MobileHostsScreen({super.key}); @override State createState() => _MobileHostsScreenState(); } class _MobileHostsScreenState extends State { + final _searchCtrl = TextEditingController(); String _query = ''; String? _tagFilter; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _kickProbe()); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + void _kickProbe() { + if (!mounted) return; + final all = context.read().allHosts; + context.read().probeAll( + all.map((h) => (id: h.id, host: h.host, port: h.port)), + ); + } + /// Coarse connection state for a host, derived from any live session on it. HostConnState _stateFor(Host host, List sessions) { final mine = sessions.where((s) => s.host.id == host.id); @@ -46,13 +62,7 @@ class _MobileHostsScreenState extends State { return HostConnState.offline; } - @override - Widget build(BuildContext context) { - final all = context.watch().allHosts; - final sessions = context.watch().sshSessions; - - final tags = {for (final h in all) ...h.tags}.toList()..sort(); - + List _filtered(List all) { var hosts = all; final q = _query.trim(); if (q.isNotEmpty) { @@ -61,86 +71,203 @@ class _MobileHostsScreenState extends State { if (_tagFilter != null) { hosts = hosts.where((h) => h.tags.contains(_tagFilter)).toList(); } + return hosts; + } + + /// Groups hosts by their first tag (or "Other" if untagged). + Map> _grouped(List hosts) { + final map = >{}; + for (final h in hosts) { + final key = h.tags.isNotEmpty ? h.tags.first : 'Other'; + map.putIfAbsent(key, () => []).add(h); + } + // Stable alphabetical order for sections. + return Map.fromEntries( + map.entries.toList()..sort((a, b) => a.key.compareTo(b.key)), + ); + } + + @override + Widget build(BuildContext context) { + final all = context.watch().allHosts; + final sessions = context.watch().sshSessions; + final probe = context.watch(); + + final tags = {for (final h in all) ...h.tags}.toList()..sort(); + final filtered = _filtered(all); + final grouped = _grouped(filtered); + + // Online/total derived from probe states. + final onlineCount = all + .where((h) => probe.pingFor(h.id).state == HostReachState.online) + .length; + final totalCount = all.length; return Scaffold( - backgroundColor: AppColors.bg, - floatingActionButton: FloatingActionButton( - onPressed: widget.onAddHost, - backgroundColor: AppColors.accent, - child: const Icon(Icons.add, color: Colors.black), - ), + backgroundColor: MobileColors.bg, body: SafeArea( - child: Column( + child: Stack( children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - MobileTokens.space3, - MobileTokens.space3, - MobileTokens.space3, - MobileTokens.space2, - ), - child: TextField( - onChanged: (v) => setState(() => _query = v), - style: const TextStyle(color: AppColors.textPrimary), - decoration: InputDecoration( - hintText: 'Search hosts', - prefixIcon: const Icon(Icons.search, color: AppColors.textSecondary), - filled: true, - fillColor: AppColors.card, - contentPadding: EdgeInsets.zero, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(MobileTokens.radiusPill), - borderSide: BorderSide.none, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space2, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Hosts', + style: mobileHeading( + size: 30, weight: FontWeight.w800)), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: MobileColors.green, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '$onlineCount online · $totalCount total', + style: mobileBody( + size: 13, + color: MobileColors.textMuted, + ), + ), + ], + ), + ], + ), + ), + _CircleButton( + icon: Icons.more_horiz, + onTap: () {}, + ), + const SizedBox(width: MobileTokens.space2), + _CircleButton( + icon: Icons.person_outline, + onTap: () {}, + ), + ], ), ), - ), - ), - if (tags.isNotEmpty) - SizedBox( - height: 36, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space3), - children: [ - Padding( - padding: const EdgeInsets.only(right: MobileTokens.space2), - child: TagChip( - label: 'All', - selected: _tagFilter == null, - onTap: () => setState(() => _tagFilter = null), + + // ── Search field ───────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + ), + child: TextField( + controller: _searchCtrl, + onChanged: (v) => setState(() => _query = v), + style: mobileBody(size: 15), + decoration: InputDecoration( + hintText: 'Search hosts, tags, IPs…', + hintStyle: mobileBody( + size: 15, color: MobileColors.textFaint), + prefixIcon: const Icon(Icons.search, + color: MobileColors.textFaint, size: 20), + filled: true, + fillColor: const Color(0xFF1C1C1E), + contentPadding: const EdgeInsets.symmetric( + vertical: MobileTokens.space3), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + borderSide: BorderSide.none, ), - ), - for (final tag in tags) - Padding( - padding: const EdgeInsets.only(right: MobileTokens.space2), - child: TagChip( - label: tag, - selected: _tagFilter == tag, - onTap: () => setState( - () => _tagFilter = _tagFilter == tag ? null : tag), - ), + enabledBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + borderSide: BorderSide.none, ), - ], + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + borderSide: const BorderSide( + color: MobileColors.accent, width: 1.5), + ), + ), + ), ), - ), - Expanded( - child: hosts.isEmpty - ? _EmptyState(onAddHost: widget.onAddHost) - : ListView.builder( - padding: const EdgeInsets.only( - top: MobileTokens.space2, bottom: 80), - itemCount: hosts.length, - itemBuilder: (_, i) { - final s = _stateFor(hosts[i], sessions); - return HostCard( - host: hosts[i], - online: s == HostConnState.connected, - connecting: s == HostConnState.connecting, - onTap: () => _connect(hosts[i]), - onLongPress: () => _showActions(hosts[i]), - ); - }, + const SizedBox(height: MobileTokens.space3), + + // ── Folder chips ───────────────────────────────────────────── + if (tags.isNotEmpty) + SizedBox( + height: 34, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + children: [ + Padding( + padding: + const EdgeInsets.only(right: MobileTokens.space2), + child: TagChip( + label: 'All', + selected: _tagFilter == null, + onTap: () => setState(() => _tagFilter = null), + ), + ), + for (final tag in tags) + Padding( + padding: const EdgeInsets.only( + right: MobileTokens.space2), + child: TagChip( + label: tag, + selected: _tagFilter == tag, + onTap: () => setState(() => + _tagFilter = _tagFilter == tag ? null : tag), + ), + ), + ], ), + ), + + const SizedBox(height: MobileTokens.space2), + + // ── Host list ──────────────────────────────────────────────── + Expanded( + child: filtered.isEmpty + ? _EmptyState( + onAdd: () => _pushAddHost(), + hasQuery: _query.isNotEmpty || _tagFilter != null, + ) + : RefreshIndicator( + onRefresh: () async => _kickProbe(), + color: MobileColors.accent, + child: ListView.builder( + padding: const EdgeInsets.only( + bottom: MobileTokens.space4 * 4), + itemCount: _groupedItemCount(grouped), + itemBuilder: (_, i) => + _groupedItem(i, grouped, sessions, probe), + ), + ), + ), + ], + ), + + // ── FAB ────────────────────────────────────────────────────────── + Positioned( + right: MobileTokens.space4, + bottom: MobileTokens.space4, + child: MobileFab(onTap: _pushAddHost), ), ], ), @@ -148,25 +275,103 @@ class _MobileHostsScreenState extends State { ); } + // ── Grouped list helpers ───────────────────────────────────────────────── + + int _groupedItemCount(Map> grouped) { + int count = 0; + for (final entry in grouped.entries) { + count += 1 + entry.value.length; // header + cards + } + return count; + } + + Widget _groupedItem( + int index, + Map> grouped, + List sessions, + HostReachabilityProbe probe, + ) { + int cursor = 0; + for (final entry in grouped.entries) { + if (index == cursor) { + return Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + 0, + ), + child: SectionHeader(entry.key), + ); + } + cursor++; + final localIdx = index - cursor; + if (localIdx < entry.value.length) { + final host = entry.value[localIdx]; + final ping = probe.pingFor(host.id); + final connState = _stateFor(host, sessions); + return HostCard( + host: host, + online: connState == HostConnState.connected || + ping.state == HostReachState.online, + connecting: connState == HostConnState.connecting || + ping.state == HostReachState.probing, + latencyMs: ping.ms, + onTap: () => _connect(host), + onLongPress: () => _showActions(host), + ); + } + cursor += entry.value.length; + } + return const SizedBox.shrink(); + } + + // ── Actions ────────────────────────────────────────────────────────────── + void _connect(Host host) { context.read().connectAny(host); - widget.onConnected(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Connecting to ${host.label}…'), + duration: const Duration(seconds: 2), + backgroundColor: MobileColors.surface, + ), + ); + } + + void _pushAddHost() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileAddHostScreen()), + ); } - /// Long-press action sheet: edit (opens the form pre-filled) or delete. Future _showActions(Host host) async { await showModalBottomSheet( context: context, - backgroundColor: AppColors.card, + backgroundColor: MobileColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Handle + Container( + margin: const EdgeInsets.only(top: MobileTokens.space3), + width: 40, + height: 4, + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: MobileTokens.space2), ListTile( - leading: - const Icon(Icons.edit_outlined, color: AppColors.textPrimary), - title: const Text('Edit', - style: TextStyle(color: AppColors.textPrimary)), + leading: const Icon(Icons.edit_outlined, + color: MobileColors.textPrimary), + title: Text('Edit', + style: mobileBody(size: 16, weight: FontWeight.w500)), onTap: () { Navigator.pop(ctx); Navigator.of(context).push(MaterialPageRoute( @@ -175,14 +380,17 @@ class _MobileHostsScreenState extends State { }, ), ListTile( - leading: const Icon(Icons.delete_outline, color: AppColors.red), - title: const Text('Delete', - style: TextStyle(color: AppColors.red)), + leading: + const Icon(Icons.delete_outline, color: MobileColors.red), + title: Text('Delete', + style: + mobileBody(size: 16, color: MobileColors.red)), onTap: () { Navigator.pop(ctx); _confirmDelete(host); }, ), + const SizedBox(height: MobileTokens.space2), ], ), ), @@ -193,19 +401,22 @@ class _MobileHostsScreenState extends State { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: AppColors.card, + backgroundColor: MobileColors.surface, title: Text('Delete ${host.label}?', - style: const TextStyle(color: AppColors.textPrimary)), - content: const Text('This removes the saved host and its credentials.', - style: TextStyle(color: AppColors.textSecondary)), + style: mobileHeading(size: 17)), + content: Text( + 'This removes the saved host and its credentials.', + style: mobileBody(size: 14, color: MobileColors.textMuted), + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), + child: Text('Cancel', style: mobileBody(size: 15)), ), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete', style: TextStyle(color: AppColors.red)), + child: Text('Delete', + style: mobileBody(size: 15, color: MobileColors.red)), ), ], ), @@ -216,9 +427,35 @@ class _MobileHostsScreenState extends State { } } +// ── Helper widgets ──────────────────────────────────────────────────────────── + +class _CircleButton extends StatelessWidget { + const _CircleButton({required this.icon, required this.onTap}); + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: MobileColors.surface, + shape: BoxShape.circle, + border: Border.all(color: MobileColors.border), + ), + child: Icon(icon, color: MobileColors.textMuted, size: 18), + ), + ); + } +} + class _EmptyState extends StatelessWidget { - final VoidCallback onAddHost; - const _EmptyState({required this.onAddHost}); + const _EmptyState({required this.onAdd, required this.hasQuery}); + final VoidCallback onAdd; + final bool hasQuery; @override Widget build(BuildContext context) { @@ -226,19 +463,34 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.dns_outlined, size: 48, color: AppColors.textTertiary), + Icon( + hasQuery ? Icons.search_off : Icons.dns_outlined, + size: 48, + color: MobileColors.textFaint, + ), const SizedBox(height: MobileTokens.space3), - const Text('No hosts yet', - style: TextStyle(color: AppColors.textPrimary, fontSize: 16)), + Text( + hasQuery ? 'No results' : 'No hosts yet', + style: mobileHeading(size: 17), + ), const SizedBox(height: MobileTokens.space1), - const Text('Add a server to get started', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), - const SizedBox(height: MobileTokens.space4), - FilledButton.icon( - onPressed: onAddHost, - icon: const Icon(Icons.add), - label: const Text('Add host'), + Text( + hasQuery + ? 'Try a different search term or tag' + : 'Add a server to get started', + style: mobileBody(size: 13, color: MobileColors.textMuted), ), + if (!hasQuery) ...[ + const SizedBox(height: MobileTokens.space4), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add), + label: const Text('Add host'), + style: FilledButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: Colors.black), + ), + ], ], ), ); diff --git a/app/test/mobile/home_shell_test.dart b/app/test/mobile/home_shell_test.dart index e503afa3..b1ac8bad 100644 --- a/app/test/mobile/home_shell_test.dart +++ b/app/test/mobile/home_shell_test.dart @@ -1,16 +1,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; +import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/known_hosts_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; Widget _wrap(Widget child) => MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => KnownHostsProvider.forTest([]), ), + ChangeNotifierProvider( + create: (_) => HostProvider(StorageService()), + ), + ChangeNotifierProvider( + create: (_) => + SessionProvider(SshService(StorageService()), TabMetadataService()), + ), ], child: MaterialApp( theme: buildMobileTheme(), @@ -19,6 +32,8 @@ Widget _wrap(Widget child) => MultiProvider( ); void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + testWidgets('shows four tab labels in MobileTabBar', (tester) async { await tester.pumpWidget(_wrap(const MobileHomeShell())); await tester.pumpAndSettle(); diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index 779a63d6..5e656211 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -1,16 +1,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; +import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/known_hosts_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; Widget _wrap(Widget child) => MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => KnownHostsProvider.forTest([]), ), + ChangeNotifierProvider( + create: (_) => HostProvider(StorageService()), + ), + ChangeNotifierProvider( + create: (_) => + SessionProvider(SshService(StorageService()), TabMetadataService()), + ), ], child: MaterialApp( theme: buildMobileTheme(), @@ -19,6 +32,8 @@ Widget _wrap(Widget child) => MultiProvider( ); void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + testWidgets('shows MobileTabBar with four destinations', (tester) async { await tester.pumpWidget(_wrap(const MobileHomeShell())); await tester.pumpAndSettle(); diff --git a/app/test/mobile/mobile_hosts_screen_test.dart b/app/test/mobile/mobile_hosts_screen_test.dart index 7012aea8..48078d15 100644 --- a/app/test/mobile/mobile_hosts_screen_test.dart +++ b/app/test/mobile/mobile_hosts_screen_test.dart @@ -6,6 +6,7 @@ import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/session_provider.dart'; import 'package:yourssh/mobile/screens/mobile_hosts_screen.dart'; +import 'package:yourssh/mobile/services/host_reachability_probe.dart'; import 'package:yourssh/mobile/widgets/host_card.dart'; import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; @@ -13,13 +14,15 @@ import 'package:yourssh/services/tab_metadata_service.dart'; Future _pump(WidgetTester tester, HostProvider hosts) async { final sessions = SessionProvider(SshService(StorageService()), TabMetadataService()); + final probe = HostReachabilityProbe(connector: (_, __, ___) async {}); await tester.pumpWidget(MaterialApp( home: MultiProvider( providers: [ ChangeNotifierProvider.value(value: hosts), ChangeNotifierProvider.value(value: sessions), + ChangeNotifierProvider.value(value: probe), ], - child: MobileHostsScreen(onConnected: () {}, onAddHost: () {}), + child: const MobileHostsScreen(), ), )); await tester.pumpAndSettle(); diff --git a/app/test/mobile/screens/hosts_screen_test.dart b/app/test/mobile/screens/hosts_screen_test.dart new file mode 100644 index 00000000..678101d8 --- /dev/null +++ b/app/test/mobile/screens/hosts_screen_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:yourssh/mobile/screens/mobile_hosts_screen.dart'; +import 'package:yourssh/mobile/services/host_reachability_probe.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/mobile/widgets/mobile_fab.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +Future _pump(WidgetTester tester, HostProvider hosts) async { + final sessions = SessionProvider(SshService(StorageService()), TabMetadataService()); + final probe = HostReachabilityProbe( + connector: (_, __, ___) async {}, // no-op — no real network in tests + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: hosts), + ChangeNotifierProvider.value(value: sessions), + ChangeNotifierProvider.value(value: probe), + ], + child: const MobileHostsScreen(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows title "Hosts"', (tester) async { + await _pump(tester, HostProvider(StorageService())); + expect(find.text('Hosts'), findsOneWidget); + }); + + testWidgets('renders both host labels and Production section header', (tester) async { + final hosts = HostProvider(StorageService()); + await hosts.addHost(Host( + label: 'web-01', + host: '10.0.0.1', + username: 'root', + tags: ['Production'], + )); + await hosts.addHost(Host( + label: 'db-01', + host: '10.0.0.2', + username: 'root', + tags: ['Production'], + )); + + await _pump(tester, hosts); + + expect(find.text('web-01'), findsOneWidget); + expect(find.text('db-01'), findsOneWidget); + expect(find.text('PRODUCTION'), findsOneWidget); + }); + + testWidgets('FAB is present', (tester) async { + await _pump(tester, HostProvider(StorageService())); + expect(find.byType(MobileFab), findsOneWidget); + }); +} From 8631ae7395f8509c6fbb9c7c48ccf4a545508d74 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:27:52 +0700 Subject: [PATCH 65/96] perf(mobile): flatten hosts list build to O(n) --- .../mobile/screens/mobile_hosts_screen.dart | 106 ++++++++++-------- 1 file changed, 61 insertions(+), 45 deletions(-) diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 935c0494..a81fc1c8 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -87,6 +87,20 @@ class _MobileHostsScreenState extends State { ); } + /// Flat list of row descriptors for the grouped host list. + /// Each entry is either a [_HeaderRow] (section title) or a [_HostRow]. + /// Built once per [build] — O(n) — so [ListView.builder] indexes O(1). + List<_ListRow> _buildRows(Map> grouped) { + final rows = <_ListRow>[]; + for (final entry in grouped.entries) { + rows.add(_HeaderRow(entry.key)); + for (final host in entry.value) { + rows.add(_HostRow(host)); + } + } + return rows; + } + @override Widget build(BuildContext context) { final all = context.watch().allHosts; @@ -96,6 +110,7 @@ class _MobileHostsScreenState extends State { final tags = {for (final h in all) ...h.tags}.toList()..sort(); final filtered = _filtered(all); final grouped = _grouped(filtered); + final rows = _buildRows(grouped); // Online/total derived from probe states. final onlineCount = all @@ -153,11 +168,13 @@ class _MobileHostsScreenState extends State { ], ), ), + // TODO(later): wire overflow menu — visual placeholder. _CircleButton( icon: Icons.more_horiz, onTap: () {}, ), const SizedBox(width: MobileTokens.space2), + // TODO(later): wire user/profile action — visual placeholder. _CircleButton( icon: Icons.person_outline, onTap: () {}, @@ -254,9 +271,9 @@ class _MobileHostsScreenState extends State { child: ListView.builder( padding: const EdgeInsets.only( bottom: MobileTokens.space4 * 4), - itemCount: _groupedItemCount(grouped), + itemCount: rows.length, itemBuilder: (_, i) => - _groupedItem(i, grouped, sessions, probe), + _buildRowWidget(rows[i], sessions, probe), ), ), ), @@ -277,53 +294,36 @@ class _MobileHostsScreenState extends State { // ── Grouped list helpers ───────────────────────────────────────────────── - int _groupedItemCount(Map> grouped) { - int count = 0; - for (final entry in grouped.entries) { - count += 1 + entry.value.length; // header + cards - } - return count; - } - - Widget _groupedItem( - int index, - Map> grouped, + /// O(1) item builder — indexes into the pre-built flat [rows] list. + Widget _buildRowWidget( + _ListRow row, List sessions, HostReachabilityProbe probe, ) { - int cursor = 0; - for (final entry in grouped.entries) { - if (index == cursor) { - return Padding( - padding: const EdgeInsets.fromLTRB( - MobileTokens.space4, - MobileTokens.space3, - MobileTokens.space4, - 0, - ), - child: SectionHeader(entry.key), - ); - } - cursor++; - final localIdx = index - cursor; - if (localIdx < entry.value.length) { - final host = entry.value[localIdx]; - final ping = probe.pingFor(host.id); - final connState = _stateFor(host, sessions); - return HostCard( - host: host, - online: connState == HostConnState.connected || - ping.state == HostReachState.online, - connecting: connState == HostConnState.connecting || - ping.state == HostReachState.probing, - latencyMs: ping.ms, - onTap: () => _connect(host), - onLongPress: () => _showActions(host), - ); - } - cursor += entry.value.length; + if (row is _HeaderRow) { + return Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + 0, + ), + child: SectionHeader(row.title), + ); } - return const SizedBox.shrink(); + final host = (row as _HostRow).host; + final ping = probe.pingFor(host.id); + final connState = _stateFor(host, sessions); + return HostCard( + host: host, + online: connState == HostConnState.connected || + ping.state == HostReachState.online, + connecting: connState == HostConnState.connecting || + ping.state == HostReachState.probing, + latencyMs: ping.ms, + onTap: () => _connect(host), + onLongPress: () => _showActions(host), + ); } // ── Actions ────────────────────────────────────────────────────────────── @@ -427,6 +427,22 @@ class _MobileHostsScreenState extends State { } } +// ── Flat-list row descriptors ───────────────────────────────────────────────── + +sealed class _ListRow {} + +/// A section-header row carrying the tag/title string. +final class _HeaderRow extends _ListRow { + _HeaderRow(this.title); + final String title; +} + +/// A host row carrying the [Host] to render. +final class _HostRow extends _ListRow { + _HostRow(this.host); + final Host host; +} + // ── Helper widgets ──────────────────────────────────────────────────────────── class _CircleButton extends StatelessWidget { From e5d9d4b4eb0cc699e89e45a327cb530788745311 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:35:24 +0700 Subject: [PATCH 66/96] feat(mobile): grouped new/edit host form --- .../screens/mobile_add_host_screen.dart | 666 +++++++++++++++--- .../mobile/mobile_add_host_screen_test.dart | 6 + .../mobile/screens/add_host_screen_test.dart | 245 +++++++ 3 files changed, 812 insertions(+), 105 deletions(-) create mode 100644 app/test/mobile/screens/add_host_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart index b980fb1b..191ab9b2 100644 --- a/app/lib/mobile/screens/mobile_add_host_screen.dart +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -1,19 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../models/host.dart'; import '../../models/ssh_key.dart'; import '../../providers/host_provider.dart'; import '../../providers/key_provider.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; -import '../widgets/section_header.dart'; +import '../widgets/list_group.dart'; -/// Minimal add/edit host form for mobile: label/host/port/username + password -/// or a saved key. Pass [existing] to edit an existing host (preserving its -/// id, tags, jump chain, and other desktop-only fields); omit it to add a new -/// one. The full editor (tags, proxy, jump chain, RDP/VNC) is desktop-only. +// Shared pref key for the app-lock toggle (same as AppLockGate). +const _kAppLockPrefKey = 'yourssh.app_lock_enabled'; + +/// Grouped add/edit host form for mobile (Design 02). +/// +/// Pass [existing] to edit; omit to create. The screen preserves id, tags, +/// jump chain, RDP/VNC, cert/agent fields on edit — only what's shown is +/// modified. Group maps to/from the first host tag; "Run on connect" maps to +/// [Host.startupSnippet]. class MobileAddHostScreen extends StatefulWidget { /// The host to edit, or null to create a new one. final Host? existing; @@ -30,21 +36,34 @@ class _MobileAddHostScreenState extends State { final _port = TextEditingController(text: '22'); final _username = TextEditingController(); final _password = TextEditingController(); + final _group = TextEditingController(); + final _snippet = TextEditingController(); + bool _useKey = false; String? _keyId; bool get _isEdit => widget.existing != null; - /// Certificate and agent auth have no UI in this minimal form, so editing - /// such a host shows a read-only note and preserves the auth untouched. + /// Certificate and agent auth have no UI in this form; we show a read-only + /// note and preserve auth untouched on save. bool get _isCertOrAgent { final a = widget.existing?.authType; return a == AuthType.certificate || a == AuthType.agent; } + bool get _canSave => + _label.text.trim().isNotEmpty && + _address.text.trim().isNotEmpty && + _username.text.trim().isNotEmpty; + @override void initState() { super.initState(); + // Rebuild when any required field changes so Save button enables/disables. + _label.addListener(_onFieldChanged); + _address.addListener(_onFieldChanged); + _username.addListener(_onFieldChanged); + final h = widget.existing; if (h != null) { _label.text = h.label; @@ -53,58 +72,90 @@ class _MobileAddHostScreenState extends State { _username.text = h.username; _useKey = h.authType == AuthType.privateKey; _keyId = h.keyId; + // Group = first tag (round-trip: save writes it back as the first tag). + _group.text = h.tags.isNotEmpty ? h.tags.first : ''; + _snippet.text = h.startupSnippet ?? ''; } } + void _onFieldChanged() => setState(() {}); + @override void dispose() { - for (final c in [_label, _address, _port, _username, _password]) { + for (final c in [ + _label, + _address, + _port, + _username, + _password, + _group, + _snippet, + ]) { c.dispose(); } super.dispose(); } - Future _save() async { + // ── Save helpers ────────────────────────────────────────────────────────── + + /// Builds the updated tag list: replaces the first tag slot with [_group] + /// (or removes it if blank), while preserving additional existing tags. + List _buildTags(List existingTags) { + final groupVal = _group.text.trim(); + final rest = existingTags.length > 1 ? existingTags.sublist(1) : []; + if (groupVal.isEmpty) return rest; + return [groupVal, ...rest]; + } + + Future _doSave() async { final label = _label.text.trim(); final address = _address.text.trim(); final username = _username.text.trim(); - if (label.isEmpty || address.isEmpty || username.isEmpty) return; - final port = int.tryParse(_port.text.trim()) ?? 22; - final authType = _useKey ? AuthType.privateKey : AuthType.password; - final keyId = _useKey ? _keyId : null; + final snippetVal = _snippet.text.trim(); final provider = context.read(); final existing = widget.existing; if (existing != null) { - // Preserve id, tags, jump chain, and other fields the minimal form - // doesn't expose; only overwrite what's editable here. + final newTags = _buildTags(existing.tags); if (_isCertOrAgent) { - // Certificate / agent auth isn't editable here (no cert-path / agent - // UI), so keep authType + keyId exactly as-is — editing connection - // fields must never silently downgrade it to password. - await provider.updateHost(existing.copyWith( + // Preserve authType + keyId — never silently downgrade cert/agent auth. + final updated = existing.copyWith( label: label, host: address, port: port, username: username, - )); + startupSnippet: snippetVal.isEmpty ? null : snippetVal, + ); + // copyWith has no tags parameter — mutate directly after copy. + updated.tags + ..clear() + ..addAll(newTags); + await provider.updateHost(updated); } else { - // A blank password leaves the stored one untouched (updateHost skips - // empty passwords). + final authType = _useKey ? AuthType.privateKey : AuthType.password; + final keyId = _useKey ? _keyId : null; + final updated = existing.copyWith( + label: label, + host: address, + port: port, + username: username, + authType: authType, + keyId: keyId, + startupSnippet: snippetVal.isEmpty ? null : snippetVal, + ); + updated.tags + ..clear() + ..addAll(newTags); await provider.updateHost( - existing.copyWith( - label: label, - host: address, - port: port, - username: username, - authType: authType, - keyId: keyId, - ), + updated, password: _useKey ? null : _password.text, ); } } else { + final authType = _useKey ? AuthType.privateKey : AuthType.password; + final keyId = _useKey ? _keyId : null; + final groupVal = _group.text.trim(); final host = Host( label: label, host: address, @@ -112,116 +163,521 @@ class _MobileAddHostScreenState extends State { username: username, authType: authType, keyId: keyId, + tags: groupVal.isEmpty ? [] : [groupVal], + startupSnippet: snippetVal.isEmpty ? null : snippetVal, ); await provider.addHost(host, password: _useKey ? null : _password.text); } + } + + Future _save() async { + if (!_canSave) return; + await _doSave(); if (mounted) Navigator.of(context).pop(); } + Future _saveAndConnect() async { + if (!_canSave) return; + await _doSave(); + if (mounted) Navigator.of(context).pop(true); // true = caller should connect + } + + // ── Build ───────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final keys = context.watch().keys; + return Scaffold( - backgroundColor: AppColors.bg, - appBar: AppBar( - backgroundColor: AppColors.bg, - title: Text(_isEdit ? 'Edit host' : 'Add host'), - actions: [TextButton(onPressed: _save, child: const Text('Save'))], - ), + backgroundColor: MobileColors.bg, + appBar: _buildAppBar(), body: SafeArea( child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space5 * 2, + ), children: [ - const SectionHeader('Connection'), - _field(_label, 'Label', fieldKey: 'host-label'), - _field(_address, 'Host / IP', fieldKey: 'host-address'), - _field(_port, 'Port', - fieldKey: 'host-port', - keyboard: TextInputType.number, - formatters: [FilteringTextInputFormatter.digitsOnly]), - _field(_username, 'Username', fieldKey: 'host-username'), - const SizedBox(height: MobileTokens.space2), - const SectionHeader('Authentication'), - if (_isCertOrAgent) - _CertAgentNote(authType: widget.existing!.authType) - else ...[ - SwitchListTile( - value: _useKey, - onChanged: - keys.isEmpty ? null : (v) => setState(() => _useKey = v), - title: const Text('Use SSH key', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: keys.isEmpty - ? const Text('No keys imported — using password', - style: TextStyle( - color: AppColors.textSecondary, fontSize: 12)) - : null, - ), - if (_useKey) - DropdownButtonFormField( - initialValue: _keyId, - items: [ - for (final SshKeyEntry k in keys) - DropdownMenuItem(value: k.id, child: Text(k.label)), - ], - onChanged: (v) => setState(() => _keyId = v), - decoration: const InputDecoration(labelText: 'Key'), - ) - else - _field(_password, 'Password', - fieldKey: 'host-password', - obscure: true, - hint: _isEdit ? 'Leave blank to keep current' : null), - ], + _generalGroup(), + const SizedBox(height: MobileTokens.space4), + _authGroup(keys), + const SizedBox(height: MobileTokens.space4), + _advancedGroup(), + const SizedBox(height: MobileTokens.space5), + _saveConnectButton(), ], ), ), ); } - Widget _field( - TextEditingController c, - String label, { + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: MobileColors.bg, + elevation: 0, + automaticallyImplyLeading: false, + leading: TextButton( + onPressed: () => Navigator.of(context).maybePop(), + style: TextButton.styleFrom(padding: EdgeInsets.zero), + child: Text( + 'Hosts', + style: mobileBody(color: MobileColors.accent, weight: FontWeight.w500), + ), + ), + leadingWidth: 72, + centerTitle: true, + title: Text( + _isEdit ? 'Edit host' : 'New host', + style: mobileHeading(size: 17), + ), + actions: [ + TextButton( + onPressed: _canSave ? _save : null, + child: Text( + 'Save', + style: mobileBody( + color: _canSave ? MobileColors.accent : MobileColors.textFaint, + weight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: MobileTokens.space2), + ], + ); + } + + // ── Groups ──────────────────────────────────────────────────────────────── + + Widget _generalGroup() => ListGroup( + label: 'General', + children: [ + _inlineField( + fieldKey: 'host-label', + controller: _label, + label: 'Nickname', + textInputAction: TextInputAction.next, + ), + _inlineField( + fieldKey: 'host-address', + controller: _address, + label: 'Hostname', + mono: true, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + ), + _inlineField( + fieldKey: 'host-port', + controller: _port, + label: 'Port', + mono: true, + keyboardType: TextInputType.number, + formatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.next, + ), + ], + ); + + Widget _authGroup(List keys) { + final usernameRow = _inlineField( + fieldKey: 'host-username', + controller: _username, + label: 'Username', + mono: true, + textInputAction: TextInputAction.next, + ); + + if (_isCertOrAgent) { + return ListGroup( + label: 'Authentication', + children: [ + usernameRow, + _CertAgentNoteRow(authType: widget.existing!.authType), + ], + ); + } + + return ListGroup( + label: 'Authentication', + children: [ + usernameRow, + _MethodRow( + useKey: _useKey, + keysAvailable: keys.isNotEmpty, + onChanged: (v) => setState(() => _useKey = v), + ), + if (_useKey) + _KeyPickerRow( + keys: keys, + selectedId: _keyId, + onChanged: (v) => setState(() => _keyId = v), + ) + else + _inlineField( + fieldKey: 'host-password', + controller: _password, + label: 'Password', + obscure: true, + hint: _isEdit ? 'Leave blank to keep current' : null, + textInputAction: TextInputAction.done, + ), + const _BiometricRow(), + ], + ); + } + + Widget _advancedGroup() => ListGroup( + label: 'Advanced', + children: [ + _inlineField( + fieldKey: 'host-group', + controller: _group, + label: 'Group', + hint: 'e.g. Production', + textInputAction: TextInputAction.next, + ), + _inlineField( + fieldKey: 'host-startup-snippet', + controller: _snippet, + label: 'Run on connect', + mono: true, + hint: 'e.g. tmux attach', + textInputAction: TextInputAction.done, + ), + ], + ); + + Widget _saveConnectButton() => SizedBox( + width: double.infinity, + height: MobileTokens.touchTarget + 4, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: Colors.black, + disabledBackgroundColor: MobileColors.accentSoft, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _canSave ? _saveAndConnect : null, + child: Text( + 'Save & connect', + style: mobileBody( + color: _canSave ? Colors.black : MobileColors.textFaint, + weight: FontWeight.w600, + size: 16, + ), + ), + ), + ); + + // ── Field helpers ───────────────────────────────────────────────────────── + + Widget _inlineField({ required String fieldKey, + required TextEditingController controller, + required String label, bool obscure = false, + bool mono = false, String? hint, - TextInputType? keyboard, + TextInputType? keyboardType, List? formatters, + TextInputAction? textInputAction, }) { + final valueStyle = mono + ? mobileMono(size: 14, color: MobileColors.textPrimary) + : mobileBody(size: 15, color: MobileColors.textPrimary); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), + child: Row( + children: [ + SizedBox( + width: 110, + child: + Text(label, style: mobileBody(color: MobileColors.textMuted)), + ), + Expanded( + child: TextField( + key: Key(fieldKey), + controller: controller, + obscureText: obscure, + keyboardType: keyboardType, + inputFormatters: formatters, + textInputAction: textInputAction, + style: valueStyle, + textAlign: TextAlign.end, + decoration: InputDecoration( + hintText: hint, + hintStyle: + mobileMono(size: 13, color: MobileColors.textFaint), + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + cursorColor: MobileColors.accent, + ), + ), + ], + ), + ); + } +} + +// ── Auth sub-widgets ───────────────────────────────────────────────────────── + +/// Row that toggles between Password and SSH Key auth method. +class _MethodRow extends StatelessWidget { + final bool useKey; + final bool keysAvailable; + final ValueChanged onChanged; + + const _MethodRow({ + required this.useKey, + required this.keysAvailable, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), + child: Row( + children: [ + SizedBox( + width: 110, + child: + Text('Method', style: mobileBody(color: MobileColors.textMuted)), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _MethodPill( + label: 'Password', + selected: !useKey, + onTap: () => onChanged(false), + ), + const SizedBox(width: MobileTokens.space2), + _MethodPill( + label: 'SSH key', + selected: useKey, + enabled: keysAvailable, + onTap: keysAvailable ? () => onChanged(true) : null, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MethodPill extends StatelessWidget { + final String label; + final bool selected; + final bool enabled; + final VoidCallback? onTap; + + const _MethodPill({ + required this.label, + required this.selected, + this.enabled = true, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final bg = selected ? MobileColors.accent : MobileColors.surfaceAlt; + final fg = selected + ? Colors.black + : (enabled ? MobileColors.textPrimary : MobileColors.textFaint); + + final pill = Container( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space3, + vertical: MobileTokens.space1 + 2, + ), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all( + color: selected ? MobileColors.accent : MobileColors.border, + ), + ), + child: Text( + label, + style: mobileBody(size: 13, color: fg, weight: FontWeight.w500), + ), + ); + + if (onTap == null) return pill; + return InkWell( + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + onTap: onTap, + child: pill, + ); + } +} + +/// Dropdown row for picking an SSH key (shown when auth method = SSH key). +class _KeyPickerRow extends StatelessWidget { + final List keys; + final String? selectedId; + final ValueChanged onChanged; + + const _KeyPickerRow({ + required this.keys, + required this.selectedId, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: TextField( - key: Key(fieldKey), - controller: c, - obscureText: obscure, - keyboardType: keyboard, - inputFormatters: formatters, - style: const TextStyle(color: AppColors.textPrimary), - decoration: InputDecoration(labelText: label, hintText: hint), + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), + child: Row( + children: [ + SizedBox( + width: 110, + child: Text('Key', style: mobileBody(color: MobileColors.textMuted)), + ), + Expanded( + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedId, + isExpanded: true, + alignment: Alignment.centerRight, + style: mobileMono(size: 13, color: MobileColors.textMuted), + dropdownColor: MobileColors.surface, + iconEnabledColor: MobileColors.textFaint, + items: [ + for (final k in keys) + DropdownMenuItem( + value: k.id, + child: Text( + k.label, + textAlign: TextAlign.end, + style: mobileMono( + size: 13, color: MobileColors.textPrimary), + ), + ), + ], + onChanged: onChanged, + ), + ), + ), + ], ), ); } } -/// Read-only authentication note shown when editing a certificate- or -/// agent-authenticated host (which the minimal mobile form can't edit). The -/// auth is preserved as-is on save. -class _CertAgentNote extends StatelessWidget { +/// Read-only auth note shown when editing a cert- or agent-authenticated host. +/// The auth type is preserved as-is on save. +class _CertAgentNoteRow extends StatelessWidget { final AuthType authType; - const _CertAgentNote({required this.authType}); + const _CertAgentNoteRow({required this.authType}); @override Widget build(BuildContext context) { final kind = authType == AuthType.certificate ? 'Certificate' : 'SSH agent'; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.verified_user_outlined, - color: AppColors.textSecondary), - title: Text('$kind authentication', - style: const TextStyle(color: AppColors.textPrimary)), - subtitle: const Text('Configured on desktop — kept unchanged', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), + child: Row( + children: [ + const Icon(Icons.verified_user_outlined, + color: MobileColors.textFaint, size: 16), + const SizedBox(width: MobileTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$kind authentication', + style: mobileBody(color: MobileColors.textPrimary)), + const SizedBox(height: 2), + Text('Configured on desktop — kept unchanged', + style: mobileBody( + size: 12, color: MobileColors.textFaint)), + ], + ), + ), + ], + ), + ); + } +} + +/// Amber toggle row for Face ID / biometric app-lock (reads/writes +/// [_kAppLockPrefKey] in SharedPreferences — same key as AppLockGate). +class _BiometricRow extends StatefulWidget { + const _BiometricRow(); + + @override + State<_BiometricRow> createState() => _BiometricRowState(); +} + +class _BiometricRowState extends State<_BiometricRow> { + bool _enabled = true; + bool _loaded = false; + + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((p) { + if (!mounted) return; + setState(() { + _enabled = p.getBool(_kAppLockPrefKey) ?? true; + _loaded = true; + }); + }); + } + + Future _toggle(bool v) async { + setState(() => _enabled = v); + final p = await SharedPreferences.getInstance(); + await p.setBool(_kAppLockPrefKey, v); + } + + @override + Widget build(BuildContext context) { + if (!_loaded) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space1, + ), + child: Row( + children: [ + Expanded( + child: Text('Unlock with biometrics', + style: mobileBody(color: MobileColors.textPrimary)), + ), + Switch( + value: _enabled, + onChanged: _toggle, + activeThumbColor: MobileColors.accent, + activeTrackColor: MobileColors.accent.withAlpha(77), + ), + ], + ), ); } } diff --git a/app/test/mobile/mobile_add_host_screen_test.dart b/app/test/mobile/mobile_add_host_screen_test.dart index 9cce5d76..fb9eff92 100644 --- a/app/test/mobile/mobile_add_host_screen_test.dart +++ b/app/test/mobile/mobile_add_host_screen_test.dart @@ -24,10 +24,14 @@ void main() { testWidgets('saving creates a host in the provider', (tester) async { final hosts = HostProvider(StorageService()); await tester.pumpWidget(wrap(hosts, const MobileAddHostScreen())); + await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('host-label')), 'edge-1'); + await tester.pump(); await tester.enterText(find.byKey(const Key('host-address')), '192.168.1.9'); + await tester.pump(); await tester.enterText(find.byKey(const Key('host-username')), 'pi'); + await tester.pump(); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -58,6 +62,7 @@ void main() { expect(find.text('10.0.0.5'), findsOneWidget); await tester.enterText(find.byKey(const Key('host-label')), 'New Name'); + await tester.pump(); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -89,6 +94,7 @@ void main() { expect(find.byKey(const Key('host-password')), findsNothing); await tester.enterText(find.byKey(const Key('host-label')), 'Cert Box 2'); + await tester.pump(); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); diff --git a/app/test/mobile/screens/add_host_screen_test.dart b/app/test/mobile/screens/add_host_screen_test.dart new file mode 100644 index 00000000..deb38bf0 --- /dev/null +++ b/app/test/mobile/screens/add_host_screen_test.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:yourssh/mobile/screens/mobile_add_host_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; + +Widget _wrap(HostProvider hosts, Widget child) => MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: hosts), + ChangeNotifierProvider(create: (_) => KeyProvider()), + ], + child: child, + ), + ); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('new-host mode shows "New host" title and Save button', + (tester) async { + final hosts = HostProvider(StorageService()); + await tester.pumpWidget(_wrap(hosts, const MobileAddHostScreen())); + await tester.pumpAndSettle(); + + expect(find.text('New host'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('edit mode prefills nickname, hostname, port', (tester) async { + final hosts = HostProvider(StorageService()); + final existing = Host( + label: 'prod-web', + host: '10.0.0.5', + port: 2222, + username: 'ubuntu', + tags: const ['prod'], + ); + await hosts.addHost(existing); + + await tester.pumpWidget( + _wrap(hosts, MobileAddHostScreen(existing: existing))); + await tester.pumpAndSettle(); + + // Title should say "Edit host" + expect(find.text('Edit host'), findsOneWidget); + + // Nickname / hostname / port prefilled + expect(find.widgetWithText(TextField, 'prod-web'), findsOneWidget); + expect(find.widgetWithText(TextField, '10.0.0.5'), findsOneWidget); + expect(find.widgetWithText(TextField, '2222'), findsOneWidget); + }); + + testWidgets('tapping Save in new mode calls provider.addHost', (tester) async { + bool addCalled = false; + final hosts = _FakeHostProvider(onAdd: () => addCalled = true); + await tester.pumpWidget(_wrap(hosts, const MobileAddHostScreen())); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('host-label')), 'my-box'); + await tester.pump(); + await tester.enterText(find.byKey(const Key('host-address')), '192.168.1.1'); + await tester.pump(); + await tester.enterText(find.byKey(const Key('host-username')), 'admin'); + await tester.pump(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(addCalled, isTrue); + }); + + testWidgets('tapping Save in edit mode calls provider.updateHost', + (tester) async { + bool updateCalled = false; + final existing = Host( + label: 'old', + host: '1.2.3.4', + port: 22, + username: 'root', + ); + final hosts = _FakeHostProvider( + initialHost: existing, + onUpdate: () => updateCalled = true, + ); + + await tester.pumpWidget( + _wrap(hosts, MobileAddHostScreen(existing: existing))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('host-label')), 'new-name'); + await tester.pump(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(updateCalled, isTrue); + }); + + testWidgets('edit preserves id, tags, jump chain when saving', (tester) async { + final hosts = HostProvider(StorageService()); + final existing = Host( + label: 'Bastion', + host: '10.0.0.3', + port: 22, + username: 'user', + tags: const ['infra', 'prod'], + jumpHostIds: ['jump-1', 'jump-2'], + ); + await hosts.addHost(existing); + + await tester.pumpWidget( + _wrap(hosts, MobileAddHostScreen(existing: existing))); + await tester.pumpAndSettle(); + + // Change the label only + await tester.enterText(find.byKey(const Key('host-label')), 'Bastion 2'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(hosts.allHosts.length, 1); + final updated = hosts.allHosts.single; + expect(updated.id, existing.id); + expect(updated.label, 'Bastion 2'); + expect(updated.tags, containsAll(['infra', 'prod'])); + expect(updated.jumpHostIds, ['jump-1', 'jump-2']); + }); + + testWidgets('editing cert host preserves authType and shows read-only note', + (tester) async { + final hosts = HostProvider(StorageService()); + final cert = Host( + label: 'Cert Box', + host: '10.0.0.7', + username: 'deploy', + authType: AuthType.certificate, + keyId: 'key-1', + ); + await hosts.addHost(cert); + + await tester.pumpWidget( + _wrap(hosts, MobileAddHostScreen(existing: cert))); + await tester.pumpAndSettle(); + + // Auth note is shown — no password field + expect(find.text('Certificate authentication'), findsOneWidget); + expect(find.byKey(const Key('host-password')), findsNothing); + + await tester.enterText(find.byKey(const Key('host-label')), 'Cert Box 2'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final updated = hosts.allHosts.single; + expect(updated.label, 'Cert Box 2'); + expect(updated.authType, AuthType.certificate); + expect(updated.keyId, 'key-1'); + }); + + testWidgets('Group field maps to/from host tags', (tester) async { + final hosts = HostProvider(StorageService()); + final existing = Host( + label: 'db-01', + host: '10.0.0.9', + username: 'admin', + tags: const ['staging'], + ); + await hosts.addHost(existing); + + await tester.pumpWidget( + _wrap(hosts, MobileAddHostScreen(existing: existing))); + await tester.pumpAndSettle(); + + // The Group field should be prefilled with the first tag + final groupField = find.byKey(const Key('host-group')); + expect(groupField, findsOneWidget); + expect( + tester.widget(groupField).controller?.text ?? '', 'staging'); + }); + + testWidgets('Run-on-connect field maps to startupSnippet', (tester) async { + final hosts = HostProvider(StorageService()); + final existing = Host( + label: 'dev-box', + host: '10.0.0.10', + username: 'dev', + startupSnippet: 'tmux attach', + ); + await hosts.addHost(existing); + + await tester.pumpWidget( + _wrap(hosts, MobileAddHostScreen(existing: existing))); + await tester.pumpAndSettle(); + + final snippetField = find.byKey(const Key('host-startup-snippet')); + expect(snippetField, findsOneWidget); + expect(tester.widget(snippetField).controller?.text ?? '', + 'tmux attach'); + }); +} + +// --------------------------------------------------------------------------- +// Fake provider to verify which provider method is called +// --------------------------------------------------------------------------- + +class _FakeHostProvider extends HostProvider { + final VoidCallback? onAdd; + final VoidCallback? onUpdate; + + _FakeHostProvider({Host? initialHost, this.onAdd, this.onUpdate}) + : super(StorageService()) { + if (initialHost != null) { + // Seed synchronously so tests don't need to await + // ignore: invalid_use_of_protected_member + hosts; // access to init list + _seedHosts.add(initialHost); + } + } + + // We can't easily sync-seed HostProvider (it loads async), so for the + // fake we override add/update without persisting. + final List _seedHosts = []; + + @override + List get allHosts => _seedHosts; + + @override + Future addHost(Host host, {String? password}) async { + _seedHosts.add(host); + onAdd?.call(); + notifyListeners(); + } + + @override + Future updateHost(Host host, {String? password}) async { + final idx = _seedHosts.indexWhere((h) => h.id == host.id); + if (idx != -1) _seedHosts[idx] = host; + onUpdate?.call(); + notifyListeners(); + } +} From f0446ef6e03714b137b0d7cdb6af7ce5439e0f3b Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:42:11 +0700 Subject: [PATCH 67/96] fix(mobile): correct app-lock pref key + wire Save & connect --- .../screens/mobile_add_host_screen.dart | 20 ++- .../mobile/screens/add_host_screen_test.dart | 120 +++++++++++++++++- 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/app/lib/mobile/screens/mobile_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart index 191ab9b2..c4e9efc2 100644 --- a/app/lib/mobile/screens/mobile_add_host_screen.dart +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -7,13 +7,12 @@ import '../../models/host.dart'; import '../../models/ssh_key.dart'; import '../../providers/host_provider.dart'; import '../../providers/key_provider.dart'; +import '../../providers/session_provider.dart'; +import '../security/app_lock_gate.dart'; import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; import '../widgets/list_group.dart'; -// Shared pref key for the app-lock toggle (same as AppLockGate). -const _kAppLockPrefKey = 'yourssh.app_lock_enabled'; - /// Grouped add/edit host form for mobile (Design 02). /// /// Pass [existing] to edit; omit to create. The screen preserves id, tags, @@ -179,7 +178,14 @@ class _MobileAddHostScreenState extends State { Future _saveAndConnect() async { if (!_canSave) return; await _doSave(); - if (mounted) Navigator.of(context).pop(true); // true = caller should connect + if (!mounted) return; + // Look up the just-saved host by id so we get the freshest copy. + final hostId = widget.existing?.id ?? context.read().allHosts.last.id; + final host = context.read().byId(hostId); + if (host != null && mounted) { + context.read().connectAny(host); + } + if (mounted) Navigator.of(context).pop(); } // ── Build ───────────────────────────────────────────────────────────────── @@ -626,7 +632,7 @@ class _CertAgentNoteRow extends StatelessWidget { } /// Amber toggle row for Face ID / biometric app-lock (reads/writes -/// [_kAppLockPrefKey] in SharedPreferences — same key as AppLockGate). +/// [kAppLockPrefKey] in SharedPreferences — same key as AppLockGate). class _BiometricRow extends StatefulWidget { const _BiometricRow(); @@ -644,7 +650,7 @@ class _BiometricRowState extends State<_BiometricRow> { SharedPreferences.getInstance().then((p) { if (!mounted) return; setState(() { - _enabled = p.getBool(_kAppLockPrefKey) ?? true; + _enabled = p.getBool(kAppLockPrefKey) ?? true; _loaded = true; }); }); @@ -653,7 +659,7 @@ class _BiometricRowState extends State<_BiometricRow> { Future _toggle(bool v) async { setState(() => _enabled = v); final p = await SharedPreferences.getInstance(); - await p.setBool(_kAppLockPrefKey, v); + await p.setBool(kAppLockPrefKey, v); } @override diff --git a/app/test/mobile/screens/add_host_screen_test.dart b/app/test/mobile/screens/add_host_screen_test.dart index deb38bf0..74040fe4 100644 --- a/app/test/mobile/screens/add_host_screen_test.dart +++ b/app/test/mobile/screens/add_host_screen_test.dart @@ -4,18 +4,31 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_add_host_screen.dart'; +import 'package:yourssh/mobile/security/app_lock_gate.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/models/app_session.dart'; import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; - -Widget _wrap(HostProvider hosts, Widget child) => MaterialApp( +import 'package:yourssh/services/tab_metadata_service.dart'; + +Widget _wrap( + HostProvider hosts, + Widget child, { + _FakeSessionProvider? session, +}) => + MaterialApp( theme: buildMobileTheme(), home: MultiProvider( providers: [ ChangeNotifierProvider.value(value: hosts), ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider.value( + value: session ?? _FakeSessionProvider(), + ), ], child: child, ), @@ -201,10 +214,88 @@ void main() { expect(tester.widget(snippetField).controller?.text ?? '', 'tmux attach'); }); + + // ── Fix 1: biometric toggle uses the shared kAppLockPrefKey ───────────── + + testWidgets('biometric toggle persists under shared kAppLockPrefKey', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final hosts = HostProvider(StorageService()); + await tester.pumpWidget(_wrap(hosts, const MobileAddHostScreen())); + await tester.pumpAndSettle(); + + // The switch should be present and enabled by default (kAppLockPrefKey + // absent → defaults to true). + final switchFinder = find.byType(Switch); + expect(switchFinder, findsOneWidget); + expect(tester.widget(switchFinder).value, isTrue); + + // Toggle it off. + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + + final prefs = await SharedPreferences.getInstance(); + // Must write under the SHARED key, not a local orphan key. + expect(prefs.getBool(kAppLockPrefKey), isFalse); + // The old orphan key must not exist. + expect(prefs.getBool('yourssh.app_lock_enabled'), isNull); + }); + + // ── Fix 2: "Save & connect" wires connectAny; plain "Save" does not ───── + + testWidgets('Save & connect calls connectAny; plain Save does not', + (tester) async { + final session = _FakeSessionProvider(); + final hosts = _FakeHostProvider(onAdd: () {}); + await tester.pumpWidget( + _wrap(hosts, const MobileAddHostScreen(), session: session), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('host-label')), 'box'); + await tester.pump(); + await tester.enterText(find.byKey(const Key('host-address')), '10.0.0.1'); + await tester.pump(); + await tester.enterText(find.byKey(const Key('host-username')), 'root'); + await tester.pump(); + + // Tap plain "Save" — connectAny must NOT be called. + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(session.connectAnyCalled, isFalse); + expect(session.connectedHost, isNull); + }); + + testWidgets('Save & connect button calls connectAny with saved host', + (tester) async { + final session = _FakeSessionProvider(); + final hosts = _FakeHostProvider(onAdd: () {}); + await tester.pumpWidget( + _wrap(hosts, const MobileAddHostScreen(), session: session), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('host-label')), 'prod'); + await tester.pump(); + await tester.enterText(find.byKey(const Key('host-address')), '10.0.0.2'); + await tester.pump(); + await tester.enterText(find.byKey(const Key('host-username')), 'ubuntu'); + await tester.pump(); + + // Drag the ListView down to reveal the "Save & connect" button at the bottom. + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save & connect')); + await tester.pumpAndSettle(); + + expect(session.connectAnyCalled, isTrue); + expect(session.connectedHost?.label, 'prod'); + expect(session.connectedHost?.host, '10.0.0.2'); + }); } // --------------------------------------------------------------------------- -// Fake provider to verify which provider method is called +// Fake providers // --------------------------------------------------------------------------- class _FakeHostProvider extends HostProvider { @@ -228,6 +319,14 @@ class _FakeHostProvider extends HostProvider { @override List get allHosts => _seedHosts; + @override + Host? byId(String id) { + for (final h in _seedHosts) { + if (h.id == id) return h; + } + return null; + } + @override Future addHost(Host host, {String? password}) async { _seedHosts.add(host); @@ -243,3 +342,18 @@ class _FakeHostProvider extends HostProvider { notifyListeners(); } } + +class _FakeSessionProvider extends SessionProvider { + bool connectAnyCalled = false; + Host? connectedHost; + + _FakeSessionProvider() + : super(SshService(StorageService()), TabMetadataService()); + + @override + Future connectAny(Host host, {String? initialCommand}) async { + connectAnyCalled = true; + connectedHost = host; + return null; + } +} From 848ce6044819b3c02fb61bf67fd99e66c75a1736 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:45:21 +0700 Subject: [PATCH 68/96] fix(mobile): return saved host id from _doSave for reliable connect _doSave() now returns Future (the saved host's id). For new hosts the id comes from the Host object built before addHost is called; for edits it is existing.id. _saveAndConnect uses the returned id to call byId() instead of the fragile allHosts.last assumption. --- app/lib/mobile/screens/mobile_add_host_screen.dart | 12 +++++++----- app/test/mobile/screens/add_host_screen_test.dart | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/lib/mobile/screens/mobile_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart index c4e9efc2..eb10bcdf 100644 --- a/app/lib/mobile/screens/mobile_add_host_screen.dart +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -106,7 +106,8 @@ class _MobileAddHostScreenState extends State { return [groupVal, ...rest]; } - Future _doSave() async { + /// Persists the form and returns the saved host's id. + Future _doSave() async { final label = _label.text.trim(); final address = _address.text.trim(); final username = _username.text.trim(); @@ -151,6 +152,7 @@ class _MobileAddHostScreenState extends State { password: _useKey ? null : _password.text, ); } + return existing.id; } else { final authType = _useKey ? AuthType.privateKey : AuthType.password; final keyId = _useKey ? _keyId : null; @@ -166,6 +168,7 @@ class _MobileAddHostScreenState extends State { startupSnippet: snippetVal.isEmpty ? null : snippetVal, ); await provider.addHost(host, password: _useKey ? null : _password.text); + return host.id; } } @@ -177,11 +180,10 @@ class _MobileAddHostScreenState extends State { Future _saveAndConnect() async { if (!_canSave) return; - await _doSave(); + final savedId = await _doSave(); if (!mounted) return; - // Look up the just-saved host by id so we get the freshest copy. - final hostId = widget.existing?.id ?? context.read().allHosts.last.id; - final host = context.read().byId(hostId); + // Use the id returned by _doSave — deterministic, no .last assumption. + final host = context.read().byId(savedId); if (host != null && mounted) { context.read().connectAny(host); } diff --git a/app/test/mobile/screens/add_host_screen_test.dart b/app/test/mobile/screens/add_host_screen_test.dart index 74040fe4..42ec89a4 100644 --- a/app/test/mobile/screens/add_host_screen_test.dart +++ b/app/test/mobile/screens/add_host_screen_test.dart @@ -291,6 +291,13 @@ void main() { expect(session.connectAnyCalled, isTrue); expect(session.connectedHost?.label, 'prod'); expect(session.connectedHost?.host, '10.0.0.2'); + + // The host passed to connectAny must be the one that was actually saved — + // id lookup must not rely on allHosts.last. + final savedHost = hosts.lastAdded; + expect(savedHost, isNotNull, reason: 'addHost was not called'); + expect(session.connectedHost?.id, savedHost!.id, + reason: 'connectAny received a different host than the one saved'); }); } @@ -316,6 +323,10 @@ class _FakeHostProvider extends HostProvider { // fake we override add/update without persisting. final List _seedHosts = []; + /// The most recently added host — used to verify connectAny received the + /// correct host instance (id must match, not just label). + Host? lastAdded; + @override List get allHosts => _seedHosts; @@ -330,6 +341,7 @@ class _FakeHostProvider extends HostProvider { @override Future addHost(Host host, {String? password}) async { _seedHosts.add(host); + lastAdded = host; onAdd?.call(); notifyListeners(); } From 3e1459bc51159e9799500f23d4e6fe9c59fecdb7 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:50:42 +0700 Subject: [PATCH 69/96] feat(mobile): keys screen with generate/import --- app/lib/mobile/screens/mobile_home_shell.dart | 3 +- .../mobile/screens/mobile_keys_screen.dart | 676 ++++++++++++++++++ app/lib/mobile/widgets/key_card.dart | 128 ++++ app/test/mobile/mobile_home_shell_test.dart | 8 + app/test/mobile/screens/keys_screen_test.dart | 145 ++++ 5 files changed, 959 insertions(+), 1 deletion(-) create mode 100644 app/lib/mobile/screens/mobile_keys_screen.dart create mode 100644 app/lib/mobile/widgets/key_card.dart create mode 100644 app/test/mobile/screens/keys_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 9f498d9c..e1a7fee4 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -5,6 +5,7 @@ import '../security/tofu_watcher.dart'; import '../services/host_reachability_probe.dart'; import '../widgets/mobile_tab_bar.dart'; import 'mobile_hosts_screen.dart'; +import 'mobile_keys_screen.dart'; /// Bottom-navigation shell for the Android app. /// Four tabs: Hosts · Snippets · Keys · Settings. @@ -30,7 +31,7 @@ class _MobileHomeShellState extends State { children: const [ MobileHostsScreen(), Center(child: Text('Snippets')), - Center(child: Text('Keys')), + MobileKeysScreen(), Center(child: Text('Settings')), ], ), diff --git a/app/lib/mobile/screens/mobile_keys_screen.dart b/app/lib/mobile/screens/mobile_keys_screen.dart new file mode 100644 index 00000000..d810d6c1 --- /dev/null +++ b/app/lib/mobile/screens/mobile_keys_screen.dart @@ -0,0 +1,676 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; + +import '../../models/ssh_key.dart'; +import '../../providers/host_provider.dart'; +import '../../providers/key_provider.dart'; +import '../../services/key_gen_service.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/key_card.dart'; + +/// Keys tab — list of SSH keys with Generate / Import actions. +class MobileKeysScreen extends StatelessWidget { + const MobileKeysScreen({super.key}); + + @override + Widget build(BuildContext context) { + final keys = context.watch().keys; + final hosts = context.watch().allHosts; + + // Count how many hosts reference each key. + int usageFor(String keyId) => + hosts.where((h) => h.keyId == keyId).length; + + final inUseCount = + keys.where((k) => usageFor(k.id) > 0).length; + + return Scaffold( + backgroundColor: MobileColors.bg, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space2, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Keys', + style: mobileHeading( + size: 30, weight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text( + '${keys.length} ${keys.length == 1 ? 'key' : 'keys'}' + ' · $inUseCount in use', + style: mobileBody( + size: 13, color: MobileColors.textMuted), + ), + ], + ), + ), + _CircleButton( + icon: Icons.add, + onTap: () => _showGenerateDialog(context), + ), + const SizedBox(width: MobileTokens.space2), + _CircleButton( + icon: Icons.upload_file_outlined, + onTap: () => _importKey(context), + ), + ], + ), + ), + + // ── Key list ───────────────────────────────────────────────────── + Expanded( + child: keys.isEmpty + ? _EmptyState( + onGenerate: () => _showGenerateDialog(context), + onImport: () => _importKey(context), + ) + : ListView.builder( + padding: const EdgeInsets.only( + bottom: MobileTokens.space4 * 4), + itemCount: keys.length, + itemBuilder: (_, i) { + final key = keys[i]; + return KeyCard( + key: ValueKey(key.id), + entry: key, + hostCount: usageFor(key.id), + onTap: () => _showKeyActions(context, key), + onLongPress: () => _showKeyActions(context, key), + ); + }, + ), + ), + + // ── Bottom action buttons ───────────────────────────────────────── + _BottomActions( + onGenerate: () => _showGenerateDialog(context), + onImport: () => _importKey(context), + ), + ], + ), + ), + ); + } + + // ── Actions ──────────────────────────────────────────────────────────────── + + Future _importKey(BuildContext context) async { + final result = await FilePicker.platform.pickFiles( + dialogTitle: 'Select SSH Private Key', + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + final path = result.files.first.path; + if (path == null) return; + if (!context.mounted) return; + await context + .read() + .addKeyFromFile(path, result.files.first.name); + } + + void _showGenerateDialog(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: MobileColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => ChangeNotifierProvider.value( + value: context.read(), + child: Provider.value( + value: context.read(), + child: const _GenerateSheet(), + ), + ), + ); + } + + Future _showKeyActions( + BuildContext context, SshKeyEntry entry) async { + await showModalBottomSheet( + context: context, + backgroundColor: MobileColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _sheetHandle(), + const SizedBox(height: MobileTokens.space2), + ListTile( + leading: const Icon(Icons.delete_outline, + color: MobileColors.red), + title: Text('Remove key', + style: mobileBody(size: 16, color: MobileColors.red)), + onTap: () { + Navigator.pop(ctx); + _confirmDelete(context, entry); + }, + ), + const SizedBox(height: MobileTokens.space2), + ], + ), + ), + ); + } + + Future _confirmDelete( + BuildContext context, SshKeyEntry entry) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MobileColors.surface, + title: Text('Remove "${entry.label}"?', + style: mobileHeading(size: 17)), + content: Text( + 'This removes the key from the keychain. The file on disk is kept.', + style: mobileBody(size: 14, color: MobileColors.textMuted), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Cancel', style: mobileBody(size: 15)), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text('Remove', + style: mobileBody(size: 15, color: MobileColors.red)), + ), + ], + ), + ); + if (ok == true && context.mounted) { + await context.read().deleteKey(entry.id); + } + } +} + +// ── Generate bottom sheet ────────────────────────────────────────────────────── + +class _GenerateSheet extends StatefulWidget { + const _GenerateSheet(); + + @override + State<_GenerateSheet> createState() => _GenerateSheetState(); +} + +class _GenerateSheetState extends State<_GenerateSheet> { + final _nameCtrl = TextEditingController(); + final _passphraseCtrl = TextEditingController(); + String _algo = 'ed25519'; + bool _sshKeygenAvailable = false; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _probe(); + } + + Future _probe() async { + final available = + await context.read().probeSshKeygen(); + if (mounted) setState(() => _sshKeygenAvailable = available); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _passphraseCtrl.dispose(); + super.dispose(); + } + + Future _generate() async { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + setState(() => _error = 'Enter a key name'); + return; + } + setState(() { + _loading = true; + _error = null; + }); + try { + final keyGen = context.read(); + final keyProv = context.read(); + final dir = await _keysDir(); + final GeneratedKey result; + if (_algo == 'ed25519') { + result = await keyGen.generateEd25519( + name: name, + passphrase: _passphraseCtrl.text, + dir: dir, + ); + } else { + result = await keyGen.generateWithSshKeygen( + type: _algo, + name: name, + passphrase: _passphraseCtrl.text, + dir: dir, + ); + } + if (!mounted) return; + await keyProv.addKeyFromFile(result.privateKeyPath, name); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _keysDir() async { + final docs = await getApplicationDocumentsDirectory(); + final dir = Directory('${docs.path}/YourSSH/keys'); + if (!dir.existsSync()) dir.createSync(recursive: true); + return dir.path; + } + + @override + Widget build(BuildContext context) { + final algoOptions = [ + const _AlgoOption('ed25519', 'Ed25519', + 'Fast, modern, recommended'), + if (_sshKeygenAvailable) ...[ + const _AlgoOption('rsa', 'RSA 4096', 'Widely compatible'), + const _AlgoOption('ecdsa', 'ECDSA P-256', 'Elliptic curve'), + ], + ]; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _sheetHandle(), + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + 0, + ), + child: Text('Generate Key', + style: mobileHeading(size: 18)), + ), + const SizedBox(height: MobileTokens.space3), + + // ── Algorithm picker ──────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + child: Wrap( + spacing: MobileTokens.space2, + runSpacing: MobileTokens.space2, + children: [ + for (final opt in algoOptions) + _AlgoChip( + option: opt, + selected: _algo == opt.value, + onTap: () => setState(() => _algo = opt.value), + ), + ], + ), + ), + + const SizedBox(height: MobileTokens.space3), + + // ── Name field ────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + child: _MobileField( + controller: _nameCtrl, + label: 'Key name', + hint: 'e.g. id_ed25519', + ), + ), + const SizedBox(height: MobileTokens.space3), + + // ── Passphrase field ──────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + child: _MobileField( + controller: _passphraseCtrl, + label: 'Passphrase (optional)', + hint: 'Leave empty for none', + obscure: true, + ), + ), + + if (_error != null) ...[ + const SizedBox(height: MobileTokens.space2), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + child: Text(_error!, + style: mobileBody( + size: 13, color: MobileColors.red)), + ), + ], + + const SizedBox(height: MobileTokens.space4), + + // ── Generate button ───────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + child: FilledButton( + onPressed: _loading ? null : _generate, + style: FilledButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight( + MobileTokens.touchTarget), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + ), + ), + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.black), + ) + : Text('Generate', + style: mobileBody( + size: 16, + weight: FontWeight.w600, + color: Colors.black)), + ), + ), + const SizedBox(height: MobileTokens.space4), + ], + ), + ), + ); + } +} + +// ── Bottom action buttons ────────────────────────────────────────────────────── + +class _BottomActions extends StatelessWidget { + final VoidCallback onGenerate; + final VoidCallback onImport; + + const _BottomActions({ + required this.onGenerate, + required this.onImport, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + MobileTokens.space4, + ), + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: MobileColors.border), + ), + ), + child: Row( + children: [ + Expanded( + child: FilledButton( + onPressed: onGenerate, + style: FilledButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: Colors.black, + minimumSize: + const Size.fromHeight(MobileTokens.touchTarget), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + ), + ), + child: Text( + 'Generate', + style: mobileBody( + size: 15, weight: FontWeight.w600, color: Colors.black), + ), + ), + ), + const SizedBox(width: MobileTokens.space3), + Expanded( + child: OutlinedButton( + onPressed: onImport, + style: OutlinedButton.styleFrom( + side: const BorderSide(color: MobileColors.border), + foregroundColor: MobileColors.textPrimary, + minimumSize: + const Size.fromHeight(MobileTokens.touchTarget), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + ), + ), + child: Text( + 'Import', + style: mobileBody(size: 15, weight: FontWeight.w600), + ), + ), + ), + ], + ), + ); + } +} + +// ── Helper widgets ───────────────────────────────────────────────────────────── + +class _CircleButton extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + + const _CircleButton({required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: MobileColors.surface, + shape: BoxShape.circle, + border: Border.all(color: MobileColors.border), + ), + child: Icon(icon, color: MobileColors.textMuted, size: 18), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final VoidCallback onGenerate; + final VoidCallback onImport; + + const _EmptyState({required this.onGenerate, required this.onImport}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.vpn_key_outlined, + size: 48, color: MobileColors.textFaint), + const SizedBox(height: MobileTokens.space3), + Text('No keys yet', style: mobileHeading(size: 17)), + const SizedBox(height: MobileTokens.space1), + Text( + 'Generate a new key or import an existing one', + style: mobileBody(size: 13, color: MobileColors.textMuted), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _MobileField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String hint; + final bool obscure; + + const _MobileField({ + required this.controller, + required this.label, + required this.hint, + this.obscure = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: mobileBody(size: 13, color: MobileColors.textMuted)), + const SizedBox(height: MobileTokens.space1), + TextField( + controller: controller, + obscureText: obscure, + style: mobileBody(size: 15), + decoration: InputDecoration( + hintText: hint, + hintStyle: + mobileBody(size: 15, color: MobileColors.textFaint), + filled: true, + fillColor: const Color(0xFF1C1C1E), + contentPadding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space3, + vertical: MobileTokens.space3, + ), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusField), + borderSide: + const BorderSide(color: MobileColors.accent, width: 1.5), + ), + ), + ), + ], + ); + } +} + +class _AlgoOption { + final String value; + final String label; + final String description; + const _AlgoOption(this.value, this.label, this.description); +} + +class _AlgoChip extends StatelessWidget { + final _AlgoOption option; + final bool selected; + final VoidCallback onTap; + + const _AlgoChip({ + required this.option, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space3, + vertical: MobileTokens.space2, + ), + decoration: BoxDecoration( + color: selected ? MobileColors.accentSoft : MobileColors.surfaceAlt, + borderRadius: BorderRadius.circular(MobileTokens.radiusField), + border: Border.all( + color: selected ? MobileColors.accent : MobileColors.border, + width: selected ? 1.5 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option.label, + style: mobileBody( + size: 14, + weight: FontWeight.w600, + color: selected + ? MobileColors.accent + : MobileColors.textPrimary, + ), + ), + Text( + option.description, + style: + mobileBody(size: 11, color: MobileColors.textFaint), + ), + ], + ), + ), + ); + } +} + +Widget _sheetHandle() => Container( + margin: const EdgeInsets.only(top: MobileTokens.space3), + width: 40, + height: 4, + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), + ); diff --git a/app/lib/mobile/widgets/key_card.dart b/app/lib/mobile/widgets/key_card.dart new file mode 100644 index 00000000..36a341b0 --- /dev/null +++ b/app/lib/mobile/widgets/key_card.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +import '../../models/ssh_key.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import 'mobile_card.dart'; + +/// Termius-style SSH key row: +/// key-glyph tile | label + algo·hosts subtitle + fingerprint | chevron. +class KeyCard extends StatelessWidget { + final SshKeyEntry entry; + + /// Number of hosts that reference this key. + final int hostCount; + + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const KeyCard({ + super.key, + required this.entry, + required this.hostCount, + this.onTap, + this.onLongPress, + }); + + String get _subtitle { + final algo = switch (entry.algorithm) { + KeyAlgorithm.ed25519 => 'Ed25519', + KeyAlgorithm.rsa => 'RSA 4096', + KeyAlgorithm.ecdsa => 'ECDSA', + }; + if (hostCount == 0) return '$algo · unused'; + return '$algo · $hostCount ${hostCount == 1 ? 'host' : 'hosts'}'; + } + + String get _fingerprintLine { + final pk = entry.publicKey; + if (pk.isEmpty) return ''; + // publicKey is "type base64comment" — show first 40 chars of base64 portion + final parts = pk.split(' '); + if (parts.length >= 2) { + final b64 = parts[1]; + final trunc = b64.length > 28 ? '${b64.substring(0, 28)}…' : b64; + return '${parts[0]} $trunc'; + } + return pk.length > 40 ? '${pk.substring(0, 40)}…' : pk; + } + + @override + Widget build(BuildContext context) { + final fp = _fingerprintLine; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space3, + vertical: MobileTokens.space1 + 2, + ), + child: MobileCard( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + children: [ + // ── Key glyph tile ──────────────────────────────────────────────── + Container( + width: MobileTokens.avatar, + height: MobileTokens.avatar, + decoration: BoxDecoration( + color: const Color(0xFF1E1A2E), // muted purple tint + borderRadius: + BorderRadius.circular(MobileTokens.radiusAvatar), + ), + child: const Icon( + Icons.vpn_key_rounded, + color: Color(0xFFB99EFF), // soft lavender + size: 22, + ), + ), + + const SizedBox(width: MobileTokens.space3), + + // ── Text column ─────────────────────────────────────────────────── + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mobileBody(size: 15.5, weight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + _subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + mobileBody(size: 12.5, color: MobileColors.textMuted), + ), + if (fp.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + fp, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mobileMono( + size: 11, color: MobileColors.textFaint), + ), + ], + ], + ), + ), + + const SizedBox(width: MobileTokens.space2), + + // ── Chevron ─────────────────────────────────────────────────────── + const Icon( + Icons.chevron_right, + color: MobileColors.textFaint, + size: 20, + ), + ], + ), + ), + ); + } +} diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index 5e656211..96d2b3c2 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -6,8 +6,10 @@ import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/providers/known_hosts_provider.dart'; import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/key_gen_service.dart'; import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/services/tab_metadata_service.dart'; @@ -20,10 +22,16 @@ Widget _wrap(Widget child) => MultiProvider( ChangeNotifierProvider( create: (_) => HostProvider(StorageService()), ), + ChangeNotifierProvider( + create: (_) => KeyProvider(), + ), ChangeNotifierProvider( create: (_) => SessionProvider(SshService(StorageService()), TabMetadataService()), ), + Provider( + create: (_) => KeyGenService(), + ), ], child: MaterialApp( theme: buildMobileTheme(), diff --git a/app/test/mobile/screens/keys_screen_test.dart b/app/test/mobile/screens/keys_screen_test.dart new file mode 100644 index 00000000..ddcb9249 --- /dev/null +++ b/app/test/mobile/screens/keys_screen_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:yourssh/mobile/screens/mobile_keys_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/ssh_key.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/services/key_gen_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +// ── Fake KeyProvider seeded with 2 keys ────────────────────────────────────── + +class _FakeKeyProvider extends KeyProvider { + final List _seed; + _FakeKeyProvider(this._seed); + + @override + List get keys => _seed; +} + +// ── Fake KeyGenService — ssh-keygen present ─────────────────────────────────── + +class _FakeKeyGen extends KeyGenService { + @override + Future probeSshKeygen() async => true; +} + +// ── Pump helper ─────────────────────────────────────────────────────────────── + +Future _pump( + WidgetTester tester, { + List? keys, + List? hosts, + KeyGenService? keyGen, +}) async { + SharedPreferences.setMockInitialValues({}); + + final seedKeys = keys ?? + [ + SshKeyEntry( + label: 'id_ed25519', + algorithm: KeyAlgorithm.ed25519, + publicKey: 'SHA256:abcdefABCDEF1234567890==', + privateKeyPath: '/home/user/.ssh/id_ed25519', + ), + SshKeyEntry( + label: 'id_rsa', + algorithm: KeyAlgorithm.rsa, + publicKey: 'SHA256:rstuvwRSTUVW0987654321==', + privateKeyPath: '/home/user/.ssh/id_rsa', + ), + ]; + + final hostProv = HostProvider(StorageService()); + if (hosts != null) { + for (final h in hosts) { + await hostProv.addHost(h); + } + } + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: _FakeKeyProvider(seedKeys), + ), + ChangeNotifierProvider.value(value: hostProv), + Provider.value(value: keyGen ?? _FakeKeyGen()), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: const MobileKeysScreen(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows title "Keys"', (tester) async { + await _pump(tester); + expect(find.text('Keys'), findsAtLeastNWidgets(1)); + }); + + testWidgets('shows subtitle with key count', (tester) async { + await _pump(tester); + // "2 keys · ..." is somewhere on screen + expect(find.textContaining('2 keys'), findsOneWidget); + }); + + testWidgets('renders both key labels', (tester) async { + await _pump(tester); + expect(find.text('id_ed25519'), findsOneWidget); + expect(find.text('id_rsa'), findsOneWidget); + }); + + testWidgets('shows SHA256 fingerprint fragment', (tester) async { + await _pump(tester); + expect(find.textContaining('SHA256:'), findsAtLeastNWidgets(1)); + }); + + testWidgets('Generate button is present', (tester) async { + await _pump(tester); + expect(find.text('Generate'), findsOneWidget); + }); + + testWidgets('Import button is present', (tester) async { + await _pump(tester); + expect(find.text('Import'), findsOneWidget); + }); + + testWidgets('shows algorithm subtitle for ed25519 key', (tester) async { + await _pump(tester); + // "Ed25519 · unused" or "Ed25519 · N hosts" + expect(find.textContaining('Ed25519'), findsAtLeastNWidgets(1)); + }); + + testWidgets('in-use count reflects host linkage', (tester) async { + final key = SshKeyEntry( + label: 'prod_key', + algorithm: KeyAlgorithm.ed25519, + publicKey: 'SHA256:prod==', + privateKeyPath: '/home/user/.ssh/prod_key', + ); + final linkedHost = Host( + label: 'prod-01', + host: '10.0.0.1', + username: 'root', + keyId: key.id, + authType: AuthType.privateKey, + ); + + await _pump(tester, keys: [key], hosts: [linkedHost]); + // subtitle "1 keys · 1 in use" + expect(find.textContaining('1 in use'), findsOneWidget); + }); +} From ef24fe7b769eb8989b6ddbae205b37dc62cbcf83 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 21:57:06 +0700 Subject: [PATCH 70/96] fix(mobile): prompt passphrase on key import, real SHA256 fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _importKey now shows _ImportPassphraseSheet after file pick; calls savePassphrase(entry.id, passphrase) when non-empty, with context.mounted guards across every await. - New app/lib/mobile/util/ssh_fingerprint.dart: sha256Fingerprint() parses the OpenSSH public key line, SHA256-hashes the raw blob via the crypto package, and returns the canonical 'SHA256:' form (no '=' padding); returns null for unparseable input. - KeyCard._fingerprintLine replaced truncated-base64 display with the real fingerprint; falls back to 'SHA256:(pending)' for null. - New test/mobile/util/ssh_fingerprint_test.dart: 7 cases covering the known vector, no-comment variant, empty/single-token/invalid-base64 inputs, and extra-whitespace handling. - keys_screen_test.dart: seed publicKey values updated to realistic OpenSSH key lines; comment typo '1 keys' → '1 key' fixed. --- .../mobile/screens/mobile_keys_screen.dart | 155 +++++++++++++++++- app/lib/mobile/util/ssh_fingerprint.dart | 39 +++++ app/lib/mobile/widgets/key_card.dart | 10 +- app/test/mobile/screens/keys_screen_test.dart | 13 +- .../mobile/util/ssh_fingerprint_test.dart | 43 +++++ 5 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 app/lib/mobile/util/ssh_fingerprint.dart create mode 100644 app/test/mobile/util/ssh_fingerprint_test.dart diff --git a/app/lib/mobile/screens/mobile_keys_screen.dart b/app/lib/mobile/screens/mobile_keys_screen.dart index d810d6c1..ed864347 100644 --- a/app/lib/mobile/screens/mobile_keys_screen.dart +++ b/app/lib/mobile/screens/mobile_keys_screen.dart @@ -124,9 +124,34 @@ class MobileKeysScreen extends StatelessWidget { final path = result.files.first.path; if (path == null) return; if (!context.mounted) return; - await context - .read() - .addKeyFromFile(path, result.files.first.name); + + // Prompt for an optional passphrase before registering the key. + final passphrase = await _showPassphraseSheet(context, result.files.first.name); + if (passphrase == null) return; // user cancelled + + if (!context.mounted) return; + final keyProv = context.read(); + final entry = await keyProv.addKeyFromFile(path, result.files.first.name); + + if (passphrase.isNotEmpty && context.mounted) { + await keyProv.savePassphrase?.call(entry.id, passphrase); + } + } + + /// Shows a bottom sheet asking for an optional passphrase. + /// Returns the passphrase string (may be empty) on Import, or `null` if + /// the user cancelled. + Future _showPassphraseSheet( + BuildContext context, String filename) async { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: MobileColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => _ImportPassphraseSheet(filename: filename), + ); } void _showGenerateDialog(BuildContext context) { @@ -674,3 +699,127 @@ Widget _sheetHandle() => Container( borderRadius: BorderRadius.circular(2), ), ); + +// ── Import passphrase sheet ─────────────────────────────────────────────────── + +/// Bottom sheet that asks for an optional passphrase before importing a key. +/// [Navigator.pop]s with the passphrase string (may be empty) on Import, or +/// `null` if the user cancels. +class _ImportPassphraseSheet extends StatefulWidget { + final String filename; + const _ImportPassphraseSheet({required this.filename}); + + @override + State<_ImportPassphraseSheet> createState() => _ImportPassphraseSheetState(); +} + +class _ImportPassphraseSheetState extends State<_ImportPassphraseSheet> { + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _sheetHandle(), + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + 0, + ), + child: Text('Import Key', style: mobileHeading(size: 18)), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space1, + MobileTokens.space4, + 0, + ), + child: Text( + widget.filename, + style: mobileBody(size: 13, color: MobileColors.textMuted), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: MobileTokens.space3), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + child: _MobileField( + controller: _ctrl, + label: 'Passphrase (optional)', + hint: 'Leave empty if the key is unencrypted', + obscure: true, + ), + ), + const SizedBox(height: MobileTokens.space4), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context, null), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: MobileColors.border), + foregroundColor: MobileColors.textPrimary, + minimumSize: const Size.fromHeight( + MobileTokens.touchTarget), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + MobileTokens.radiusField), + ), + ), + child: Text('Cancel', + style: mobileBody( + size: 15, weight: FontWeight.w600)), + ), + ), + const SizedBox(width: MobileTokens.space3), + Expanded( + child: FilledButton( + onPressed: () => Navigator.pop(context, _ctrl.text), + style: FilledButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight( + MobileTokens.touchTarget), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + MobileTokens.radiusField), + ), + ), + child: Text('Import', + style: mobileBody( + size: 15, + weight: FontWeight.w600, + color: Colors.black)), + ), + ), + ], + ), + ), + const SizedBox(height: MobileTokens.space4), + ], + ), + ), + ); + } +} diff --git a/app/lib/mobile/util/ssh_fingerprint.dart b/app/lib/mobile/util/ssh_fingerprint.dart new file mode 100644 index 00000000..c13a0d2a --- /dev/null +++ b/app/lib/mobile/util/ssh_fingerprint.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Returns the OpenSSH SHA256 fingerprint for a public key line, e.g.: +/// `SHA256:AbCdEf...` +/// +/// [openSshPublicKey] should be in the form ` [comment]`. +/// Returns `null` if the input cannot be parsed as a valid OpenSSH public key. +String? sha256Fingerprint(String openSshPublicKey) { + final trimmed = openSshPublicKey.trim(); + if (trimmed.isEmpty) return null; + + final parts = trimmed.split(RegExp(r'\s+')); + if (parts.length < 2) return null; + + final blob = parts[1]; + final Uint8List bytes; + try { + bytes = base64.decode(_normalizeBase64(blob)); + } catch (_) { + return null; + } + + if (bytes.isEmpty) return null; + + final digest = sha256.convert(bytes); + // OpenSSH omits '=' padding in SHA256: display + final b64 = base64.encode(digest.bytes).replaceAll('=', ''); + return 'SHA256:$b64'; +} + +/// Adds padding so [base64.decode] accepts blobs that may lack `=` padding. +String _normalizeBase64(String s) { + final rem = s.length % 4; + if (rem == 0) return s; + return s + '=' * (4 - rem); +} diff --git a/app/lib/mobile/widgets/key_card.dart b/app/lib/mobile/widgets/key_card.dart index 36a341b0..ca191164 100644 --- a/app/lib/mobile/widgets/key_card.dart +++ b/app/lib/mobile/widgets/key_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../models/ssh_key.dart'; import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; +import '../util/ssh_fingerprint.dart'; import 'mobile_card.dart'; /// Termius-style SSH key row: @@ -37,14 +38,7 @@ class KeyCard extends StatelessWidget { String get _fingerprintLine { final pk = entry.publicKey; if (pk.isEmpty) return ''; - // publicKey is "type base64comment" — show first 40 chars of base64 portion - final parts = pk.split(' '); - if (parts.length >= 2) { - final b64 = parts[1]; - final trunc = b64.length > 28 ? '${b64.substring(0, 28)}…' : b64; - return '${parts[0]} $trunc'; - } - return pk.length > 40 ? '${pk.substring(0, 40)}…' : pk; + return sha256Fingerprint(pk) ?? 'SHA256:(pending)'; } @override diff --git a/app/test/mobile/screens/keys_screen_test.dart b/app/test/mobile/screens/keys_screen_test.dart index ddcb9249..25f5551a 100644 --- a/app/test/mobile/screens/keys_screen_test.dart +++ b/app/test/mobile/screens/keys_screen_test.dart @@ -39,18 +39,22 @@ Future _pump( }) async { SharedPreferences.setMockInitialValues({}); + // Realistic OpenSSH public key lines — the card now computes a real SHA256 + // fingerprint from the base64 blob rather than displaying a literal. final seedKeys = keys ?? [ SshKeyEntry( label: 'id_ed25519', algorithm: KeyAlgorithm.ed25519, - publicKey: 'SHA256:abcdefABCDEF1234567890==', + publicKey: + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPM4vCvRPxj8W9BoYRLsNJNMW5JCZw0DhS4OxGmwsUAZ user@host', privateKeyPath: '/home/user/.ssh/id_ed25519', ), SshKeyEntry( label: 'id_rsa', algorithm: KeyAlgorithm.rsa, - publicKey: 'SHA256:rstuvwRSTUVW0987654321==', + publicKey: + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2 user@host', privateKeyPath: '/home/user/.ssh/id_rsa', ), ]; @@ -127,7 +131,8 @@ void main() { final key = SshKeyEntry( label: 'prod_key', algorithm: KeyAlgorithm.ed25519, - publicKey: 'SHA256:prod==', + publicKey: + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPM4vCvRPxj8W9BoYRLsNJNMW5JCZw0DhS4OxGmwsUAZ prod@host', privateKeyPath: '/home/user/.ssh/prod_key', ); final linkedHost = Host( @@ -139,7 +144,7 @@ void main() { ); await _pump(tester, keys: [key], hosts: [linkedHost]); - // subtitle "1 keys · 1 in use" + // subtitle "1 key · 1 in use" expect(find.textContaining('1 in use'), findsOneWidget); }); } diff --git a/app/test/mobile/util/ssh_fingerprint_test.dart b/app/test/mobile/util/ssh_fingerprint_test.dart new file mode 100644 index 00000000..b845ca67 --- /dev/null +++ b/app/test/mobile/util/ssh_fingerprint_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:yourssh/mobile/util/ssh_fingerprint.dart'; + +void main() { + // Known vector: computed once via Python's hashlib.sha256 over the raw + // base64-decoded blob, then base64-encoded without '=' padding. + const knownPubKey = + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPM4vCvRPxj8W9BoYRLsNJNMW5JCZw0DhS4OxGmwsUAZ test@example'; + const knownFingerprint = 'SHA256:HMCzWOXfDZ/SJgzrmmqFS5EObmWd6/IYky/Ck6FBoYU'; + + test('returns correct SHA256 fingerprint for known key', () { + expect(sha256Fingerprint(knownPubKey), equals(knownFingerprint)); + }); + + test('works without comment field', () { + const noComment = + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPM4vCvRPxj8W9BoYRLsNJNMW5JCZw0DhS4OxGmwsUAZ'; + expect(sha256Fingerprint(noComment), equals(knownFingerprint)); + }); + + test('starts with SHA256:', () { + expect(sha256Fingerprint(knownPubKey), startsWith('SHA256:')); + }); + + test('returns null for empty string', () { + expect(sha256Fingerprint(''), isNull); + }); + + test('returns null for single token (no blob)', () { + expect(sha256Fingerprint('ssh-ed25519'), isNull); + }); + + test('returns null for invalid base64 blob', () { + expect(sha256Fingerprint('ssh-ed25519 NOT!!VALID!!BASE64'), isNull); + }); + + test('handles key with extra whitespace gracefully', () { + final padded = + ' ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPM4vCvRPxj8W9BoYRLsNJNMW5JCZw0DhS4OxGmwsUAZ '; + expect(sha256Fingerprint(padded), equals(knownFingerprint)); + }); +} From adb1a3f622e6c04be0286f8454d22abe6d660daf Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:03:21 +0700 Subject: [PATCH 71/96] feat(mobile): snippets screen with tap-to-run --- app/lib/mobile/screens/mobile_home_shell.dart | 3 +- .../screens/mobile_snippets_screen.dart | 163 ++++++++++++ app/lib/mobile/widgets/snippet_card.dart | 60 +++++ .../mobile/screens/snippets_screen_test.dart | 234 ++++++++++++++++++ 4 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 app/lib/mobile/screens/mobile_snippets_screen.dart create mode 100644 app/lib/mobile/widgets/snippet_card.dart create mode 100644 app/test/mobile/screens/snippets_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index e1a7fee4..616f4340 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -6,6 +6,7 @@ import '../services/host_reachability_probe.dart'; import '../widgets/mobile_tab_bar.dart'; import 'mobile_hosts_screen.dart'; import 'mobile_keys_screen.dart'; +import 'mobile_snippets_screen.dart'; /// Bottom-navigation shell for the Android app. /// Four tabs: Hosts · Snippets · Keys · Settings. @@ -30,7 +31,7 @@ class _MobileHomeShellState extends State { index: MobileTab.values.indexOf(_current), children: const [ MobileHostsScreen(), - Center(child: Text('Snippets')), + MobileSnippetsScreen(), MobileKeysScreen(), Center(child: Text('Settings')), ], diff --git a/app/lib/mobile/screens/mobile_snippets_screen.dart b/app/lib/mobile/screens/mobile_snippets_screen.dart new file mode 100644 index 00000000..a8286819 --- /dev/null +++ b/app/lib/mobile/screens/mobile_snippets_screen.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; + +import '../../providers/session_provider.dart'; +import '../../services/ssh_service.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/snippet_card.dart'; +import '../widgets/tag_chip.dart'; + +/// Snippets tab — lists saved commands with a category filter. +/// Tapping a card sends the command into the active SSH session. +class MobileSnippetsScreen extends StatefulWidget { + const MobileSnippetsScreen({super.key}); + + @override + State createState() => _MobileSnippetsScreenState(); +} + +class _MobileSnippetsScreenState extends State { + // null means "All" + String? _selectedTag; + + @override + Widget build(BuildContext context) { + final snippetProv = context.watch(); + final sessionProv = context.watch(); + final activeSession = sessionProv.activeSshSession; + + final allSnippets = snippetProv.snippets; + + // Collect distinct non-empty tags in insertion order. + final tags = []; + for (final s in allSnippets) { + if (s.tag.isNotEmpty && !tags.contains(s.tag)) tags.add(s.tag); + } + + final shown = _selectedTag == null + ? allSnippets + : allSnippets.where((s) => s.tag == _selectedTag).toList(); + + final subtitle = activeSession != null + ? 'Tap to run on ${activeSession.host.label}' + : 'No active session'; + + return Scaffold( + backgroundColor: MobileColors.bg, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space2, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Snippets', + style: mobileHeading(size: 30, weight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: mobileBody(size: 13, color: MobileColors.textMuted), + ), + ], + ), + ), + + // ── Category filter chips ──────────────────────────────────────── + if (tags.isNotEmpty) + SizedBox( + height: 36, + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + scrollDirection: Axis.horizontal, + children: [ + TagChip( + label: 'All', + selected: _selectedTag == null, + onTap: () => setState(() => _selectedTag = null), + ), + for (final tag in tags) ...[ + const SizedBox(width: MobileTokens.space2), + TagChip( + label: tag, + selected: _selectedTag == tag, + onTap: () => setState(() => _selectedTag = tag), + ), + ], + ], + ), + ), + + const SizedBox(height: MobileTokens.space3), + + // ── Snippet list ───────────────────────────────────────────────── + Expanded( + child: shown.isEmpty + ? _EmptyState(hasFilter: _selectedTag != null) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + itemCount: shown.length, + itemBuilder: (_, i) { + final snippet = shown[i]; + return SnippetCard( + key: ValueKey(snippet.id), + snippet: snippet, + onTap: () => _run(context, snippet, activeSession?.id), + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _run(BuildContext context, Snippet snippet, String? sessionId) { + if (sessionId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No active session')), + ); + return; + } + context.read().sendInput(sessionId, '${snippet.command}\n'); + } +} + +// ── Empty state ─────────────────────────────────────────────────────────────── + +class _EmptyState extends StatelessWidget { + final bool hasFilter; + const _EmptyState({required this.hasFilter}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.code_off_outlined, + size: 48, color: MobileColors.textFaint), + const SizedBox(height: MobileTokens.space3), + Text( + hasFilter ? 'No snippets in this category' : 'No snippets yet', + style: mobileHeading(size: 17), + ), + ], + ), + ); + } +} diff --git a/app/lib/mobile/widgets/snippet_card.dart b/app/lib/mobile/widgets/snippet_card.dart new file mode 100644 index 00000000..d4c8c164 --- /dev/null +++ b/app/lib/mobile/widgets/snippet_card.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; + +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import 'mobile_card.dart'; +import 'tag_chip.dart'; + +/// Card for a single [Snippet]: title + category tag + monospace command. +/// Tapping fires [onTap]. +class SnippetCard extends StatelessWidget { + final Snippet snippet; + final VoidCallback onTap; + + const SnippetCard({ + super.key, + required this.snippet, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: MobileTokens.space2), + child: MobileCard( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Title row + tag ─────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Text( + snippet.label, + style: mobileBody(size: 15, weight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (snippet.tag.isNotEmpty) ...[ + const SizedBox(width: MobileTokens.space2), + TagChip(label: snippet.tag), + ], + ], + ), + const SizedBox(height: MobileTokens.space1), + // ── Command (mono, faint) ───────────────────────────────────── + Text( + snippet.command, + style: mobileMono(size: 12, color: MobileColors.textFaint), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/app/test/mobile/screens/snippets_screen_test.dart b/app/test/mobile/screens/snippets_screen_test.dart new file mode 100644 index 00000000..8b884e6d --- /dev/null +++ b/app/test/mobile/screens/snippets_screen_test.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; + +import 'package:yourssh/mobile/screens/mobile_snippets_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/ssh_session.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +// ── Fake SnippetProvider ────────────────────────────────────────────────────── + +class _FakeSnippetProvider extends SnippetProvider { + final List _seed; + _FakeSnippetProvider(this._seed); + + @override + List get snippets => _seed; +} + +// ── Fake SshService — tracks sendInput calls ────────────────────────────────── + +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + final List<({String sessionId, String text})> inputCalls = []; + + @override + bool sendInput(String sessionId, String text) { + inputCalls.add((sessionId: sessionId, text: text)); + return true; + } +} + +// ── Fake SessionProvider — one active SSH session ───────────────────────────── + +class _FakeSessionProvider extends SessionProvider { + final SshSession _session; + + _FakeSessionProvider(this._session) + : super(SshService(StorageService()), TabMetadataService()); + + @override + SshSession? get activeSshSession => _session; +} + +// ── Pump helper ──────────────────────────────────────────────────────────────── + +Future _pump( + WidgetTester tester, { + required SnippetProvider snippetProvider, + required SessionProvider sessionProvider, + required SshService sshService, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: snippetProvider), + ChangeNotifierProvider.value(value: sessionProvider), + Provider.value(value: sshService), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: const MobileSnippetsScreen(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + late _FakeSshService fakeSsh; + late _FakeSnippetProvider fakeSnippets; + late _FakeSessionProvider fakeSession; + late SshSession activeSession; + + setUp(() { + fakeSsh = _FakeSshService(); + fakeSnippets = _FakeSnippetProvider([ + Snippet(label: 'Disk usage', command: 'df -h', tag: 'system'), + Snippet(label: 'Tail log', command: 'tail -f /var/log/syslog', tag: 'logs'), + ]); + activeSession = SshSession( + host: Host(label: 'web-01', host: '10.0.0.1', username: 'root'), + ); + fakeSession = _FakeSessionProvider(activeSession); + }); + + testWidgets('shows "Snippets" title', (tester) async { + await _pump(tester, + snippetProvider: fakeSnippets, + sessionProvider: fakeSession, + sshService: fakeSsh); + expect(find.text('Snippets'), findsOneWidget); + }); + + testWidgets('shows active host name in subtitle', (tester) async { + await _pump(tester, + snippetProvider: fakeSnippets, + sessionProvider: fakeSession, + sshService: fakeSsh); + expect(find.textContaining('web-01'), findsOneWidget); + }); + + testWidgets('shows "No active session" subtitle when no session', (tester) async { + final noSession = _FakeSessionProvider( + SshSession( + host: Host(label: 'dummy', host: '0.0.0.0', username: 'u'), + ), + ); + // Override to return null active session + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: fakeSnippets), + ChangeNotifierProvider.value( + value: _NullSessionProvider(), + ), + Provider.value(value: fakeSsh), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: const MobileSnippetsScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('No active session'), findsOneWidget); + }); + + testWidgets('renders both snippet labels', (tester) async { + await _pump(tester, + snippetProvider: fakeSnippets, + sessionProvider: fakeSession, + sshService: fakeSsh); + expect(find.text('Disk usage'), findsOneWidget); + expect(find.text('Tail log'), findsOneWidget); + }); + + testWidgets('renders snippet commands in monospace', (tester) async { + await _pump(tester, + snippetProvider: fakeSnippets, + sessionProvider: fakeSession, + sshService: fakeSsh); + expect(find.text('df -h'), findsOneWidget); + }); + + testWidgets('renders category filter chips (All + distinct tags)', (tester) async { + await _pump(tester, + snippetProvider: fakeSnippets, + sessionProvider: fakeSession, + sshService: fakeSsh); + // "All" chip only appears in the filter bar + expect(find.text('All'), findsOneWidget); + // Tags appear in both filter chips and snippet cards (at least once each) + expect(find.text('system'), findsAtLeastNWidgets(1)); + expect(find.text('logs'), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping a snippet calls sendInput with command + newline', + (tester) async { + await _pump(tester, + snippetProvider: fakeSnippets, + sessionProvider: fakeSession, + sshService: fakeSsh); + + await tester.tap(find.text('Disk usage')); + await tester.pumpAndSettle(); + + expect(fakeSsh.inputCalls, hasLength(1)); + expect(fakeSsh.inputCalls.first.sessionId, equals(activeSession.id)); + expect(fakeSsh.inputCalls.first.text, equals('df -h\n')); + }); + + testWidgets('tapping snippet with no active session shows snackbar', + (tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: fakeSnippets), + ChangeNotifierProvider.value( + value: _NullSessionProvider(), + ), + Provider.value(value: fakeSsh), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: const MobileSnippetsScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Disk usage')); + await tester.pumpAndSettle(); + + expect(find.text('No active session'), findsAtLeastNWidgets(1)); + expect(fakeSsh.inputCalls, isEmpty); + }); + + testWidgets('category chip filters snippets', (tester) async { + await _pump(tester, + snippetProvider: fakeSnippets, + sessionProvider: fakeSession, + sshService: fakeSsh); + + // Tap the "logs" filter chip (first occurrence is the chip in the filter bar). + // The "logs" tag also appears in the snippet card, so we target the chip row. + await tester.tap(find.text('logs').first); + await tester.pumpAndSettle(); + + expect(find.text('Tail log'), findsOneWidget); + expect(find.text('Disk usage'), findsNothing); + }); +} + +// ── Helper: SessionProvider with no active SSH session ─────────────────────── + +class _NullSessionProvider extends SessionProvider { + _NullSessionProvider() + : super(SshService(StorageService()), TabMetadataService()); + + @override + SshSession? get activeSshSession => null; +} From a250befe42d1ed8db04c13b1d8077f7872b946a8 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:09:54 +0700 Subject: [PATCH 72/96] feat(mobile): grouped settings screen Rewrites MobileSettingsScreen with ListGroup/SettingsRow primitives: TERMINAL (TerminalAppearanceControls embedded), SECURITY (biometric toggle on kAppLockPrefKey, auto-lock chevron), KEYBOARD & SYNC (shortcut key bar toggle, Supabase sync sheet, P2P QR pair). Sync banner card reflects isSupabaseConfigured. Footer shows version via PackageInfo. Preserves full Supabase config + P2P QR import logic via bottom sheet. Wires Settings tab in MobileHomeShell. --- app/lib/mobile/screens/mobile_home_shell.dart | 3 +- .../screens/mobile_settings_screen.dart | 425 +++++++++++++----- .../mobile/screens/settings_screen_test.dart | 66 +++ 3 files changed, 384 insertions(+), 110 deletions(-) create mode 100644 app/test/mobile/screens/settings_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart index 616f4340..5eed0ae7 100644 --- a/app/lib/mobile/screens/mobile_home_shell.dart +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -6,6 +6,7 @@ import '../services/host_reachability_probe.dart'; import '../widgets/mobile_tab_bar.dart'; import 'mobile_hosts_screen.dart'; import 'mobile_keys_screen.dart'; +import 'mobile_settings_screen.dart'; import 'mobile_snippets_screen.dart'; /// Bottom-navigation shell for the Android app. @@ -33,7 +34,7 @@ class _MobileHomeShellState extends State { MobileHostsScreen(), MobileSnippetsScreen(), MobileKeysScreen(), - Center(child: Text('Settings')), + MobileSettingsScreen(), ], ), ), diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index b81a9a33..4ccb5081 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -1,20 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../providers/host_provider.dart'; +import '../../providers/settings_provider.dart'; import '../../providers/sync_provider.dart'; import '../../services/sync_service.dart'; import '../../theme/app_theme.dart'; import '../../widgets/terminal_appearance_controls.dart'; import '../security/app_lock_gate.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; -import '../widgets/mobile_card.dart'; -import '../widgets/section_header.dart'; +import '../widgets/list_group.dart'; +import '../widgets/settings_row.dart'; import 'mobile_qr_scan_screen.dart'; -/// Settings tab. M3 ships the Sync section (cloud pull + P2P QR scan). -/// Appearance + app-lock arrive in M5. +/// Settings tab — grouped preferences and security. +/// Groups: TERMINAL · SECURITY · KEYBOARD & SYNC +/// Sync functionality (Supabase config + P2P QR import) is preserved via +/// inline bottom sheets (Task 16 will replace with MobileSyncScreen). class MobileSettingsScreen extends StatefulWidget { const MobileSettingsScreen({super.key}); @@ -23,22 +28,27 @@ class MobileSettingsScreen extends StatefulWidget { } class _MobileSettingsScreenState extends State { - final _url = TextEditingController(); + // ── Sync form state ─────────────────────────────────────────────────────── + final _url = TextEditingController(); final _anon = TextEditingController(); final _code = TextEditingController(); - // Once the user edits a field we stop syncing it from the provider, so an - // async config load that completes after the first build can't clobber typing. - bool _dirty = false; + bool _dirty = false; bool _pulling = false; + + // ── Security ────────────────────────────────────────────────────────────── bool _appLock = true; + // ── Version ─────────────────────────────────────────────────────────────── + String _version = ''; + @override void initState() { super.initState(); SharedPreferences.getInstance().then((p) { - if (mounted) { - setState(() => _appLock = p.getBool(kAppLockPrefKey) ?? true); - } + if (mounted) setState(() => _appLock = p.getBool(kAppLockPrefKey) ?? true); + }); + PackageInfo.fromPlatform().then((info) { + if (mounted) setState(() => _version = info.version); }); } @@ -50,116 +60,223 @@ class _MobileSettingsScreenState extends State { super.dispose(); } + // ── Build ───────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final sync = context.watch(); + + // Mirror the provider until the user edits a field. if (!_dirty) { - // Mirror the provider until the user edits a field. SyncProvider loads its - // values asynchronously, so this re-seeds once the load completes instead - // of latching empty on the first frame (which a Save would then persist). - if (_url.text != sync.supabaseUrl) _url.text = sync.supabaseUrl; + if (_url.text != sync.supabaseUrl) _url.text = sync.supabaseUrl; if (_anon.text != sync.supabaseAnonKey) _anon.text = sync.supabaseAnonKey; - if (_code.text != sync.syncCode) _code.text = sync.syncCode; + if (_code.text != sync.syncCode) _code.text = sync.syncCode; } return Scaffold( - backgroundColor: AppColors.bg, - appBar: - AppBar(backgroundColor: AppColors.bg, title: const Text('Settings')), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - const SectionHeader('Cloud sync'), - MobileCard( - padding: const EdgeInsets.all(MobileTokens.space3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Pull hosts from your desktop via Supabase.', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), - const SizedBox(height: MobileTokens.space3), - _field(_url, 'Supabase URL', 'sync-url'), - _field(_anon, 'Anon key', 'sync-anon'), - _field(_code, 'Sync code (12 chars)', 'sync-code'), - const SizedBox(height: MobileTokens.space2), - Row( - children: [ - FilledButton(onPressed: _save, child: const Text('Save')), - const SizedBox(width: 10), - OutlinedButton( - onPressed: _pulling ? null : _pull, - child: _pulling - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Pull from cloud'), - ), - ], + backgroundColor: MobileColors.bg, + appBar: AppBar( + backgroundColor: MobileColors.bg, + surfaceTintColor: Colors.transparent, + title: Text('Settings', style: mobileHeading()), + actions: [ + IconButton( + icon: const Icon(Icons.tune, color: MobileColors.textMuted, size: 22), + onPressed: null, // placeholder — Task 16 + ), + ], + ), + body: ListView( + padding: EdgeInsets.only( + top: MobileTokens.space4, + left: MobileTokens.space4, + right: MobileTokens.space4, + bottom: MobileTokens.space5, + ), + children: [ + _syncBanner(sync), + const SizedBox(height: MobileTokens.space5), + _terminalGroup(), + const SizedBox(height: MobileTokens.space5), + _securityGroup(), + const SizedBox(height: MobileTokens.space5), + _keyboardSyncGroup(sync), + const SizedBox(height: MobileTokens.space5), + _footer(), + ], + ), + ); + } + + // ── Sync banner ─────────────────────────────────────────────────────────── + + Widget _syncBanner(SyncProvider sync) { + final active = sync.isSupabaseConfigured; + return Container( + decoration: BoxDecoration( + color: MobileColors.surface, + borderRadius: BorderRadius.circular(MobileTokens.radiusCard), + border: Border.all(color: MobileColors.border), + ), + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + active ? 'Sync active' : 'Sync off', + style: mobileBody( + size: 14, + weight: FontWeight.w600, + color: active ? MobileColors.textPrimary : MobileColors.textMuted, ), - if (sync.error != null) ...[ - const SizedBox(height: 10), - Text(sync.error!, - style: const TextStyle(color: AppColors.red, fontSize: 12)), - ], - ], - ), + ), + const SizedBox(height: 2), + Text( + 'Supabase · end-to-end encrypted', + style: mobileBody(size: 12, color: MobileColors.textMuted), + ), + ], ), - const SizedBox(height: MobileTokens.space4), - const SectionHeader('P2P transfer'), - MobileCard( - padding: const EdgeInsets.all(MobileTokens.space3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Scan the QR code shown on your desktop (Settings → Sync → Show QR).', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), - const SizedBox(height: MobileTokens.space3), - FilledButton.icon( - icon: const Icon(Icons.qr_code_scanner), - label: const Text('Scan QR code'), - onPressed: _scan, - ), - ], - ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: active + ? MobileColors.accent.withAlpha(38) + : MobileColors.border, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), ), - const SizedBox(height: MobileTokens.space4), - const SectionHeader('Security'), - MobileCard( - padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2), - child: Material( - color: Colors.transparent, - child: SwitchListTile( - contentPadding: EdgeInsets.zero, - value: _appLock, - onChanged: (v) async { - setState(() => _appLock = v); - final p = await SharedPreferences.getInstance(); - await p.setBool(kAppLockPrefKey, v); - }, - title: const Text('Require biometrics to open', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: const Text('Applies on next app launch', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), - ), + child: Text( + active ? 'On' : 'Off', + style: mobileBody( + size: 12, + weight: FontWeight.w600, + color: active ? MobileColors.accent : MobileColors.textFaint, ), ), - const SizedBox(height: MobileTokens.space4), - const SectionHeader('Terminal appearance'), - MobileCard( - padding: const EdgeInsets.all(MobileTokens.space3), - child: const TerminalAppearanceControls( - layout: AppearanceControlsLayout.rows), - ), - ], + ), + ], + ), + ); + } + + // ── TERMINAL group ──────────────────────────────────────────────────────── + + Widget _terminalGroup() { + return ListGroup( + label: 'Terminal', + children: [ + Padding( + padding: const EdgeInsets.all(MobileTokens.space4), + child: const TerminalAppearanceControls( + layout: AppearanceControlsLayout.rows, + ), + ), + ], + ); + } + + // ── SECURITY group ──────────────────────────────────────────────────────── + + Widget _securityGroup() { + return ListGroup( + label: 'Security', + children: [ + SettingsRow( + leading: Icon(Icons.fingerprint, color: MobileColors.textMuted, size: 20), + title: 'Biometric unlock', + toggle: _appLock, + onToggle: (v) async { + setState(() => _appLock = v); + final p = await SharedPreferences.getInstance(); + await p.setBool(kAppLockPrefKey, v); + }, + ), + SettingsRow( + leading: Icon(Icons.lock_clock_outlined, color: MobileColors.textMuted, size: 20), + title: 'Auto-lock', + value: 'After 1 min', + onTap: () {}, // placeholder — future enhancement + ), + ], + ); + } + + // ── KEYBOARD & SYNC group ───────────────────────────────────────────────── + + Widget _keyboardSyncGroup(SyncProvider sync) { + return ListGroup( + label: 'Keyboard & Sync', + children: [ + SettingsRow( + leading: Icon(Icons.keyboard_outlined, color: MobileColors.textMuted, size: 20), + title: 'Shortcut key bar', + toggle: context.watch().shellIntegrationEnabled, + onToggle: (v) => context.read().save(shellIntegrationEnabled: v), + ), + SettingsRow( + leading: Icon(Icons.cloud_sync_outlined, color: MobileColors.textMuted, size: 20), + title: 'Supabase sync', + value: sync.isSupabaseConfigured ? 'Configured' : null, + onTap: () => _showSupabaseSyncSheet(sync), ), + SettingsRow( + leading: Icon(Icons.qr_code_scanner, color: MobileColors.textMuted, size: 20), + title: 'Pair new device', + onTap: _scanQr, + ), + ], + ); + } + + // ── Footer ──────────────────────────────────────────────────────────────── + + Widget _footer() { + return Center( + child: Text( + 'YourSSH · Version $_version', + style: mobileBody(size: 12, color: MobileColors.textFaint), ), ); } - Future _scan() async { + // ── Supabase sync sheet (preserved from original) ───────────────────────── + + void _showSupabaseSyncSheet(SyncProvider sync) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: MobileColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => _SupabaseSyncSheet( + urlController: _url, + anonController: _anon, + codeController: _code, + pulling: _pulling, + onDirty: () => _dirty = true, + onSave: () async { + await _save(); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + onPull: () async { + await _pull(); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + ), + ); + } + + // ── P2P QR scan (preserved from original) ──────────────────────────────── + + Future _scanQr() async { final result = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const MobileQrScanScreen()), ); @@ -169,6 +286,8 @@ class _MobileSettingsScreenState extends State { } } + // ── Save / pull helpers (unchanged logic) ──────────────────────────────── + Future _save() async { final sync = context.read(); await sync.setSupabaseConfig(_url.text, _anon.text); @@ -189,8 +308,8 @@ class _MobileSettingsScreenState extends State { .replaceAll(payload.hosts, payload.passwords); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text('Imported ${payload.hosts.length} hosts from cloud'))); + content: Text('Imported ${payload.hosts.length} hosts from cloud'), + )); } } else if (mounted) { final err = context.read().error; @@ -199,15 +318,103 @@ class _MobileSettingsScreenState extends State { } if (mounted) setState(() => _pulling = false); } +} + +// ── Supabase sync bottom sheet ──────────────────────────────────────────────── + +class _SupabaseSyncSheet extends StatelessWidget { + final TextEditingController urlController; + final TextEditingController anonController; + final TextEditingController codeController; + final bool pulling; + final VoidCallback onDirty; + final VoidCallback onSave; + final VoidCallback onPull; + + const _SupabaseSyncSheet({ + required this.urlController, + required this.anonController, + required this.codeController, + required this.pulling, + required this.onDirty, + required this.onSave, + required this.onPull, + }); + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + final viewInsets = MediaQuery.viewInsetsOf(context); + return Padding( + padding: EdgeInsets.only( + left: MobileTokens.space4, + right: MobileTokens.space4, + top: MobileTokens.space4, + bottom: MobileTokens.space4 + viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: MobileTokens.space4), + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text('Supabase Sync', style: mobileHeading()), + const SizedBox(height: MobileTokens.space2), + Text( + 'Pull hosts from your desktop via Supabase.', + style: mobileBody(size: 13, color: MobileColors.textMuted), + ), + const SizedBox(height: MobileTokens.space4), + _field(context, urlController, 'Supabase URL', 'sync-url'), + _field(context, anonController, 'Anon key', 'sync-anon'), + _field(context, codeController, 'Sync code (12 chars)', 'sync-code'), + const SizedBox(height: MobileTokens.space3), + Row( + children: [ + FilledButton(onPressed: onSave, child: const Text('Save')), + const SizedBox(width: 10), + OutlinedButton( + onPressed: pulling ? null : onPull, + child: pulling + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Pull from cloud'), + ), + ], + ), + if (sync.error != null) ...[ + const SizedBox(height: 10), + Text( + sync.error!, + style: mobileBody(size: 12, color: AppColors.red), + ), + ], + ], + ), + ); + } - Widget _field(TextEditingController c, String label, String key) { + Widget _field(BuildContext context, TextEditingController c, String label, String key) { return Padding( - padding: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.only(bottom: MobileTokens.space3), child: TextField( key: Key(key), controller: c, - onChanged: (_) => _dirty = true, - style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + onChanged: (_) => onDirty(), + style: const TextStyle(color: MobileColors.textPrimary, fontSize: 13), decoration: InputDecoration(labelText: label), ), ); diff --git a/app/test/mobile/screens/settings_screen_test.dart b/app/test/mobile/screens/settings_screen_test.dart new file mode 100644 index 00000000..78ea968b --- /dev/null +++ b/app/test/mobile/screens/settings_screen_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:yourssh/mobile/screens/mobile_settings_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/providers/settings_provider.dart'; +import 'package:yourssh/providers/sync_provider.dart'; + +Future _pump(WidgetTester tester) async { + final settings = SettingsProvider(); + final sync = SyncProvider(); + + await tester.pumpWidget( + MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: settings), + ChangeNotifierProvider.value(value: sync), + ], + child: const MobileSettingsScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + // Scroll to ensure all lazy list items are built. + await tester.drag(find.byType(ListView), const Offset(0, -3000)); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + PackageInfo.setMockInitialValues( + appName: 'YourSSH', + packageName: 'com.yourssh', + version: '1.2.3', + buildNumber: '42', + buildSignature: '', + installerStore: null, + ); + }); + + testWidgets('renders TERMINAL section header', (tester) async { + await _pump(tester); + expect(find.text('TERMINAL'), findsOneWidget); + }); + + testWidgets('renders SECURITY section header', (tester) async { + await _pump(tester); + expect(find.text('SECURITY'), findsOneWidget); + }); + + testWidgets('renders KEYBOARD & SYNC section header', (tester) async { + await _pump(tester); + expect(find.text('KEYBOARD & SYNC'), findsOneWidget); + }); + + testWidgets('renders version string in footer', (tester) async { + await _pump(tester); + expect(find.textContaining('1.2.3'), findsOneWidget); + }); +} From cf6e3c7beb4a83115dc72624322925c3bc4e490e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:16:52 +0700 Subject: [PATCH 73/96] fix(mobile): dedicated accessory-bar pref, fix sync-sheet spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce kAccessoryBarPrefKey ('mobile_accessory_bar_enabled') in app/lib/mobile/util/mobile_prefs.dart; wire the Shortcut key bar toggle to read/write this key via SharedPreferences instead of the desktop-only shellIntegrationEnabled flag - Fix sync-sheet pull spinner: wrap _SupabaseSyncSheet in StatefulBuilder so setSheetState drives the in-sheet spinner/disabled state during an in-flight pull (parent setState never reached the modal builder context) - Upgrade test pump: register SettingsProvider, HostProvider, SyncService so the screen never throws ProviderNotFoundException; add two pref-key persistence tests (biometric → kAppLockPrefKey, shortcut bar → kAccessoryBarPrefKey) --- .../screens/mobile_settings_screen.dart | 56 +++++++++++------- app/lib/mobile/util/mobile_prefs.dart | 6 ++ .../mobile/screens/settings_screen_test.dart | 58 ++++++++++++++++++- 3 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 app/lib/mobile/util/mobile_prefs.dart diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index 4ccb5081..349d825f 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -4,7 +4,6 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../providers/host_provider.dart'; -import '../../providers/settings_provider.dart'; import '../../providers/sync_provider.dart'; import '../../services/sync_service.dart'; import '../../theme/app_theme.dart'; @@ -12,6 +11,7 @@ import '../../widgets/terminal_appearance_controls.dart'; import '../security/app_lock_gate.dart'; import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; +import '../util/mobile_prefs.dart'; import '../widgets/list_group.dart'; import '../widgets/settings_row.dart'; import 'mobile_qr_scan_screen.dart'; @@ -38,6 +38,9 @@ class _MobileSettingsScreenState extends State { // ── Security ────────────────────────────────────────────────────────────── bool _appLock = true; + // ── Keyboard ────────────────────────────────────────────────────────────── + bool _accessoryBar = true; + // ── Version ─────────────────────────────────────────────────────────────── String _version = ''; @@ -45,7 +48,12 @@ class _MobileSettingsScreenState extends State { void initState() { super.initState(); SharedPreferences.getInstance().then((p) { - if (mounted) setState(() => _appLock = p.getBool(kAppLockPrefKey) ?? true); + if (mounted) { + setState(() { + _appLock = p.getBool(kAppLockPrefKey) ?? true; + _accessoryBar = p.getBool(kAccessoryBarPrefKey) ?? true; + }); + } }); PackageInfo.fromPlatform().then((info) { if (mounted) setState(() => _version = info.version); @@ -217,8 +225,12 @@ class _MobileSettingsScreenState extends State { SettingsRow( leading: Icon(Icons.keyboard_outlined, color: MobileColors.textMuted, size: 20), title: 'Shortcut key bar', - toggle: context.watch().shellIntegrationEnabled, - onToggle: (v) => context.read().save(shellIntegrationEnabled: v), + toggle: _accessoryBar, + onToggle: (v) async { + setState(() => _accessoryBar = v); + final p = await SharedPreferences.getInstance(); + await p.setBool(kAccessoryBarPrefKey, v); + }, ), SettingsRow( leading: Icon(Icons.cloud_sync_outlined, color: MobileColors.textMuted, size: 20), @@ -256,20 +268,26 @@ class _MobileSettingsScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (ctx) => _SupabaseSyncSheet( - urlController: _url, - anonController: _anon, - codeController: _code, - pulling: _pulling, - onDirty: () => _dirty = true, - onSave: () async { - await _save(); - if (ctx.mounted) Navigator.of(ctx).pop(); - }, - onPull: () async { - await _pull(); - if (ctx.mounted) Navigator.of(ctx).pop(); - }, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) => _SupabaseSyncSheet( + urlController: _url, + anonController: _anon, + codeController: _code, + pulling: _pulling, + onDirty: () => _dirty = true, + onSave: () async { + await _save(); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + onPull: () async { + setSheetState(() => _pulling = true); + await _pull(); + if (ctx.mounted) { + setSheetState(() => _pulling = false); + Navigator.of(ctx).pop(); + } + }, + ), ), ); } @@ -299,7 +317,6 @@ class _MobileSettingsScreenState extends State { } Future _pull() async { - setState(() => _pulling = true); final payload = await context.read().pull(); if (!mounted) return; if (payload != null) { @@ -316,7 +333,6 @@ class _MobileSettingsScreenState extends State { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(err ?? 'Nothing new to pull'))); } - if (mounted) setState(() => _pulling = false); } } diff --git a/app/lib/mobile/util/mobile_prefs.dart b/app/lib/mobile/util/mobile_prefs.dart new file mode 100644 index 00000000..c99278bb --- /dev/null +++ b/app/lib/mobile/util/mobile_prefs.dart @@ -0,0 +1,6 @@ +// Shared-preferences keys for mobile-only settings. +// Import this file wherever a mobile screen needs to read/write these prefs. + +/// Whether the terminal accessory key bar is shown (default: true). +/// Read by the terminal accessory bar widget; persisted here. +const String kAccessoryBarPrefKey = 'mobile_accessory_bar_enabled'; diff --git a/app/test/mobile/screens/settings_screen_test.dart b/app/test/mobile/screens/settings_screen_test.dart index 78ea968b..fbb18f54 100644 --- a/app/test/mobile/screens/settings_screen_test.dart +++ b/app/test/mobile/screens/settings_screen_test.dart @@ -5,13 +5,21 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_settings_screen.dart'; +import 'package:yourssh/mobile/security/app_lock_gate.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/mobile/util/mobile_prefs.dart'; +import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/settings_provider.dart'; import 'package:yourssh/providers/sync_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/sync_service.dart'; Future _pump(WidgetTester tester) async { final settings = SettingsProvider(); - final sync = SyncProvider(); + final sync = SyncProvider(); + final storage = StorageService(); + final hosts = HostProvider(storage); + final syncSvc = SyncService(sync); await tester.pumpWidget( MaterialApp( @@ -20,6 +28,8 @@ Future _pump(WidgetTester tester) async { providers: [ ChangeNotifierProvider.value(value: settings), ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider.value(value: hosts), + Provider.value(value: syncSvc), ], child: const MobileSettingsScreen(), ), @@ -63,4 +73,50 @@ void main() { await _pump(tester); expect(find.textContaining('1.2.3'), findsOneWidget); }); + + testWidgets('biometric toggle persists under kAppLockPrefKey', (tester) async { + await _pump(tester); + + // SettingsRow renders a Row (not ListTile); find the Switch that sits in + // the same Row ancestor as the "Biometric unlock" text. + final biometricRow = find.ancestor( + of: find.text('Biometric unlock'), + matching: find.byType(Row), + ).last; // innermost Row wrapping the row content + final biometricSwitch = find.descendant( + of: biometricRow, + matching: find.byType(Switch), + ); + expect(biometricSwitch, findsOneWidget); + + final initialValue = tester.widget(biometricSwitch).value; + await tester.tap(biometricSwitch); + await tester.pumpAndSettle(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool(kAppLockPrefKey), !initialValue); + }); + + testWidgets('shortcut-bar toggle persists under kAccessoryBarPrefKey', (tester) async { + await _pump(tester); + + // SettingsRow renders a Row (not ListTile); find the Switch that sits in + // the same Row ancestor as the "Shortcut key bar" text. + final shortcutRow = find.ancestor( + of: find.text('Shortcut key bar'), + matching: find.byType(Row), + ).last; + final shortcutSwitch = find.descendant( + of: shortcutRow, + matching: find.byType(Switch), + ); + expect(shortcutSwitch, findsOneWidget); + + final initialValue = tester.widget(shortcutSwitch).value; + await tester.tap(shortcutSwitch); + await tester.pumpAndSettle(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool(kAccessoryBarPrefKey), !initialValue); + }); } From e72c35f2a955fbdd397ca3b777358fbb36a8659e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:25:34 +0700 Subject: [PATCH 74/96] fix(mobile): supply all tab-screen providers in shell tests Both home-shell test helpers were missing SnippetProvider, KeyProvider, KeyGenService, SettingsProvider, SyncProvider, SyncService, and SshService. MobileHomeShell uses an IndexedStack so all 4 tab screens build eagerly; each screen's provider dependencies must be satisfied at the shell level. Also switches the 'Settings tab is reachable' assertion from tester.widget to tester.firstWidget: TerminalAppearanceControls renders a DropdownButton which Flutter implements via IndexedStack internally, causing find.byType(IndexedStack) to match multiple widgets once the Settings screen is fully rendered. --- app/test/mobile/home_shell_test.dart | 57 ++++++++++++------ app/test/mobile/mobile_home_shell_test.dart | 66 +++++++++++++-------- 2 files changed, 82 insertions(+), 41 deletions(-) diff --git a/app/test/mobile/home_shell_test.dart b/app/test/mobile/home_shell_test.dart index b1ac8bad..f5da4fdd 100644 --- a/app/test/mobile/home_shell_test.dart +++ b/app/test/mobile/home_shell_test.dart @@ -2,34 +2,57 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/providers/known_hosts_provider.dart'; import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; +import 'package:yourssh/providers/sync_provider.dart'; +import 'package:yourssh/services/key_gen_service.dart'; import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/sync_service.dart'; import 'package:yourssh/services/tab_metadata_service.dart'; -Widget _wrap(Widget child) => MultiProvider( - providers: [ - ChangeNotifierProvider( - create: (_) => KnownHostsProvider.forTest([]), - ), - ChangeNotifierProvider( - create: (_) => HostProvider(StorageService()), - ), - ChangeNotifierProvider( - create: (_) => - SessionProvider(SshService(StorageService()), TabMetadataService()), - ), - ], - child: MaterialApp( - theme: buildMobileTheme(), - home: child, +Widget _wrap(Widget child) { + final storage = StorageService(); + final sync = SyncProvider(); + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => KnownHostsProvider.forTest([]), ), - ); + ChangeNotifierProvider( + create: (_) => HostProvider(storage), + ), + ChangeNotifierProvider( + create: (_) => KeyProvider(), + ), + ChangeNotifierProvider( + create: (_) => + SessionProvider(SshService(storage), TabMetadataService()), + ), + ChangeNotifierProvider( + create: (_) => SnippetProvider(), + ), + ChangeNotifierProvider( + create: (_) => SettingsProvider(), + ), + ChangeNotifierProvider.value(value: sync), + Provider(create: (_) => SyncService(sync)), + Provider(create: (_) => KeyGenService()), + Provider(create: (_) => SshService(storage)), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: child, + ), + ); +} void main() { setUp(() => SharedPreferences.setMockInitialValues({})); diff --git a/app/test/mobile/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart index 96d2b3c2..d27e9b7d 100644 --- a/app/test/mobile/mobile_home_shell_test.dart +++ b/app/test/mobile/mobile_home_shell_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; import 'package:yourssh/mobile/screens/mobile_home_shell.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/mobile/widgets/mobile_tab_bar.dart'; @@ -9,35 +10,49 @@ import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/providers/known_hosts_provider.dart'; import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; +import 'package:yourssh/providers/sync_provider.dart'; import 'package:yourssh/services/key_gen_service.dart'; import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/sync_service.dart'; import 'package:yourssh/services/tab_metadata_service.dart'; -Widget _wrap(Widget child) => MultiProvider( - providers: [ - ChangeNotifierProvider( - create: (_) => KnownHostsProvider.forTest([]), - ), - ChangeNotifierProvider( - create: (_) => HostProvider(StorageService()), - ), - ChangeNotifierProvider( - create: (_) => KeyProvider(), - ), - ChangeNotifierProvider( - create: (_) => - SessionProvider(SshService(StorageService()), TabMetadataService()), - ), - Provider( - create: (_) => KeyGenService(), - ), - ], - child: MaterialApp( - theme: buildMobileTheme(), - home: child, +Widget _wrap(Widget child) { + final storage = StorageService(); + final sync = SyncProvider(); + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => KnownHostsProvider.forTest([]), ), - ); + ChangeNotifierProvider( + create: (_) => HostProvider(storage), + ), + ChangeNotifierProvider( + create: (_) => KeyProvider(), + ), + ChangeNotifierProvider( + create: (_) => + SessionProvider(SshService(storage), TabMetadataService()), + ), + ChangeNotifierProvider( + create: (_) => SnippetProvider(), + ), + ChangeNotifierProvider( + create: (_) => SettingsProvider(), + ), + ChangeNotifierProvider.value(value: sync), + Provider(create: (_) => SyncService(sync)), + Provider(create: (_) => KeyGenService()), + Provider(create: (_) => SshService(storage)), + ], + child: MaterialApp( + theme: buildMobileTheme(), + home: child, + ), + ); +} void main() { setUp(() => SharedPreferences.setMockInitialValues({})); @@ -66,7 +81,10 @@ void main() { ); await tester.pumpAndSettle(); - final stack = tester.widget(find.byType(IndexedStack)); + // Use firstWidget: TerminalAppearanceControls on the Settings screen renders + // a DropdownButton which internally creates additional IndexedStack widgets. + // The shell's IndexedStack is always the outermost (first) one in the tree. + final stack = tester.firstWidget(find.byType(IndexedStack)); expect(stack.index, MobileTab.values.indexOf(MobileTab.settings)); }); } From c47f020808a678b818ab8756ed982aa1aa0bcad0 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:28:12 +0700 Subject: [PATCH 75/96] test(mobile): update settings cloud-config save test for new sheet UI --- .../mobile/mobile_settings_screen_test.dart | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/app/test/mobile/mobile_settings_screen_test.dart b/app/test/mobile/mobile_settings_screen_test.dart index 5bd909de..4320b37f 100644 --- a/app/test/mobile/mobile_settings_screen_test.dart +++ b/app/test/mobile/mobile_settings_screen_test.dart @@ -1,41 +1,75 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_settings_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/settings_provider.dart'; import 'package:yourssh/providers/sync_provider.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/services/sync_service.dart'; -Future _pump(WidgetTester tester, SyncProvider sync) async { +Future _pump(WidgetTester tester) async { + final sync = SyncProvider(); final storage = StorageService(); - await tester.pumpWidget(MaterialApp( - home: MultiProvider( + + // Providers must wrap MaterialApp (not just home:) so that modal bottom + // sheets — which run in a separate overlay route — can still find them. + await tester.pumpWidget( + MultiProvider( providers: [ - ChangeNotifierProvider.value(value: sync), - ChangeNotifierProvider(create: (_) => HostProvider(storage)), - ChangeNotifierProvider(create: (_) => SettingsProvider()), + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider.value( + value: HostProvider(storage), + ), + ChangeNotifierProvider( + create: (_) => SettingsProvider(), + ), Provider(create: (_) => SyncService(sync)), ], - child: const MobileSettingsScreen(), + child: MaterialApp( + theme: buildMobileTheme(), + home: const MobileSettingsScreen(), + ), ), - )); + ); + await tester.pumpAndSettle(); + // Scroll to ensure lazy list items below the fold are built. + await tester.drag(find.byType(ListView), const Offset(0, -3000)); await tester.pumpAndSettle(); + return sync; } void main() { - setUp(() => SharedPreferences.setMockInitialValues({})); + setUp(() { + SharedPreferences.setMockInitialValues({}); + PackageInfo.setMockInitialValues( + appName: 'YourSSH', + packageName: 'com.yourssh', + version: '1.2.3', + buildNumber: '42', + buildSignature: '', + installerStore: null, + ); + }); testWidgets('saving cloud config writes to the provider', (tester) async { - final sync = SyncProvider(); - await _pump(tester, sync); + final sync = await _pump(tester); + + // Open the Supabase sync bottom sheet via the row tap. + await tester.tap(find.text('Supabase sync')); + await tester.pumpAndSettle(); + // The sheet fields are now visible. await tester.enterText( find.byKey(const Key('sync-url')), 'https://x.supabase.co'); - await tester.enterText(find.byKey(const Key('sync-anon')), 'anon-key-123'); - await tester.enterText(find.byKey(const Key('sync-code')), '123456789012'); + await tester.enterText( + find.byKey(const Key('sync-anon')), 'anon-key-123'); + await tester.enterText( + find.byKey(const Key('sync-code')), '123456789012'); + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); From eaae513727ab38fbc6e142375a9372670f8494cd Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:34:34 +0700 Subject: [PATCH 76/96] feat(mobile): terminal screen with session tabs + reskinned shortcut bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace MobileSessionsScreen with MobileTerminalScreen: adds amber header bar with back/split/⋮ buttons, session-tab strip with active/inactive states and a "+" tile, accessory-bar pref wiring (kAccessoryBarPrefKey), and stub ⋮ menu items for Files and Port forwarding. Preserves pinch-zoom, CursorDragMapper long-press drag, and showTerminalSidePanel. --- .../screens/mobile_sessions_screen.dart | 304 ---------- .../screens/mobile_terminal_screen.dart | 536 ++++++++++++++++++ .../mobile/mobile_sessions_screen_test.dart | 12 +- .../mobile/screens/terminal_screen_test.dart | 67 +++ 4 files changed, 611 insertions(+), 308 deletions(-) delete mode 100644 app/lib/mobile/screens/mobile_sessions_screen.dart create mode 100644 app/lib/mobile/screens/mobile_terminal_screen.dart create mode 100644 app/test/mobile/screens/terminal_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_sessions_screen.dart b/app/lib/mobile/screens/mobile_sessions_screen.dart deleted file mode 100644 index 5d5b2161..00000000 --- a/app/lib/mobile/screens/mobile_sessions_screen.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:xterm/xterm.dart'; - -import '../../models/ssh_session.dart'; -import '../../providers/session_provider.dart'; -import '../../providers/settings_provider.dart'; -import '../../theme/app_theme.dart'; -import '../../theme/terminal_themes.dart'; -import '../../util/terminal_appearance.dart'; -import '../terminal/accessory_bar_controller.dart'; -import '../terminal/accessory_key_bar.dart'; -import '../terminal/terminal_cursor_gestures.dart'; -import '../terminal/terminal_side_panel.dart'; -import '../theme/mobile_tokens.dart'; - -/// Sessions tab: a session strip + the active SSH session's terminal (when -/// connected, with the accessory key bar docked below and per-session -/// pinch-to-zoom) or its connection status. -class MobileSessionsScreen extends StatefulWidget { - const MobileSessionsScreen({super.key}); - - @override - State createState() => _MobileSessionsScreenState(); -} - -class _MobileSessionsScreenState extends State { - final _accessory = AccessoryBarController(); - // Pinch-zoom font overrides, keyed by session id — so zooming one session - // doesn't bleed its size onto another (and absence = follow appearance). - final Map _pinch = {}; - double _scaleBase = 0; - - @override - void dispose() { - _accessory.dispose(); - super.dispose(); - } - - void _onScaleStart(String sessionId, double followSize) { - _scaleBase = _pinch[sessionId] ?? followSize; - } - - void _onScaleUpdate(String sessionId, ScaleUpdateDetails d) { - if (d.scale == 1.0) return; - setState(() => _pinch[sessionId] = (_scaleBase * d.scale).clamp(8.0, 28.0)); - } - - void _openPanel(SshSession active, {int initialTab = 0}) { - showTerminalSidePanel( - context, - sessionId: active.id, - onInsert: (cmd) => active.terminal.textInput(cmd), - onKey: (k, {ctrl = false, alt = false}) => - active.terminal.keyInput(k, ctrl: ctrl, alt: alt), - initialTab: initialTab, - ); - } - - @override - Widget build(BuildContext context) { - final sp = context.watch(); - final ssh = sp.sshSessions; - // Drop pinch-zoom overrides for sessions that have since closed. - _pinch.removeWhere((id, _) => ssh.every((s) => s.id != id)); - - if (ssh.isEmpty) { - return const Scaffold( - backgroundColor: AppColors.bg, - body: Center( - child: Text('No active sessions — connect from Hosts', - style: TextStyle(color: AppColors.textSecondary)), - ), - ); - } - - final active = sp.activeSession is SshSession - ? sp.activeSession as SshSession - : ssh.last; - - final settings = context.watch(); - final appearance = resolveTerminalAppearance( - host: active.host, - globalTheme: settings.terminalTheme, - globalFont: settings.terminalFont, - globalFontSize: settings.fontSize, - ); - - return Scaffold( - backgroundColor: AppColors.bg, - body: SafeArea( - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _SessionStrip(sessions: ssh, activeId: active.id)), - IconButton( - icon: const Icon(Icons.dashboard_customize_outlined, - color: AppColors.textSecondary), - tooltip: 'Keyboard, snippets, history, themes', - onPressed: () => _openPanel(active), - ), - ], - ), - const Divider(height: 1, color: AppColors.border), - Expanded( - child: _SessionBody( - session: active, - accessory: _accessory, - appearance: appearance, - fontSize: _pinch[active.id] ?? appearance.fontSize, - onScaleStart: (_) => - _onScaleStart(active.id, appearance.fontSize), - onScaleUpdate: (d) => _onScaleUpdate(active.id, d), - onOpenPanel: () => _openPanel(active), - ), - ), - ], - ), - ), - ); - } -} - -class _SessionStrip extends StatelessWidget { - final List sessions; - final String activeId; - const _SessionStrip({required this.sessions, required this.activeId}); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 44, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - children: [ - for (final s in sessions) - Padding( - padding: const EdgeInsets.only(right: 6), - child: _SessionPill( - session: s, - selected: s.id == activeId, - onTap: () => context.read().setActive(s.id), - onClose: () => context.read().closeSession(s.id), - ), - ), - ], - ), - ); - } -} - -class _SessionPill extends StatelessWidget { - final SshSession session; - final bool selected; - final VoidCallback onTap; - final VoidCallback onClose; - const _SessionPill({ - required this.session, - required this.selected, - required this.onTap, - required this.onClose, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - borderRadius: BorderRadius.circular(MobileTokens.radiusPill), - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: selected ? AppColors.card : AppColors.bg, - borderRadius: BorderRadius.circular(MobileTokens.radiusPill), - border: Border.all( - color: selected ? AppColors.accent : AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: AppColors.hostColor(session.host.host), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text(session.tabLabel, - style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textSecondary, - fontSize: 13)), - const SizedBox(width: 6), - GestureDetector( - onTap: onClose, - child: const Icon(Icons.close, - size: 14, color: AppColors.textTertiary), - ), - ], - ), - ), - ); - } -} - -class _SessionBody extends StatefulWidget { - final SshSession session; - final AccessoryBarController accessory; - final TerminalAppearance appearance; - final double fontSize; - final void Function(ScaleStartDetails) onScaleStart; - final void Function(ScaleUpdateDetails) onScaleUpdate; - final VoidCallback onOpenPanel; - - const _SessionBody({ - required this.session, - required this.accessory, - required this.appearance, - required this.fontSize, - required this.onScaleStart, - required this.onScaleUpdate, - required this.onOpenPanel, - }); - - @override - State<_SessionBody> createState() => _SessionBodyState(); -} - -class _SessionBodyState extends State<_SessionBody> { - final _cursor = CursorDragMapper(); - Offset _lastDrag = Offset.zero; - - @override - Widget build(BuildContext context) { - if (widget.session.status == SessionStatus.connected) { - return Column( - children: [ - Expanded( - // Outer GestureDetector handles long-press-drag for cursor movement. - // Inner GestureDetector handles pinch-to-zoom (scale). - // Nesting avoids competing recognizer conflicts between scale and - // long-press-drag that can occur with a single GestureDetector. - child: GestureDetector( - onLongPressMoveUpdate: (d) { - final delta = d.offsetFromOrigin - _lastDrag; - for (final k in _cursor.addDelta(delta.dx, delta.dy)) { - widget.session.terminal.keyInput(k); - } - _lastDrag = d.offsetFromOrigin; - }, - onLongPressEnd: (_) { - _cursor.reset(); - _lastDrag = Offset.zero; - }, - child: GestureDetector( - onScaleStart: widget.onScaleStart, - onScaleUpdate: widget.onScaleUpdate, - child: TerminalView( - widget.session.terminal, - theme: terminalThemeByName(widget.appearance.themeName), - textStyle: TerminalStyle( - fontSize: widget.fontSize, - fontFamily: widget.appearance.fontFamily, - ), - ), - ), - ), - ), - AccessoryKeyBar( - controller: widget.accessory, - onKey: (k, {ctrl = false, alt = false}) => - widget.session.terminal.keyInput(k, ctrl: ctrl, alt: alt), - onText: (s) => widget.session.terminal.textInput(s), - onOpenPanel: widget.onOpenPanel, - ), - ], - ); - } - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.session.status == SessionStatus.connecting) - const Padding( - padding: EdgeInsets.only(bottom: 12), - child: CircularProgressIndicator(), - ), - Text(widget.session.statusLabel, - style: const TextStyle(color: AppColors.textSecondary)), - if (widget.session.errorMessage != null) ...[ - const SizedBox(height: 8), - Text(widget.session.errorMessage!, - style: const TextStyle(color: AppColors.red, fontSize: 12)), - ], - ], - ), - ); - } -} diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart new file mode 100644 index 00000000..2e31c710 --- /dev/null +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -0,0 +1,536 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xterm/xterm.dart'; + +import '../../models/ssh_session.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/settings_provider.dart'; +import '../../theme/terminal_themes.dart'; +import '../../util/terminal_appearance.dart'; +import '../terminal/accessory_bar_controller.dart'; +import '../terminal/accessory_key_bar.dart'; +import '../terminal/terminal_cursor_gestures.dart'; +import '../terminal/terminal_side_panel.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import '../util/mobile_prefs.dart'; + +/// Mobile terminal screen: header + session-tab strip + xterm + accessory bar. +/// +/// [focusSessionId] — if non-null, switch to that session on mount. +/// The ⋮ menu exposes [onOpenFiles] and [onOpenPortForward] hooks; these are +/// wired by Tasks 13 & 15 respectively. Passing null shows a "coming soon" +/// SnackBar as an interim stub. +class MobileTerminalScreen extends StatefulWidget { + final String? focusSessionId; + final VoidCallback? onOpenFiles; + final VoidCallback? onOpenPortForward; + + const MobileTerminalScreen({ + super.key, + this.focusSessionId, + this.onOpenFiles, + this.onOpenPortForward, + }); + + @override + State createState() => _MobileTerminalScreenState(); +} + +class _MobileTerminalScreenState extends State { + final _accessory = AccessoryBarController(); + + /// Per-session pinch-zoom font override. Absence = follow appearance default. + final Map _pinch = {}; + double _scaleBase = 0; + bool _accessoryBarEnabled = true; + + @override + void initState() { + super.initState(); + _loadAccessoryPref(); + if (widget.focusSessionId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().setActive(widget.focusSessionId!); + } + }); + } + } + + Future _loadAccessoryPref() async { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) return; + setState(() { + _accessoryBarEnabled = prefs.getBool(kAccessoryBarPrefKey) ?? true; + }); + } + + @override + void dispose() { + _accessory.dispose(); + super.dispose(); + } + + void _onScaleStart(String sessionId, double followSize) { + _scaleBase = _pinch[sessionId] ?? followSize; + } + + void _onScaleUpdate(String sessionId, ScaleUpdateDetails d) { + if (d.scale == 1.0) return; + setState(() => _pinch[sessionId] = (_scaleBase * d.scale).clamp(8.0, 28.0)); + } + + void _openPanel(SshSession active, {int initialTab = 0}) { + showTerminalSidePanel( + context, + sessionId: active.id, + onInsert: (cmd) => active.terminal.textInput(cmd), + onKey: (k, {ctrl = false, alt = false}) => + active.terminal.keyInput(k, ctrl: ctrl, alt: alt), + initialTab: initialTab, + ); + } + + void _showMenu(SshSession? active) { + showModalBottomSheet( + context: context, + backgroundColor: MobileColors.surfaceAlt, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + ListTile( + leading: const Icon(Icons.folder_outlined, + color: MobileColors.textPrimary), + title: Text('Files', + style: mobileBody(color: MobileColors.textPrimary)), + onTap: () { + Navigator.pop(context); + if (widget.onOpenFiles != null) { + widget.onOpenFiles!(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Files — coming soon')), + ); + } + }, + ), + ListTile( + leading: const Icon(Icons.compare_arrows_outlined, + color: MobileColors.textPrimary), + title: Text('Port forwarding', + style: mobileBody(color: MobileColors.textPrimary)), + onTap: () { + Navigator.pop(context); + if (widget.onOpenPortForward != null) { + widget.onOpenPortForward!(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Port forwarding — coming soon')), + ); + } + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final sp = context.watch(); + final ssh = sp.sshSessions; + // Prune pinch overrides for closed sessions. + _pinch.removeWhere((id, _) => ssh.every((s) => s.id != id)); + + if (ssh.isEmpty) { + return Scaffold( + backgroundColor: MobileColors.bg, + appBar: AppBar( + backgroundColor: MobileColors.surfaceAlt, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: MobileColors.accent, size: 20), + onPressed: () => Navigator.maybePop(context), + ), + title: Text('Terminal', + style: mobileBody(size: 16, weight: FontWeight.w600)), + centerTitle: true, + ), + body: const Center( + child: Text( + 'No active sessions — connect from Hosts', + style: TextStyle(color: MobileColors.textMuted), + ), + ), + ); + } + + final active = sp.activeSession is SshSession + ? sp.activeSession as SshSession + : ssh.last; + + final settings = context.watch(); + final appearance = resolveTerminalAppearance( + host: active.host, + globalTheme: settings.terminalTheme, + globalFont: settings.terminalFont, + globalFontSize: settings.fontSize, + ); + + return Scaffold( + backgroundColor: MobileColors.bg, + body: SafeArea( + child: Column( + children: [ + _TerminalHeader( + session: active, + onBack: () => Navigator.maybePop(context), + onMenu: () => _showMenu(active), + ), + _SessionTabStrip( + sessions: ssh, + activeId: active.id, + onAdd: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Add session — pick a host')), + ), + ), + Expanded( + child: _SessionBody( + session: active, + accessory: _accessory, + appearance: appearance, + fontSize: _pinch[active.id] ?? appearance.fontSize, + accessoryBarEnabled: _accessoryBarEnabled, + onScaleStart: (_) => + _onScaleStart(active.id, appearance.fontSize), + onScaleUpdate: (d) => _onScaleUpdate(active.id, d), + onOpenPanel: () => _openPanel(active), + ), + ), + ], + ), + ), + ); + } +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +class _TerminalHeader extends StatelessWidget { + final SshSession session; + final VoidCallback onBack; + final VoidCallback onMenu; + + const _TerminalHeader({ + required this.session, + required this.onBack, + required this.onMenu, + }); + + @override + Widget build(BuildContext context) { + final label = session.host.label; + final subtitle = '${session.host.username}@${session.host.host}'; + return Container( + height: 52, + color: MobileColors.surfaceAlt, + child: Row( + children: [ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: MobileColors.accent, size: 20), + onPressed: onBack, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: MobileColors.green, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text(label, + style: mobileBody( + size: 14, weight: FontWeight.w600)), + ], + ), + Text( + subtitle, + style: mobileMono( + size: 11, color: MobileColors.textMuted), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.call_split_outlined, + color: MobileColors.textMuted, size: 20), + tooltip: 'Split', + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.more_vert, + color: MobileColors.textMuted, size: 20), + tooltip: 'More', + onPressed: onMenu, + ), + const SizedBox(width: 4), + ], + ), + ); + } +} + +// ── Session-tab strip ───────────────────────────────────────────────────────── + +class _SessionTabStrip extends StatelessWidget { + final List sessions; + final String activeId; + final VoidCallback onAdd; + + const _SessionTabStrip({ + required this.sessions, + required this.activeId, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + color: MobileColors.surfaceAlt, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + children: [ + for (final s in sessions) + Padding( + padding: const EdgeInsets.only(right: 6), + child: _SessionTab( + session: s, + isActive: s.id == activeId, + onTap: () => + context.read().setActive(s.id), + onClose: () => + context.read().closeSession(s.id), + ), + ), + // "+" tile + GestureDetector( + onTap: onAdd, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: MobileColors.surface, + borderRadius: + BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all(color: MobileColors.border), + ), + child: const Icon(Icons.add, + size: 16, color: MobileColors.textMuted), + ), + ), + ], + ), + ); + } +} + +class _SessionTab extends StatelessWidget { + final SshSession session; + final bool isActive; + final VoidCallback onTap; + final VoidCallback onClose; + + const _SessionTab({ + required this.session, + required this.isActive, + required this.onTap, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: isActive ? MobileColors.accentSoft : MobileColors.surface, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all( + color: isActive ? MobileColors.accent : MobileColors.border, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: MobileColors.green, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 7), + Text( + session.tabLabel, + style: mobileBody( + size: 13, + color: isActive + ? MobileColors.textPrimary + : MobileColors.textMuted, + weight: isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: onClose, + child: const Icon(Icons.close, + size: 14, color: MobileColors.textFaint), + ), + ], + ), + ), + ); + } +} + +// ── Session body (terminal + accessory bar) ─────────────────────────────────── + +class _SessionBody extends StatefulWidget { + final SshSession session; + final AccessoryBarController accessory; + final TerminalAppearance appearance; + final double fontSize; + final bool accessoryBarEnabled; + final void Function(ScaleStartDetails) onScaleStart; + final void Function(ScaleUpdateDetails) onScaleUpdate; + final VoidCallback onOpenPanel; + + const _SessionBody({ + required this.session, + required this.accessory, + required this.appearance, + required this.fontSize, + required this.accessoryBarEnabled, + required this.onScaleStart, + required this.onScaleUpdate, + required this.onOpenPanel, + }); + + @override + State<_SessionBody> createState() => _SessionBodyState(); +} + +class _SessionBodyState extends State<_SessionBody> { + final _cursor = CursorDragMapper(); + Offset _lastDrag = Offset.zero; + + @override + Widget build(BuildContext context) { + if (widget.session.status == SessionStatus.connected) { + return Column( + children: [ + Expanded( + // Outer GestureDetector: long-press-drag for cursor movement. + // Inner GestureDetector: pinch-to-zoom (scale). + // Nesting avoids competing recognizer conflicts. + child: GestureDetector( + onLongPressMoveUpdate: (d) { + final delta = d.offsetFromOrigin - _lastDrag; + for (final k in _cursor.addDelta(delta.dx, delta.dy)) { + widget.session.terminal.keyInput(k); + } + _lastDrag = d.offsetFromOrigin; + }, + onLongPressEnd: (_) { + _cursor.reset(); + _lastDrag = Offset.zero; + }, + child: GestureDetector( + onScaleStart: widget.onScaleStart, + onScaleUpdate: widget.onScaleUpdate, + child: ColoredBox( + color: MobileColors.bg, + child: TerminalView( + widget.session.terminal, + theme: terminalThemeByName(widget.appearance.themeName), + textStyle: TerminalStyle( + fontSize: widget.fontSize, + fontFamily: widget.appearance.fontFamily, + ), + ), + ), + ), + ), + ), + if (widget.accessoryBarEnabled) + AccessoryKeyBar( + controller: widget.accessory, + onKey: (k, {ctrl = false, alt = false}) => + widget.session.terminal.keyInput(k, ctrl: ctrl, alt: alt), + onText: (s) => widget.session.terminal.textInput(s), + onOpenPanel: widget.onOpenPanel, + ), + ], + ); + } + + // Non-connected state: progress/status/error. + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.session.status == SessionStatus.connecting) + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: CircularProgressIndicator(color: MobileColors.accent), + ), + Text( + widget.session.statusLabel, + style: const TextStyle(color: MobileColors.textMuted), + ), + if (widget.session.errorMessage != null) ...[ + const SizedBox(height: 8), + Text( + widget.session.errorMessage!, + style: const TextStyle( + color: MobileColors.red, fontSize: 12), + ), + ], + ], + ), + ); + } +} diff --git a/app/test/mobile/mobile_sessions_screen_test.dart b/app/test/mobile/mobile_sessions_screen_test.dart index 22b6bdcb..2f7cf791 100644 --- a/app/test/mobile/mobile_sessions_screen_test.dart +++ b/app/test/mobile/mobile_sessions_screen_test.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:yourssh/mobile/screens/mobile_sessions_screen.dart'; +import 'package:yourssh/mobile/screens/mobile_terminal_screen.dart'; import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/services/tab_metadata_service.dart'; @@ -15,9 +16,12 @@ void main() { final sp = SessionProvider(SshService(StorageService()), TabMetadataService()); await tester.pumpWidget(MaterialApp( - home: ChangeNotifierProvider.value( - value: sp, - child: const MobileSessionsScreen(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sp), + ChangeNotifierProvider(create: (_) => SettingsProvider()), + ], + child: const MobileTerminalScreen(), ), )); expect(find.textContaining('No active sessions'), findsOneWidget); diff --git a/app/test/mobile/screens/terminal_screen_test.dart b/app/test/mobile/screens/terminal_screen_test.dart new file mode 100644 index 00000000..fc9f53d4 --- /dev/null +++ b/app/test/mobile/screens/terminal_screen_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xterm/xterm.dart'; + +import 'package:yourssh/mobile/screens/mobile_terminal_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/models/app_session.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/ssh_session.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +// Fake SessionProvider with one connected SSH session. +class _FakeSessionProvider extends SessionProvider { + final SshSession _session; + _FakeSessionProvider(this._session) + : super(SshService(StorageService()), TabMetadataService()); + + @override + List get sshSessions => [_session]; + + @override + AppSession? get activeSession => _session; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows session tab label and TerminalView for connected session', + (tester) async { + final host = Host( + id: 'h1', + label: 'web-01', + host: '10.0.4.21', + port: 22, + username: 'deploy', + ); + final session = SshSession( + id: 's1', + host: host, + status: SessionStatus.connected, + ); + final sp = _FakeSessionProvider(session); + + await tester.pumpWidget(MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sp), + ChangeNotifierProvider(create: (_) => SettingsProvider()), + ], + child: const MobileTerminalScreen(), + ), + )); + await tester.pump(); + + // Session tab label should be visible. + expect(find.text('web-01'), findsAtLeastNWidgets(1)); + // TerminalView should be present. + expect(find.byType(TerminalView), findsOneWidget); + }); +} From 0ac48125bab7f654c679deada2752bd910d5a3be Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:40:58 +0700 Subject: [PATCH 77/96] fix(mobile): reskin accessory key bar to amber, status-driven header dot --- app/lib/mobile/screens/mobile_terminal_screen.dart | 8 ++++++-- app/lib/mobile/terminal/accessory_key_bar.dart | 12 ++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart index 2e31c710..58344bee 100644 --- a/app/lib/mobile/screens/mobile_terminal_screen.dart +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -271,8 +271,12 @@ class _TerminalHeader extends StatelessWidget { Container( width: 8, height: 8, - decoration: const BoxDecoration( - color: MobileColors.green, + decoration: BoxDecoration( + color: session.status == SessionStatus.connected + ? MobileColors.green + : session.status == SessionStatus.connecting + ? MobileColors.accent + : MobileColors.red, shape: BoxShape.circle, ), ), diff --git a/app/lib/mobile/terminal/accessory_key_bar.dart b/app/lib/mobile/terminal/accessory_key_bar.dart index 29b63d9a..10df57cc 100644 --- a/app/lib/mobile/terminal/accessory_key_bar.dart +++ b/app/lib/mobile/terminal/accessory_key_bar.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:xterm/xterm.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; import 'accessory_bar_controller.dart'; @@ -53,7 +53,7 @@ class AccessoryKeyBar extends StatelessWidget { builder: (context, _) { return Container( height: MobileTokens.accessoryBarHeight, - color: AppColors.card, + color: MobileColors.surfaceAlt, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), @@ -93,12 +93,12 @@ class AccessoryKeyBar extends StatelessWidget { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( - color: armed ? AppColors.accent : AppColors.bg, + color: armed ? MobileColors.accent : MobileColors.bg, borderRadius: BorderRadius.circular(8), ), child: Text(label, style: TextStyle( - color: armed ? Colors.black : AppColors.textPrimary, + color: armed ? Colors.black : MobileColors.textPrimary, fontSize: 14)), ), ), @@ -116,10 +116,10 @@ class AccessoryKeyBar extends StatelessWidget { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( - color: AppColors.bg, + color: MobileColors.bg, borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, color: AppColors.textPrimary, size: 20), + child: Icon(icon, color: MobileColors.textPrimary, size: 20), ), ), ), From ff1785aaa167fb8c1acc0be22dbfd857aee899d6 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:49:33 +0700 Subject: [PATCH 78/96] feat(mobile): host->terminal navigation and session re-entry --- .../mobile/screens/mobile_hosts_screen.dart | 41 ++++- .../screens/mobile_terminal_screen.dart | 95 +++++++++- app/test/mobile/terminal_nav_test.dart | 173 ++++++++++++++++++ 3 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 app/test/mobile/terminal_nav_test.dart diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index a81fc1c8..951f3e97 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -15,6 +15,7 @@ import '../widgets/section_header.dart'; import '../widgets/status_dot.dart'; import '../widgets/tag_chip.dart'; import 'mobile_add_host_screen.dart'; +import 'mobile_terminal_screen.dart'; /// Hosts tab: header + search + folder chips + tag-grouped list + amber FAB. /// Tap a host → connect via SessionProvider. Long-press → edit/delete sheet. @@ -321,20 +322,44 @@ class _MobileHostsScreenState extends State { connecting: connState == HostConnState.connecting || ping.state == HostReachState.probing, latencyMs: ping.ms, - onTap: () => _connect(host), + onTap: () => _openSession(host), onLongPress: () => _showActions(host), ); } // ── Actions ────────────────────────────────────────────────────────────── - void _connect(Host host) { - context.read().connectAny(host); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Connecting to ${host.label}…'), - duration: const Duration(seconds: 2), - backgroundColor: MobileColors.surface, + Future _openSession(Host host) async { + final sp = context.read(); + + // Re-entry: if a live session exists for this host, just open the terminal. + final existing = sp.sshSessions + .where((s) => s.host.id == host.id) + .toList(); + if (existing.isNotEmpty) { + final sessionId = existing.first.id; + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MobileTerminalScreen(focusSessionId: sessionId), + ), + ); + return; + } + + // No live session — connect, then navigate. + await sp.connectAny(host); + if (!mounted) return; + + // After connectAny the session is in sshSessions; find the freshest one. + final after = sp.sshSessions + .where((s) => s.host.id == host.id) + .toList(); + final sessionId = after.isNotEmpty ? after.last.id : null; + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MobileTerminalScreen(focusSessionId: sessionId), ), ); } diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart index 58344bee..6c514ff7 100644 --- a/app/lib/mobile/screens/mobile_terminal_screen.dart +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:xterm/xterm.dart'; import '../../models/ssh_session.dart'; +import '../../providers/host_provider.dart'; import '../../providers/session_provider.dart'; import '../../providers/settings_provider.dart'; import '../../theme/terminal_themes.dart'; @@ -46,6 +47,10 @@ class _MobileTerminalScreenState extends State { double _scaleBase = 0; bool _accessoryBarEnabled = true; + /// Whether we have already scheduled a pop-on-empty frame. Prevents double-pop + /// when the provider fires multiple notifications in one frame. + bool _popScheduled = false; + @override void initState() { super.initState(); @@ -59,6 +64,18 @@ class _MobileTerminalScreenState extends State { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final ssh = context.watch().sshSessions; + if (ssh.isEmpty && !_popScheduled) { + _popScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.maybePop(context); + }); + } + } + Future _loadAccessoryPref() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) return; @@ -154,6 +171,80 @@ class _MobileTerminalScreenState extends State { ); } + /// Opens a bottom-sheet host picker so the user can start a new session + /// without leaving the terminal. Picking a host calls [SessionProvider.connectAny]. + Future _showHostPicker(BuildContext ctx) async { + final hosts = ctx.read().allHosts; + if (!mounted) return; + await showModalBottomSheet( + context: ctx, + backgroundColor: MobileColors.surfaceAlt, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), + child: Text( + 'Connect to host', + style: mobileBody(size: 16, weight: FontWeight.w600), + ), + ), + if (hosts.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: MobileTokens.space4), + child: Text( + 'No hosts configured', + style: mobileBody( + size: 14, color: MobileColors.textMuted), + ), + ) + else + ListView.builder( + shrinkWrap: true, + itemCount: hosts.length, + itemBuilder: (_, i) { + final host = hosts[i]; + return ListTile( + leading: const Icon(Icons.dns_outlined, + color: MobileColors.textPrimary), + title: Text(host.label, + style: mobileBody( + size: 15, color: MobileColors.textPrimary)), + subtitle: Text( + '${host.username}@${host.host}', + style: mobileMono( + size: 12, color: MobileColors.textMuted), + ), + onTap: () { + Navigator.pop(sheetCtx); + ctx.read().connectAny(host); + }, + ); + }, + ), + const SizedBox(height: MobileTokens.space2), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final sp = context.watch(); @@ -209,9 +300,7 @@ class _MobileTerminalScreenState extends State { _SessionTabStrip( sessions: ssh, activeId: active.id, - onAdd: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Add session — pick a host')), - ), + onAdd: () => _showHostPicker(context), ), Expanded( child: _SessionBody( diff --git a/app/test/mobile/terminal_nav_test.dart b/app/test/mobile/terminal_nav_test.dart new file mode 100644 index 00000000..de2c0776 --- /dev/null +++ b/app/test/mobile/terminal_nav_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/app_session.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/ssh_session.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/providers/settings_provider.dart'; +import 'package:yourssh/mobile/screens/mobile_hosts_screen.dart'; +import 'package:yourssh/mobile/screens/mobile_terminal_screen.dart'; +import 'package:yourssh/mobile/services/host_reachability_probe.dart'; +import 'package:yourssh/mobile/widgets/host_card.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/tab_metadata_service.dart'; + +// --------------------------------------------------------------------------- +// Fake SessionProvider — injects a pre-built SshSession so connectAny +// immediately materialises a session without real SSH. +// --------------------------------------------------------------------------- + +class _FakeSessionProvider extends SessionProvider { + final List _injected; + int connectAnyCalls = 0; + + _FakeSessionProvider(this._injected) + : super(SshService(StorageService()), TabMetadataService()); + + @override + List get sshSessions => List.unmodifiable(_injected); + + /// Override the real connectAny so no SSH dial happens. + @override + Future connectAny(Host host, {String? initialCommand}) async { + connectAnyCalls++; + // The session is already injected; return null like the SSH path does. + return null; + } +} + +// --------------------------------------------------------------------------- +// Helper: pumps MobileHostsScreen inside a Navigator with all required +// providers so that navigation to MobileTerminalScreen works in tests. +// --------------------------------------------------------------------------- + +Future<({_FakeSessionProvider sp, HostProvider hp})> _pump( + WidgetTester tester, { + List hosts = const [], + List sessions = const [], +}) async { + SharedPreferences.setMockInitialValues({}); + final hp = HostProvider(StorageService()); + for (final h in hosts) { + await hp.addHost(h); + } + + final sp = _FakeSessionProvider(sessions); + final probe = HostReachabilityProbe(connector: (_, __, ___) async {}); + final settings = SettingsProvider(); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: hp), + ChangeNotifierProvider.value(value: sp), + ChangeNotifierProvider.value(value: probe), + ChangeNotifierProvider.value(value: settings), + ], + child: MaterialApp( + home: const MobileHostsScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + return (sp: sp, hp: hp); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + // --------------------------------------------------------------------------- + // T1: Tapping a host pushes MobileTerminalScreen + // --------------------------------------------------------------------------- + testWidgets('tapping a host pushes MobileTerminalScreen', (tester) async { + final host = Host(label: 'prod', host: '10.0.0.1', username: 'root'); + final session = SshSession(host: host); + + await _pump(tester, hosts: [host], sessions: [session]); + + expect(find.byType(HostCard), findsOneWidget); + await tester.tap(find.byType(HostCard)); + // Use pump with a Duration to avoid pumpAndSettle timing out on xterm's + // continuous cursor blink animations. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(MobileTerminalScreen), findsOneWidget); + }); + + // --------------------------------------------------------------------------- + // T2: Re-tapping a connected host navigates to Terminal without duplicate + // --------------------------------------------------------------------------- + testWidgets('re-tapping connected host navigates without extra connect call', + (tester) async { + final host = Host(label: 'prod', host: '10.0.0.1', username: 'root'); + final session = SshSession(host: host); + + // Mark the session as connected so _stateFor returns HostConnState.connected. + session.status = SessionStatus.connected; + + final result = await _pump(tester, hosts: [host], sessions: [session]); + + await tester.tap(find.byType(HostCard)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // connectAny must NOT have been called (session already live). + expect(result.sp.connectAnyCalls, 0); + expect(find.byType(MobileTerminalScreen), findsOneWidget); + }); + + // --------------------------------------------------------------------------- + // T3: Popping Terminal returns to Hosts + // --------------------------------------------------------------------------- + testWidgets('back button from Terminal returns to Hosts', (tester) async { + final host = Host(label: 'prod', host: '10.0.0.1', username: 'root'); + final session = SshSession(host: host); + + await _pump(tester, hosts: [host], sessions: [session]); + + await tester.tap(find.byType(HostCard)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(MobileTerminalScreen), findsOneWidget); + + // Tap the back arrow in the terminal header. + await tester.tap(find.byIcon(Icons.arrow_back_ios_new).first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(MobileHostsScreen), findsOneWidget); + expect(find.byType(MobileTerminalScreen), findsNothing); + }); + + // --------------------------------------------------------------------------- + // T4: "+" tile shows a host-picker bottom sheet + // --------------------------------------------------------------------------- + testWidgets('+ tile opens host picker bottom sheet', (tester) async { + final host = Host(label: 'prod', host: '10.0.0.1', username: 'root'); + final session = SshSession(host: host); + + await _pump(tester, hosts: [host], sessions: [session]); + + // Navigate to the terminal screen first. + await tester.tap(find.byType(HostCard)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Tap the "+" tile inside MobileTerminalScreen's session tab strip. + final addInTerminal = find.descendant( + of: find.byType(MobileTerminalScreen), + matching: find.byIcon(Icons.add), + ); + await tester.tap(addInTerminal); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // A bottom sheet should appear with 'Connect to host' heading. + expect(find.text('Connect to host'), findsOneWidget); + }); +} From c921dc407ac25b9b18284b32822509d359d91467 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 22:53:45 +0700 Subject: [PATCH 79/96] fix(mobile): re-check sessions before pop, consistent session pick, live picker list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pop-on-empty: re-check sshSessions.isEmpty at postFrameCallback fire time and reset _popScheduled so a later empty→fill→empty cycle still triggers a pop - re-entry path: use existing.last.id (matches new-connect path which uses after.last.id) - host picker: read HostProvider.allHosts inside the sheet builder so the list is live while the sheet is open, not a stale snapshot - tests: add T5 (pop-on-empty) and T6 (no-spurious-pop) to terminal_nav_test.dart; fix pre-existing __ lint in probe connector lambda --- .../mobile/screens/mobile_hosts_screen.dart | 2 +- .../screens/mobile_terminal_screen.dart | 121 +++++++++--------- app/test/mobile/terminal_nav_test.dart | 78 ++++++++++- 3 files changed, 139 insertions(+), 62 deletions(-) diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 951f3e97..d1b6b78f 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -337,7 +337,7 @@ class _MobileHostsScreenState extends State { .where((s) => s.host.id == host.id) .toList(); if (existing.isNotEmpty) { - final sessionId = existing.first.id; + final sessionId = existing.last.id; if (!mounted) return; await Navigator.of(context).push( MaterialPageRoute( diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart index 6c514ff7..aba3c6ab 100644 --- a/app/lib/mobile/screens/mobile_terminal_screen.dart +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -71,7 +71,10 @@ class _MobileTerminalScreenState extends State { if (ssh.isEmpty && !_popScheduled) { _popScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.maybePop(context); + if (mounted && context.read().sshSessions.isEmpty) { + Navigator.maybePop(context); + } + _popScheduled = false; }); } } @@ -174,7 +177,6 @@ class _MobileTerminalScreenState extends State { /// Opens a bottom-sheet host picker so the user can start a new session /// without leaving the terminal. Picking a host calls [SessionProvider.connectAny]. Future _showHostPicker(BuildContext ctx) async { - final hosts = ctx.read().allHosts; if (!mounted) return; await showModalBottomSheet( context: ctx, @@ -182,66 +184,71 @@ class _MobileTerminalScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (sheetCtx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: MobileColors.border, - borderRadius: BorderRadius.circular(2), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: MobileTokens.space4, - vertical: MobileTokens.space3, - ), - child: Text( - 'Connect to host', - style: mobileBody(size: 16, weight: FontWeight.w600), + builder: (sheetCtx) { + // Read hosts inside the builder so the list is live while the sheet + // is open (not a stale snapshot captured before the sheet opened). + final hosts = sheetCtx.read().allHosts; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), ), - ), - if (hosts.isEmpty) Padding( - padding: const EdgeInsets.only(bottom: MobileTokens.space4), + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space3, + ), child: Text( - 'No hosts configured', - style: mobileBody( - size: 14, color: MobileColors.textMuted), + 'Connect to host', + style: mobileBody(size: 16, weight: FontWeight.w600), ), - ) - else - ListView.builder( - shrinkWrap: true, - itemCount: hosts.length, - itemBuilder: (_, i) { - final host = hosts[i]; - return ListTile( - leading: const Icon(Icons.dns_outlined, - color: MobileColors.textPrimary), - title: Text(host.label, - style: mobileBody( - size: 15, color: MobileColors.textPrimary)), - subtitle: Text( - '${host.username}@${host.host}', - style: mobileMono( - size: 12, color: MobileColors.textMuted), - ), - onTap: () { - Navigator.pop(sheetCtx); - ctx.read().connectAny(host); - }, - ); - }, ), - const SizedBox(height: MobileTokens.space2), - ], - ), - ), + if (hosts.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: MobileTokens.space4), + child: Text( + 'No hosts configured', + style: mobileBody( + size: 14, color: MobileColors.textMuted), + ), + ) + else + ListView.builder( + shrinkWrap: true, + itemCount: hosts.length, + itemBuilder: (_, i) { + final host = hosts[i]; + return ListTile( + leading: const Icon(Icons.dns_outlined, + color: MobileColors.textPrimary), + title: Text(host.label, + style: mobileBody( + size: 15, color: MobileColors.textPrimary)), + subtitle: Text( + '${host.username}@${host.host}', + style: mobileMono( + size: 12, color: MobileColors.textMuted), + ), + onTap: () { + Navigator.pop(sheetCtx); + sheetCtx.read().connectAny(host); + }, + ); + }, + ), + const SizedBox(height: MobileTokens.space2), + ], + ), + ); + }, ); } diff --git a/app/test/mobile/terminal_nav_test.dart b/app/test/mobile/terminal_nav_test.dart index de2c0776..9f99f4f8 100644 --- a/app/test/mobile/terminal_nav_test.dart +++ b/app/test/mobile/terminal_nav_test.dart @@ -22,14 +22,22 @@ import 'package:yourssh/services/tab_metadata_service.dart'; // --------------------------------------------------------------------------- class _FakeSessionProvider extends SessionProvider { - final List _injected; + final List _sessions; int connectAnyCalls = 0; - _FakeSessionProvider(this._injected) + _FakeSessionProvider(this._sessions) : super(SshService(StorageService()), TabMetadataService()); @override - List get sshSessions => List.unmodifiable(_injected); + List get sshSessions => List.unmodifiable(_sessions); + + /// Mutate the injected list and notify so [didChangeDependencies] fires. + void setSessions(List updated) { + _sessions + ..clear() + ..addAll(updated); + notifyListeners(); + } /// Override the real connectAny so no SSH dial happens. @override @@ -57,7 +65,7 @@ Future<({_FakeSessionProvider sp, HostProvider hp})> _pump( } final sp = _FakeSessionProvider(sessions); - final probe = HostReachabilityProbe(connector: (_, __, ___) async {}); + final probe = HostReachabilityProbe(connector: (_, _, _) async {}); final settings = SettingsProvider(); await tester.pumpWidget( @@ -170,4 +178,66 @@ void main() { // A bottom sheet should appear with 'Connect to host' heading. expect(find.text('Connect to host'), findsOneWidget); }); + + // --------------------------------------------------------------------------- + // T5: closing the LAST session pops Terminal (pop-on-empty works) + // --------------------------------------------------------------------------- + testWidgets('closing last session pops Terminal screen', (tester) async { + final host = Host(label: 'prod', host: '10.0.0.1', username: 'root'); + final session = SshSession(host: host); + + final result = await _pump(tester, hosts: [host], sessions: [session]); + + // Navigate to Terminal. + await tester.tap(find.byType(HostCard)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(MobileTerminalScreen), findsOneWidget); + + // Drain sessions — simulates the last session being closed. + result.sp.setSessions([]); + // Let didChangeDependencies fire and schedule the postFrameCallback. + await tester.pump(); + // Let the postFrameCallback fire (pop-on-empty re-check runs here). + await tester.pump(); + // Let Navigator complete the pop transition. + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + + // Terminal screen should have been popped. + expect(find.byType(MobileHostsScreen), findsOneWidget); + expect(find.byType(MobileTerminalScreen), findsNothing); + }); + + // --------------------------------------------------------------------------- + // T6: no spurious pop when sessions go empty then non-empty in same frame + // --------------------------------------------------------------------------- + testWidgets( + 'no spurious pop when session added before postFrameCallback fires', + (tester) async { + final host = Host(label: 'prod', host: '10.0.0.1', username: 'root'); + final session = SshSession(host: host); + final session2 = SshSession(host: host); + + final result = await _pump(tester, hosts: [host], sessions: [session]); + + // Navigate to Terminal. + await tester.tap(find.byType(HostCard)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(MobileTerminalScreen), findsOneWidget); + + // Drain sessions → schedules the postFrameCallback (sets _popScheduled=true). + result.sp.setSessions([]); + await tester.pump(); // didChangeDependencies fires, callback queued + + // Before the callback fires, add a new session back. + result.sp.setSessions([session2]); + await tester.pump(); // postFrameCallback fires — re-checks, sees non-empty + + await tester.pump(const Duration(milliseconds: 300)); + + // Terminal must still be visible — no spurious pop. + expect(find.byType(MobileTerminalScreen), findsOneWidget); + }); } From 4f51caf15455546d00175fe23f0a91ee9b156528 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:03:02 +0700 Subject: [PATCH 80/96] feat(mobile): redesigned SFTP files browser (contextual) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MobileSftpScreen is now contextual: accepts a required Host param instead of reading SessionProvider.activeSshSession. Design 04 applied: header with back + "Files / " + upload + ⋮; breadcrumb row (home icon, mono segments, current bold); meta row with item count, total size, and cycling sort control (Name/Size ↑↓); list rows with typed icons (folder=blue, dotfiles/secrets=lock yellow, archives/images/code), mono names for dotfiles, file sizes in mono, chevrons for dirs, hairline dividers inset 47px. SFTP browsing logic (single reusable SftpClient, load-token, listdir, chunked download, SftpTransferService upload) preserved unchanged. Terminal ⋮ "Files" entry now calls onOpenFiles(host) with the active session's host; MobileHostsScreen passes a callback that pushes MobileSftpScreen(host). --- .../mobile/screens/mobile_hosts_screen.dart | 19 +- .../mobile/screens/mobile_sftp_screen.dart | 689 +++++++++++++----- .../screens/mobile_terminal_screen.dart | 12 +- app/test/mobile/mobile_sftp_screen_test.dart | 32 +- app/test/mobile/screens/sftp_screen_test.dart | 124 ++++ 5 files changed, 700 insertions(+), 176 deletions(-) create mode 100644 app/test/mobile/screens/sftp_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index d1b6b78f..4919c51c 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -15,6 +15,7 @@ import '../widgets/section_header.dart'; import '../widgets/status_dot.dart'; import '../widgets/tag_chip.dart'; import 'mobile_add_host_screen.dart'; +import 'mobile_sftp_screen.dart'; import 'mobile_terminal_screen.dart'; /// Hosts tab: header + search + folder chips + tag-grouped list + amber FAB. @@ -341,7 +342,10 @@ class _MobileHostsScreenState extends State { if (!mounted) return; await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => MobileTerminalScreen(focusSessionId: sessionId), + builder: (ctx) => MobileTerminalScreen( + focusSessionId: sessionId, + onOpenFiles: (h) => _pushSftp(ctx, h), + ), ), ); return; @@ -359,7 +363,18 @@ class _MobileHostsScreenState extends State { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => MobileTerminalScreen(focusSessionId: sessionId), + builder: (ctx) => MobileTerminalScreen( + focusSessionId: sessionId, + onOpenFiles: (h) => _pushSftp(ctx, h), + ), + ), + ); + } + + void _pushSftp(BuildContext ctx, Host host) { + Navigator.of(ctx).push( + MaterialPageRoute( + builder: (_) => MobileSftpScreen(host: host), ), ); } diff --git a/app/lib/mobile/screens/mobile_sftp_screen.dart b/app/lib/mobile/screens/mobile_sftp_screen.dart index 1bdcaa88..d3338f90 100644 --- a/app/lib/mobile/screens/mobile_sftp_screen.dart +++ b/app/lib/mobile/screens/mobile_sftp_screen.dart @@ -7,72 +7,90 @@ import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import '../../models/host.dart'; -import '../../providers/session_provider.dart'; import '../../services/sftp_transfer_service.dart'; import '../../services/ssh_service.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../theme/mobile_tokens.dart'; -/// Single-panel SFTP browser over the active SSH session's host. Lists, opens -/// directories, downloads files to a picked folder, and uploads a picked file. -/// One SFTP channel is held per host and reused for all operations. +/// Contextual SFTP browser — reached from the Terminal ⋮ menu for the +/// currently active session's host. One SFTP channel is reused for all +/// operations; a load-token prevents stale results from a slow listdir +/// overwriting a newer navigation. class MobileSftpScreen extends StatefulWidget { - const MobileSftpScreen({super.key}); + final Host host; + + /// Optional override for directory listing — injected in tests so the widget + /// tree can be pumped without a real SSH connection. + final Future> Function(String path)? lister; + + const MobileSftpScreen({ + super.key, + required this.host, + this.lister, + }); @override State createState() => _MobileSftpScreenState(); } +enum _SortField { name, size } + class _MobileSftpScreenState extends State { String _path = '.'; List _entries = []; bool _loading = false; String? _error; - String? _loadedHostId; // One reusable SFTP channel per host (opening one per listdir/download adds a // full channel-handshake RTT to every tap on a mobile link). SftpClient? _sftp; - String? _sftpHostId; - // Monotonic token so a slow listdir from a previous host can't overwrite the - // current view when the user switches sessions mid-load. + // Monotonic token so a slow listdir from a previous path can't overwrite the + // current view when the user navigates quickly. int _loadToken = 0; + _SortField _sortField = _SortField.name; + bool _sortAscending = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load('.')); + } + @override void dispose() { _sftp?.close(); super.dispose(); } - Future _client(Host host) async { - if (_sftp != null && _sftpHostId == host.id) return _sftp!; - _sftp?.close(); - _sftp = await context.read().openSftp(host); - _sftpHostId = host.id; + Future _client() async { + if (_sftp != null) return _sftp!; + _sftp = await context.read().openSftp(widget.host); return _sftp!; } - Future _load(Host host, String path) async { + Future> _listDir(String path) async { + if (widget.lister != null) return widget.lister!(path); + final sftp = await _client(); + return sftp.listdir(path); + } + + Future _load(String path) async { final token = ++_loadToken; setState(() { _loading = true; _error = null; }); try { - final sftp = await _client(host); - final raw = await sftp.listdir(path); - raw.sort((a, b) { - final ad = a.attr.isDirectory, bd = b.attr.isDirectory; - if (ad != bd) return ad ? -1 : 1; - // Case-insensitive, matching the desktop SFTP ordering. - return a.filename.toLowerCase().compareTo(b.filename.toLowerCase()); - }); - if (!mounted || token != _loadToken) return; // a newer load supersedes us + final raw = await _listDir(path); + if (!mounted || token != _loadToken) return; + final entries = raw + .where((e) => e.filename != '.' && e.filename != '..') + .toList(); + _sortEntries(entries); setState(() { _path = path; - _entries = raw - .where((e) => e.filename != '.' && e.filename != '..') - .toList(); + _entries = entries; _loading = false; }); } catch (e) { @@ -85,160 +103,101 @@ class _MobileSftpScreenState extends State { } } - void _maybeLoad(Host host) { - if (_loadedHostId == host.id) return; - _loadedHostId = host.id; - _path = '.'; - WidgetsBinding.instance.addPostFrameCallback((_) => _load(host, '.')); + void _sortEntries(List list) { + list.sort((a, b) { + final ad = a.attr.isDirectory, bd = b.attr.isDirectory; + // Directories always first regardless of sort field. + if (ad != bd) return ad ? -1 : 1; + int cmp; + if (_sortField == _SortField.size) { + cmp = (a.attr.size ?? 0).compareTo(b.attr.size ?? 0); + } else { + cmp = a.filename.toLowerCase().compareTo(b.filename.toLowerCase()); + } + return _sortAscending ? cmp : -cmp; + }); } - @override - Widget build(BuildContext context) { - final active = context.watch().activeSshSession; - if (active == null) { - return const Scaffold( - backgroundColor: AppColors.bg, - body: Center( - child: Text('Connect a host to browse files', - style: TextStyle(color: AppColors.textSecondary)), - ), - ); - } - final host = active.host; - _maybeLoad(host); - - return Scaffold( - backgroundColor: AppColors.bg, - floatingActionButton: FloatingActionButton( - onPressed: () => _upload(host), - backgroundColor: AppColors.accent, - child: const Icon(Icons.upload_file, color: Colors.black), - ), - body: SafeArea( - child: Column( - children: [ - _bar(host), - if (_error != null) - Padding( - padding: const EdgeInsets.all(12), - child: Text(_error!, - style: const TextStyle(color: AppColors.red, fontSize: 12)), - ), - Expanded( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : ListView.builder( - itemCount: _entries.length, - itemBuilder: (_, i) => _row(host, _entries[i]), - ), - ), - ], - ), - ), - ); + void _applySort() { + final sorted = List.from(_entries); + _sortEntries(sorted); + setState(() => _entries = sorted); } - Widget _bar(Host host) { - final parts = _path == '.' ? [] : _path.split('/').where((s) => s.isNotEmpty).toList(); - return Container( - padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2, vertical: MobileTokens.space2), - color: AppColors.card, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_upward, color: AppColors.textSecondary), - onPressed: _path == '.' || _path == '/' - ? null - : () => _load(host, p.posix.dirname(_path)), - ), - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - reverse: true, - child: Row( - children: [ - _crumb(host, 'home', _path == '.' ? null : () => _load(host, '.')), - for (var i = 0; i < parts.length; i++) ...[ - const Icon(Icons.chevron_right, size: 16, color: AppColors.textTertiary), - _crumb( - host, - parts[i], - i == parts.length - 1 - ? null - : () => _load(host, '/${parts.sublist(0, i + 1).join('/')}'), - ), - ], - ], - ), - ), - ), - IconButton( - icon: const Icon(Icons.refresh, color: AppColors.textSecondary), - onPressed: () => _load(host, _path), - ), - ], - ), - ); - } + // ── Breadcrumb helpers ──────────────────────────────────────────────────── - Widget _crumb(Host host, String label, VoidCallback? onTap) { - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: Text(label, - style: TextStyle( - color: onTap == null ? AppColors.textPrimary : AppColors.accent, - fontSize: 13)), - ), - ); - } + List get _parts => + _path == '.' ? [] : _path.split('/').where((s) => s.isNotEmpty).toList(); + + String _join(String name) => + _path == '.' ? name : p.posix.join(_path, name); + + // ── Size formatting ─────────────────────────────────────────────────────── static String _fmtSize(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + } return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; } + static String _totalSize(List entries) { + final total = + entries.fold(0, (s, e) => s + (e.attr.size ?? 0)); + return _fmtSize(total); + } + + // ── Icon selection ──────────────────────────────────────────────────────── + IconData _iconFor(SftpName e) { - if (e.attr.isDirectory) return Icons.folder; - final name = e.filename.toLowerCase(); - if (RegExp(r'\.(zip|tar|gz|tgz|bz2|xz|7z|rar)$').hasMatch(name)) return Icons.folder_zip; - if (RegExp(r'\.(png|jpe?g|gif|webp|svg|bmp)$').hasMatch(name)) return Icons.image; - if (RegExp(r'\.(sh|bash|zsh|py|js|ts|go|rs|c|cpp|dart|rb|json|ya?ml|toml|conf)$').hasMatch(name)) return Icons.description; - return Icons.insert_drive_file; + final name = e.filename; + final lower = name.toLowerCase(); + if (e.attr.isDirectory) return Icons.folder_rounded; + // Sensitive / dot-files get a lock glyph. + if (name.startsWith('.') || lower.contains('secret') || + lower.contains('credential') || lower.contains('passwd') || + lower.endsWith('.pem') || lower.endsWith('.key') || + lower.endsWith('.env')) { + return Icons.lock_outline; + } + if (RegExp(r'\.(zip|tar|gz|tgz|bz2|xz|7z|rar)$').hasMatch(lower)) { + return Icons.folder_zip_outlined; + } + if (RegExp(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$').hasMatch(lower)) { + return Icons.image_outlined; + } + if (RegExp(r'\.(mp4|mov|avi|mkv|webm)$').hasMatch(lower)) { + return Icons.videocam_outlined; + } + if (RegExp(r'\.(sh|bash|zsh|py|js|ts|go|rs|c|cpp|dart|rb|json|ya?ml|toml|conf|ini|cfg)$') + .hasMatch(lower)) { + return Icons.description_outlined; + } + return Icons.insert_drive_file_outlined; } - Widget _row(Host host, SftpName e) { - final isDir = e.attr.isDirectory; - return ListTile( - leading: Icon(_iconFor(e), color: isDir ? AppColors.accent : AppColors.textSecondary), - title: Text(e.filename, style: const TextStyle(color: AppColors.textPrimary)), - subtitle: isDir - ? null - : Text(_fmtSize(e.attr.size ?? 0), - style: const TextStyle(color: AppColors.textSecondary, fontSize: 11)), - trailing: isDir - ? const Icon(Icons.chevron_right, color: AppColors.textTertiary) - : IconButton( - icon: const Icon(Icons.download, color: AppColors.textSecondary), - onPressed: () => _download(host, e), - ), - onTap: isDir ? () => _load(host, _join(e.filename)) : null, - ); + Color _iconColor(SftpName e) { + if (e.attr.isDirectory) return MobileColors.blue; + final name = e.filename.toLowerCase(); + if (e.filename.startsWith('.') || name.endsWith('.pem') || + name.endsWith('.key') || name.endsWith('.env') || + name.contains('secret') || name.contains('passwd')) { + return MobileColors.yellow; + } + return MobileColors.textMuted; } - String _join(String name) => _path == '.' ? name : p.posix.join(_path, name); + // ── Transfer ops ────────────────────────────────────────────────────────── - Future _download(Host host, SftpName e) async { + Future _download(SftpName e) async { final dir = await FilePicker.platform.getDirectoryPath(); if (dir == null) return; final sink = io.File(p.join(dir, e.filename)).openWrite(); try { - final sftp = await _client(host); + final sftp = await _client(); final file = await sftp.open(_join(e.filename)); - // Stream chunks to disk instead of buffering the whole file in memory. await for (final chunk in file.read()) { sink.add(chunk); } @@ -251,7 +210,7 @@ class _MobileSftpScreenState extends State { } } - Future _upload(Host host) async { + Future _upload() async { final transfer = context.read(); final picked = await FilePicker.platform.pickFiles(); final localPath = @@ -259,9 +218,9 @@ class _MobileSftpScreenState extends State { if (localPath == null) return; final name = p.basename(localPath); try { - await transfer.uploadFile(host, localPath, _join(name)); + await transfer.uploadFile(widget.host, localPath, _join(name)); _snack('Uploaded $name'); - _load(host, _path); + _load(_path); } catch (err) { _snack('Upload failed: $err'); } @@ -269,7 +228,411 @@ class _MobileSftpScreenState extends State { void _snack(String msg) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); } } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: MobileColors.bg, + body: SafeArea( + child: Column( + children: [ + _Header( + host: widget.host, + onBack: () => Navigator.maybePop(context), + onUpload: _upload, + onRefresh: () => _load(_path), + ), + _BreadcrumbRow( + parts: _parts, + onHome: _parts.isEmpty ? null : () => _load('.'), + onSegment: (i) => + _load('/${_parts.sublist(0, i + 1).join('/')}'), + ), + _MetaRow( + count: _entries.length, + totalSize: _totalSize(_entries), + sortField: _sortField, + ascending: _sortAscending, + onSort: (field, asc) { + setState(() { + _sortField = field; + _sortAscending = asc; + }); + _applySort(); + }, + ), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space2), + child: Text( + _error!, + style: mobileBody(size: 12, color: MobileColors.red), + ), + ), + Expanded( + child: _loading + ? const Center( + child: CircularProgressIndicator( + color: MobileColors.accent)) + : _entries.isEmpty + ? Center( + child: Text( + 'Empty folder', + style: + mobileBody(color: MobileColors.textMuted), + ), + ) + : ListView.separated( + itemCount: _entries.length, + separatorBuilder: (context, i) => Padding( + padding: + const EdgeInsets.only(left: 47), + child: Divider( + height: 1, + thickness: 0.5, + color: MobileColors.border, + ), + ), + itemBuilder: (_, i) => _EntryRow( + entry: _entries[i], + icon: _iconFor(_entries[i]), + iconColor: _iconColor(_entries[i]), + onTap: _entries[i].attr.isDirectory + ? () => + _load(_join(_entries[i].filename)) + : null, + onDownload: _entries[i].attr.isDirectory + ? null + : () => _download(_entries[i]), + ), + ), + ), + ], + ), + ), + ); + } +} + +// ── Header ───────────────────────────────────────────────────────────────── + +class _Header extends StatelessWidget { + final Host host; + final VoidCallback onBack; + final VoidCallback onUpload; + final VoidCallback onRefresh; + + const _Header({ + required this.host, + required this.onBack, + required this.onUpload, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + color: MobileColors.surfaceAlt, + child: Row( + children: [ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: MobileColors.accent, size: 20), + onPressed: onBack, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Files', + style: mobileBody( + size: 11, + color: MobileColors.textMuted, + weight: FontWeight.w500), + ), + Text( + host.label, + style: mobileBody( + size: 14, weight: FontWeight.w600), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.upload_file_outlined, + color: MobileColors.textMuted, size: 20), + tooltip: 'Upload', + onPressed: onUpload, + ), + IconButton( + icon: const Icon(Icons.more_vert, + color: MobileColors.textMuted, size: 20), + tooltip: 'More', + onPressed: () => _showMenu(context), + ), + const SizedBox(width: 4), + ], + ), + ); + } + + void _showMenu(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: MobileColors.surfaceAlt, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + ListTile( + leading: const Icon(Icons.refresh, + color: MobileColors.textPrimary), + title: Text('Refresh', + style: mobileBody(color: MobileColors.textPrimary)), + onTap: () { + Navigator.pop(context); + onRefresh(); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} + +// ── Breadcrumb row ────────────────────────────────────────────────────────── + +class _BreadcrumbRow extends StatelessWidget { + final List parts; + final VoidCallback? onHome; + final void Function(int segmentIndex) onSegment; + + const _BreadcrumbRow({ + required this.parts, + required this.onHome, + required this.onSegment, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 38, + color: MobileColors.surfaceAlt, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space4), + child: Row( + children: [ + // Home icon — tappable when not at root. + GestureDetector( + onTap: onHome, + child: Icon( + Icons.home_outlined, + size: 16, + color: parts.isEmpty + ? MobileColors.textPrimary + : MobileColors.accent, + ), + ), + for (var i = 0; i < parts.length; i++) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon(Icons.chevron_right, + size: 14, color: MobileColors.textFaint), + ), + GestureDetector( + onTap: i == parts.length - 1 + ? null + : () => onSegment(i), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text( + parts[i], + style: mobileMono( + size: 13, + color: i == parts.length - 1 + ? MobileColors.textPrimary + : MobileColors.accent, + weight: i == parts.length - 1 + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +// ── Meta row (count + sort) ───────────────────────────────────────────────── + +class _MetaRow extends StatelessWidget { + final int count; + final String totalSize; + final _SortField sortField; + final bool ascending; + final void Function(_SortField field, bool ascending) onSort; + + const _MetaRow({ + required this.count, + required this.totalSize, + required this.sortField, + required this.ascending, + required this.onSort, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 36, + color: MobileColors.surfaceAlt, + padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space4), + child: Row( + children: [ + Expanded( + child: Text( + '$count item${count == 1 ? '' : 's'} · $totalSize', + style: mobileMono(size: 11, color: MobileColors.textMuted), + ), + ), + GestureDetector( + onTap: () { + // Cycle: name↑ → name↓ → size↑ → size↓ → name↑ + if (sortField == _SortField.name && ascending) { + onSort(_SortField.name, false); + } else if (sortField == _SortField.name && !ascending) { + onSort(_SortField.size, true); + } else if (sortField == _SortField.size && ascending) { + onSort(_SortField.size, false); + } else { + onSort(_SortField.name, true); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + sortField == _SortField.name ? 'Name' : 'Size', + style: mobileBody( + size: 12, color: MobileColors.textMuted), + ), + const SizedBox(width: 2), + Icon( + ascending + ? Icons.arrow_upward + : Icons.arrow_downward, + size: 12, + color: MobileColors.textMuted, + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Entry row ─────────────────────────────────────────────────────────────── + +class _EntryRow extends StatelessWidget { + final SftpName entry; + final IconData icon; + final Color iconColor; + final VoidCallback? onTap; + final VoidCallback? onDownload; + + const _EntryRow({ + required this.entry, + required this.icon, + required this.iconColor, + this.onTap, + this.onDownload, + }); + + @override + Widget build(BuildContext context) { + final isDir = entry.attr.isDirectory; + final isDotFile = entry.filename.startsWith('.'); + final nameStyle = isDotFile + ? mobileMono(size: 14, color: MobileColors.textPrimary) + : mobileBody(size: 14, color: MobileColors.textPrimary); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, vertical: MobileTokens.space3), + child: Row( + children: [ + // Icon column — fixed width so names align. + SizedBox( + width: 32, + child: Icon(icon, size: 22, color: iconColor), + ), + const SizedBox(width: MobileTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.filename, style: nameStyle), + if (!isDir && entry.attr.size != null) ...[ + const SizedBox(height: 2), + Text( + _MobileSftpScreenState._fmtSize(entry.attr.size!), + style: mobileMono( + size: 11, color: MobileColors.textMuted), + ), + ], + ], + ), + ), + if (isDir) + const Icon(Icons.chevron_right, + size: 18, color: MobileColors.textFaint) + else if (onDownload != null) + GestureDetector( + onTap: onDownload, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.download_outlined, + size: 18, color: MobileColors.textMuted), + ), + ), + ], + ), + ), + ); + } } diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart index aba3c6ab..cc9cd887 100644 --- a/app/lib/mobile/screens/mobile_terminal_screen.dart +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xterm/xterm.dart'; +import '../../models/host.dart'; import '../../models/ssh_session.dart'; import '../../providers/host_provider.dart'; import '../../providers/session_provider.dart'; @@ -25,7 +26,10 @@ import '../util/mobile_prefs.dart'; /// SnackBar as an interim stub. class MobileTerminalScreen extends StatefulWidget { final String? focusSessionId; - final VoidCallback? onOpenFiles; + + /// Called when the user taps "Files" in the ⋮ menu. Receives the active + /// session's [Host] so the caller can open the contextual SFTP screen. + final void Function(Host host)? onOpenFiles; final VoidCallback? onOpenPortForward; const MobileTerminalScreen({ @@ -141,9 +145,9 @@ class _MobileTerminalScreenState extends State { style: mobileBody(color: MobileColors.textPrimary)), onTap: () { Navigator.pop(context); - if (widget.onOpenFiles != null) { - widget.onOpenFiles!(); - } else { + if (widget.onOpenFiles != null && active != null) { + widget.onOpenFiles!(active.host); + } else if (widget.onOpenFiles == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Files — coming soon')), ); diff --git a/app/test/mobile/mobile_sftp_screen_test.dart b/app/test/mobile/mobile_sftp_screen_test.dart index ed750d26..4a0abe27 100644 --- a/app/test/mobile/mobile_sftp_screen_test.dart +++ b/app/test/mobile/mobile_sftp_screen_test.dart @@ -3,27 +3,45 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_sftp_screen.dart'; -import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/sftp_transfer_service.dart'; import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; -import 'package:yourssh/services/tab_metadata_service.dart'; + +// Updated after Task 13 redesign: MobileSftpScreen is now contextual and +// requires a [Host]. The full smoke-test suite lives in +// test/mobile/screens/sftp_screen_test.dart. void main() { setUp(() => SharedPreferences.setMockInitialValues({})); - testWidgets('shows connect prompt with no SSH session', (tester) async { + testWidgets('shows empty folder message with empty listing', (tester) async { final storage = StorageService(); final ssh = SshService(storage); - final sp = SessionProvider(ssh, TabMetadataService()); + final transfer = SftpTransferService(ssh); + final host = Host( + id: 'h1', + label: 'web-01', + host: '10.0.0.1', + port: 22, + username: 'deploy', + ); + await tester.pumpWidget(MaterialApp( home: MultiProvider( providers: [ - ChangeNotifierProvider.value(value: sp), Provider.value(value: ssh), + Provider.value(value: transfer), ], - child: const MobileSftpScreen(), + child: MobileSftpScreen( + host: host, + lister: (_) async => [], + ), ), )); - expect(find.textContaining('Connect a host'), findsOneWidget); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.textContaining('Empty'), findsOneWidget); }); } diff --git a/app/test/mobile/screens/sftp_screen_test.dart b/app/test/mobile/screens/sftp_screen_test.dart new file mode 100644 index 00000000..ee43af61 --- /dev/null +++ b/app/test/mobile/screens/sftp_screen_test.dart @@ -0,0 +1,124 @@ +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:yourssh/mobile/screens/mobile_sftp_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/sftp_transfer_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +// --------------------------------------------------------------------------- +// Fake entry builders — use SftpFileAttrs / SftpFileMode named constructors. +// --------------------------------------------------------------------------- + +// Raw mode flags (not exported from dartssh2's private _ModeFlags): +// 0x4000 = S_IFDIR, 0x8000 = S_IFREG, 0x1ED = 0755, 0x1A4 = 0644 +SftpName _dir(String name) => SftpName( + filename: name, + longname: name, + attr: SftpFileAttrs( + mode: SftpFileMode.value(0x4000 | 0x1ed), // S_IFDIR | 0755 + ), + ); + +SftpName _file(String name, int size) => SftpName( + filename: name, + longname: name, + attr: SftpFileAttrs( + size: size, + mode: SftpFileMode.value(0x8000 | 0x1a4), // S_IFREG | 0644 + ), + ); + +// --------------------------------------------------------------------------- +// Test host + pump helper +// --------------------------------------------------------------------------- + +final _host = Host( + id: 'h1', + label: 'web-01', + host: '10.0.0.1', + port: 22, + username: 'deploy', +); + +Future _pump( + WidgetTester tester, { + required List entries, +}) async { + final storage = StorageService(); + final ssh = SshService(storage); + final transfer = SftpTransferService(ssh); + + await tester.pumpWidget( + MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + Provider.value(value: ssh), + Provider.value(value: transfer), + ], + // Inject a synchronous lister so no real SSH is needed. + child: MobileSftpScreen( + host: _host, + lister: (_) async => entries, + ), + ), + ), + ); + // Trigger post-frame callback + async lister. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('header shows "Files" label and host name', (tester) async { + await _pump(tester, entries: []); + expect(find.text('Files'), findsAtLeastNWidgets(1)); + expect(find.text('web-01'), findsAtLeastNWidgets(1)); + }); + + testWidgets('breadcrumb shows home icon at root', (tester) async { + await _pump(tester, entries: []); + expect(find.byIcon(Icons.home_outlined), findsAtLeastNWidgets(1)); + }); + + testWidgets('renders folder and file names in listing', (tester) async { + await _pump(tester, entries: [ + _dir('var'), + _dir('.hidden_dir'), + _file('README.md', 1024), + _file('.env', 512), + ]); + expect(find.text('var'), findsOneWidget); + expect(find.text('.hidden_dir'), findsOneWidget); + expect(find.text('README.md'), findsOneWidget); + expect(find.text('.env'), findsOneWidget); + }); + + testWidgets('renders file size in KB', (tester) async { + await _pump(tester, entries: [_file('data.bin', 2048)]); + expect(find.textContaining('KB'), findsAtLeastNWidgets(1)); + }); + + testWidgets('folder row shows chevron (navigable indicator)', (tester) async { + await _pump(tester, entries: [_dir('etc')]); + expect(find.byIcon(Icons.chevron_right), findsAtLeastNWidgets(1)); + }); + + testWidgets('sort control row shows "Name" label', (tester) async { + await _pump(tester, entries: []); + expect(find.text('Name'), findsAtLeastNWidgets(1)); + }); + + testWidgets('item count row shows correct count', (tester) async { + await _pump(tester, entries: [_dir('a'), _file('b.txt', 100)]); + expect(find.textContaining('2 items'), findsAtLeastNWidgets(1)); + }); +} From bd58f360c2d2a0746217e08aaf0b2f4052ce6a4a Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:07:02 +0700 Subject: [PATCH 81/96] feat(mobile): wire port-forward, snippets, key-gen into bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PortForwardProvider + PortForwardService to MobileBootstrap, mirroring the desktop wiring in main.dart: onStatus/onConnections callbacks feed the provider, autoStartAll fires after provider.ready, and onHostDeleted tears down tunnels. SnippetProvider and KeyGenService were already present. Providers list grows from 11 → 13 entries. --- app/lib/mobile/mobile_bootstrap.dart | 22 ++++++++++++++++++++++ app/test/mobile/mobile_bootstrap_test.dart | 15 +++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/app/lib/mobile/mobile_bootstrap.dart b/app/lib/mobile/mobile_bootstrap.dart index ac32dc93..2c83a03e 100644 --- a/app/lib/mobile/mobile_bootstrap.dart +++ b/app/lib/mobile/mobile_bootstrap.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; import 'package:yourssh_snippets/yourssh_snippets.dart'; @@ -5,9 +7,11 @@ import 'package:yourssh_snippets/yourssh_snippets.dart'; import '../providers/host_provider.dart'; import '../providers/key_provider.dart'; import '../providers/known_hosts_provider.dart'; +import '../providers/port_forward_provider.dart'; import '../providers/session_provider.dart'; import '../providers/settings_provider.dart'; import '../providers/sync_provider.dart'; +import '../services/port_forward_service.dart'; import '../services/sftp_transfer_service.dart'; import '../services/ssh_service.dart'; import '../services/storage_service.dart'; @@ -30,6 +34,8 @@ class MobileBootstrap { late final SyncService syncService; late final SnippetProvider snippets; late final SftpTransferService transfer; + late final PortForwardProvider portForwardProvider; + late final PortForwardService portForwardService; MobileBootstrap() { storage = StorageService(); @@ -43,6 +49,20 @@ class MobileBootstrap { syncService = SyncService(sync); snippets = SnippetProvider(); transfer = SftpTransferService(ssh); + portForwardProvider = PortForwardProvider(); + portForwardService = PortForwardService( + acquireTransport: (host) async => + SshTunnelTransport(await ssh.ensureClient(host)), + resolveHost: (id) => + hostProvider.allHosts.where((h) => h.id == id).firstOrNull, + onStatus: (id, status, {error}) => + portForwardProvider.setStatus(id, status, error: error), + onConnections: (id, n) => portForwardProvider.setConnections(id, n), + ); + hostProvider.onHostDeleted = + (id) => unawaited(portForwardService.stopForHost(id)); + unawaited(portForwardProvider.ready.then( + (_) => portForwardService.autoStartAll(portForwardProvider.forwards))); _wire(); } @@ -75,6 +95,8 @@ class MobileBootstrap { Provider.value(value: syncService), ChangeNotifierProvider.value(value: snippets), Provider.value(value: transfer), + ChangeNotifierProvider.value(value: portForwardProvider), + Provider.value(value: portForwardService), ]; /// Disposes the objects exposed via `Provider.value` (which does not dispose diff --git a/app/test/mobile/mobile_bootstrap_test.dart b/app/test/mobile/mobile_bootstrap_test.dart index 1511674d..0c48b8cf 100644 --- a/app/test/mobile/mobile_bootstrap_test.dart +++ b/app/test/mobile/mobile_bootstrap_test.dart @@ -31,4 +31,19 @@ void main() { expect(b.snippets, isNotNull); expect(b.transfer, isNotNull); }); + + test('exposes port-forward provider + service', () { + final b = MobileBootstrap(); + expect(b.portForwardProvider, isNotNull); + expect(b.portForwardService, isNotNull); + }); + + test('port-forward provider is in the providers list', () { + final b = MobileBootstrap(); + // providers list must be non-empty and include entries for the new services + // (checked indirectly: the list grew by at least 1 vs the known-good count + // from before Task-14 — easier to just confirm the fields exist and the + // list length is reasonable). + expect(b.providers.length, greaterThanOrEqualTo(13)); + }); } From 7e283e15d7f7ab97e44377b7126d53fbcc77a916 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:09:54 +0700 Subject: [PATCH 82/96] test(mobile): assert bootstrap providers resolve from child context Add a testWidgets test that pumps the real provider tree and calls Provider.of from a child BuildContext, catching type-param or Provider.value vs ChangeNotifierProvider.value mismatches that field-access checks cannot detect. Remove the misleading comment that discouraged the real test in favour of the indirect list-length check. --- app/test/mobile/mobile_bootstrap_test.dart | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/app/test/mobile/mobile_bootstrap_test.dart b/app/test/mobile/mobile_bootstrap_test.dart index 0c48b8cf..ab998d69 100644 --- a/app/test/mobile/mobile_bootstrap_test.dart +++ b/app/test/mobile/mobile_bootstrap_test.dart @@ -1,6 +1,10 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/mobile_bootstrap.dart'; +import 'package:yourssh/providers/port_forward_provider.dart'; +import 'package:yourssh_snippets/yourssh_snippets.dart'; void main() { setUp(() => SharedPreferences.setMockInitialValues({})); @@ -40,10 +44,24 @@ void main() { test('port-forward provider is in the providers list', () { final b = MobileBootstrap(); - // providers list must be non-empty and include entries for the new services - // (checked indirectly: the list grew by at least 1 vs the known-good count - // from before Task-14 — easier to just confirm the fields exist and the - // list length is reasonable). expect(b.providers.length, greaterThanOrEqualTo(13)); }); + + testWidgets('providers resolve from a child context', (tester) async { + final b = MobileBootstrap(); + late BuildContext childCtx; + await tester.pumpWidget(MultiProvider( + providers: b.providers, + child: Builder(builder: (ctx) { + childCtx = ctx; + return const SizedBox(); + }), + )); + + // These assertions exercise the real Flutter Provider lookup path — a wrong + // type param or Provider.value vs ChangeNotifierProvider.value mismatch + // would throw here even though field-access checks above would pass. + expect(Provider.of(childCtx, listen: false), isNotNull); + expect(Provider.of(childCtx, listen: false), isNotNull); + }); } From b0c8357f9d6a84d597c93ee4cd55fd4a4d9f8481 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:15:17 +0700 Subject: [PATCH 83/96] feat(mobile): port forwarding screen with start/stop --- .../mobile/screens/mobile_hosts_screen.dart | 11 + .../screens/mobile_port_forward_screen.dart | 334 ++++++++++++++++++ .../screens/mobile_terminal_screen.dart | 9 +- app/lib/mobile/widgets/forward_rule_row.dart | 98 +++++ .../screens/port_forward_screen_test.dart | 162 +++++++++ 5 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 app/lib/mobile/screens/mobile_port_forward_screen.dart create mode 100644 app/lib/mobile/widgets/forward_rule_row.dart create mode 100644 app/test/mobile/screens/port_forward_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 4919c51c..23c9e046 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -15,6 +15,7 @@ import '../widgets/section_header.dart'; import '../widgets/status_dot.dart'; import '../widgets/tag_chip.dart'; import 'mobile_add_host_screen.dart'; +import 'mobile_port_forward_screen.dart'; import 'mobile_sftp_screen.dart'; import 'mobile_terminal_screen.dart'; @@ -345,6 +346,7 @@ class _MobileHostsScreenState extends State { builder: (ctx) => MobileTerminalScreen( focusSessionId: sessionId, onOpenFiles: (h) => _pushSftp(ctx, h), + onOpenPortForward: (h) => _pushPortForward(ctx, h), ), ), ); @@ -366,6 +368,7 @@ class _MobileHostsScreenState extends State { builder: (ctx) => MobileTerminalScreen( focusSessionId: sessionId, onOpenFiles: (h) => _pushSftp(ctx, h), + onOpenPortForward: (h) => _pushPortForward(ctx, h), ), ), ); @@ -379,6 +382,14 @@ class _MobileHostsScreenState extends State { ); } + void _pushPortForward(BuildContext ctx, Host host) { + Navigator.of(ctx).push( + MaterialPageRoute( + builder: (_) => MobilePortForwardScreen(host: host), + ), + ); + } + void _pushAddHost() { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const MobileAddHostScreen()), diff --git a/app/lib/mobile/screens/mobile_port_forward_screen.dart b/app/lib/mobile/screens/mobile_port_forward_screen.dart new file mode 100644 index 00000000..7eb07865 --- /dev/null +++ b/app/lib/mobile/screens/mobile_port_forward_screen.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../models/host.dart'; +import '../../models/port_forward.dart'; +import '../../providers/port_forward_provider.dart'; +import '../../services/port_forward_service.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/forward_rule_row.dart'; +import '../widgets/section_header.dart'; + +/// Port-forwarding screen scoped to [host]. Reached from Terminal ⋮ menu. +class MobilePortForwardScreen extends StatelessWidget { + final Host host; + + const MobilePortForwardScreen({super.key, required this.host}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final service = context.read(); + final rules = + provider.forwards.where((f) => f.hostId == host.id).toList(); + + return Scaffold( + backgroundColor: MobileColors.bg, + appBar: AppBar( + backgroundColor: MobileColors.surfaceAlt, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: MobileColors.accent, size: 20), + onPressed: () => Navigator.maybePop(context), + ), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: MobileColors.green, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + host.label, + style: mobileBody(size: 16, weight: FontWeight.w600), + ), + ], + ), + centerTitle: true, + actions: [ + PopupMenuButton( + icon: const Icon(Icons.more_vert, + color: MobileColors.textMuted, size: 20), + itemBuilder: (_) => const [], + ), + ], + elevation: 0, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + 0, + ), + child: SectionHeader('Forwarding'), + ), + Expanded( + child: rules.isEmpty + ? Center( + child: Text( + 'No forwarding rules for this host', + style: mobileBody( + size: 14, color: MobileColors.textMuted), + ), + ) + : ListView.builder( + padding: const EdgeInsets.only( + bottom: MobileTokens.space4 * 4), + itemCount: rules.length, + itemBuilder: (_, i) { + final rule = rules[i]; + final running = service.isRunning(rule.id) || + rule.status == ForwardStatus.active; + return ForwardRuleRow( + rule: rule, + isRunning: running, + onToggle: () { + if (running) { + service.stop(rule.id); + } else { + service.start(rule); + } + }, + ); + }, + ), + ), + // Add forwarding rule button + Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + 0, + MobileTokens.space4, + MobileTokens.space4, + ), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: MobileColors.bg, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusCard), + ), + padding: const EdgeInsets.symmetric( + vertical: MobileTokens.space3), + ), + icon: const Icon(Icons.add, size: 20), + label: Text( + 'Add forwarding rule', + style: mobileBody( + size: 15, + weight: FontWeight.w600, + color: MobileColors.bg), + ), + onPressed: () => _showAddRuleSheet(context), + ), + ), + ), + ], + ), + ); + } + + Future _showAddRuleSheet(BuildContext context) async { + final provider = context.read(); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: MobileColors.surfaceAlt, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => _AddRuleSheet(hostId: host.id, provider: provider), + ); + } +} + +// --------------------------------------------------------------------------- +// Add-rule bottom sheet +// --------------------------------------------------------------------------- + +class _AddRuleSheet extends StatefulWidget { + final String hostId; + final PortForwardProvider provider; + + const _AddRuleSheet({required this.hostId, required this.provider}); + + @override + State<_AddRuleSheet> createState() => _AddRuleSheetState(); +} + +class _AddRuleSheetState extends State<_AddRuleSheet> { + ForwardType _type = ForwardType.local; + final _labelCtrl = TextEditingController(); + final _localPortCtrl = TextEditingController(); + final _remoteHostCtrl = TextEditingController(); + final _remotePortCtrl = TextEditingController(); + + @override + void dispose() { + _labelCtrl.dispose(); + _localPortCtrl.dispose(); + _remoteHostCtrl.dispose(); + _remotePortCtrl.dispose(); + super.dispose(); + } + + void _save() { + final localPort = int.tryParse(_localPortCtrl.text.trim()) ?? 0; + final remotePort = int.tryParse(_remotePortCtrl.text.trim()) ?? 0; + final label = _labelCtrl.text.trim().isEmpty + ? _type.name + : _labelCtrl.text.trim(); + + final rule = PortForward( + label: label, + type: _type, + localPort: localPort, + remoteHost: _remoteHostCtrl.text.trim(), + remotePort: remotePort, + hostId: widget.hostId, + ); + widget.provider.add(rule); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final viewInsets = MediaQuery.of(context).viewInsets; + return Padding( + padding: EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + MobileTokens.space4 + viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: MobileColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: MobileTokens.space3), + Text('Add forwarding rule', + style: mobileHeading(size: 17)), + const SizedBox(height: MobileTokens.space3), + // Type selector + SegmentedButton( + segments: const [ + ButtonSegment(value: ForwardType.local, label: Text('Local')), + ButtonSegment(value: ForwardType.remote, label: Text('Remote')), + ButtonSegment( + value: ForwardType.dynamic, label: Text('Dynamic')), + ], + selected: {_type}, + onSelectionChanged: (s) => setState(() => _type = s.first), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? MobileColors.accent + : MobileColors.surface), + foregroundColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? MobileColors.bg + : MobileColors.textMuted), + ), + ), + const SizedBox(height: MobileTokens.space3), + _field('Label (optional)', _labelCtrl), + const SizedBox(height: MobileTokens.space2), + _field('Local port', _localPortCtrl, + numeric: true, + hint: _type == ForwardType.dynamic ? '1080' : '5432'), + if (_type != ForwardType.dynamic) ...[ + const SizedBox(height: MobileTokens.space2), + _field('Remote host', _remoteHostCtrl, hint: 'db-server'), + const SizedBox(height: MobileTokens.space2), + _field('Remote port', _remotePortCtrl, + numeric: true, hint: '5432'), + ], + const SizedBox(height: MobileTokens.space4), + SizedBox( + width: double.infinity, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: MobileColors.bg, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(MobileTokens.radiusCard), + ), + ), + onPressed: _save, + child: Text('Add rule', + style: mobileBody( + size: 15, + weight: FontWeight.w600, + color: MobileColors.bg)), + ), + ), + ], + ), + ); + } + + Widget _field( + String label, + TextEditingController ctrl, { + bool numeric = false, + String? hint, + }) { + return TextField( + controller: ctrl, + keyboardType: numeric ? TextInputType.number : TextInputType.text, + inputFormatters: + numeric ? [FilteringTextInputFormatter.digitsOnly] : null, + style: mobileBody(size: 15), + decoration: InputDecoration( + labelText: label, + hintText: hint, + labelStyle: mobileBody(size: 13, color: MobileColors.textMuted), + hintStyle: mobileBody(size: 14, color: MobileColors.textFaint), + filled: true, + fillColor: MobileColors.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space3, + vertical: MobileTokens.space3, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(MobileTokens.radiusField), + borderSide: const BorderSide(color: MobileColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MobileTokens.radiusField), + borderSide: const BorderSide(color: MobileColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MobileTokens.radiusField), + borderSide: + const BorderSide(color: MobileColors.accent, width: 1.5), + ), + ), + ); + } +} diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart index cc9cd887..788cd643 100644 --- a/app/lib/mobile/screens/mobile_terminal_screen.dart +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -30,7 +30,10 @@ class MobileTerminalScreen extends StatefulWidget { /// Called when the user taps "Files" in the ⋮ menu. Receives the active /// session's [Host] so the caller can open the contextual SFTP screen. final void Function(Host host)? onOpenFiles; - final VoidCallback? onOpenPortForward; + + /// Called when the user taps "Port forwarding" in the ⋮ menu. Receives the + /// active session's [Host] so the caller can open the contextual screen. + final void Function(Host host)? onOpenPortForward; const MobileTerminalScreen({ super.key, @@ -161,8 +164,8 @@ class _MobileTerminalScreenState extends State { style: mobileBody(color: MobileColors.textPrimary)), onTap: () { Navigator.pop(context); - if (widget.onOpenPortForward != null) { - widget.onOpenPortForward!(); + if (widget.onOpenPortForward != null && active != null) { + widget.onOpenPortForward!(active.host); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/app/lib/mobile/widgets/forward_rule_row.dart b/app/lib/mobile/widgets/forward_rule_row.dart new file mode 100644 index 00000000..d526e409 --- /dev/null +++ b/app/lib/mobile/widgets/forward_rule_row.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import '../../models/port_forward.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import 'mobile_card.dart'; + +/// A single port-forward rule card: type chip + state dot + mono summary + toggle. +class ForwardRuleRow extends StatelessWidget { + final PortForward rule; + final bool isRunning; + final VoidCallback onToggle; + + const ForwardRuleRow({ + super.key, + required this.rule, + required this.isRunning, + required this.onToggle, + }); + + String get _typeLabel => switch (rule.type) { + ForwardType.local => 'LOCAL', + ForwardType.remote => 'REMOTE', + ForwardType.dynamic => 'DYNAMIC', + }; + + String get _monoLine => switch (rule.type) { + ForwardType.local => + ':${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}', + ForwardType.remote => + 'Remote :${rule.remotePort} → :${rule.localPort}', + ForwardType.dynamic => 'SOCKS5 :${rule.localPort}', + }; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4, + vertical: MobileTokens.space1, + ), + child: MobileCard( + child: Row( + children: [ + // Type chip + Container( + padding: + const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: MobileColors.accentSoft, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + _typeLabel, + style: mobileMono( + size: 10, + color: MobileColors.accent, + weight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: MobileTokens.space2), + // State dot + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: isRunning ? MobileColors.green : MobileColors.textFaint, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: MobileTokens.space2), + // Mono rule line + Expanded( + child: Text( + _monoLine, + style: mobileMono(size: 12), + overflow: TextOverflow.ellipsis, + ), + ), + // Start / stop toggle + IconButton( + icon: Icon( + isRunning + ? Icons.stop_circle_outlined + : Icons.play_circle_outline, + color: isRunning ? MobileColors.red : MobileColors.green, + size: 22, + ), + tooltip: isRunning ? 'Stop' : 'Start', + onPressed: onToggle, + ), + ], + ), + ), + ); + } +} diff --git a/app/test/mobile/screens/port_forward_screen_test.dart b/app/test/mobile/screens/port_forward_screen_test.dart new file mode 100644 index 00000000..2c9613c6 --- /dev/null +++ b/app/test/mobile/screens/port_forward_screen_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:yourssh/mobile/screens/mobile_port_forward_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/port_forward.dart'; +import 'package:yourssh/providers/port_forward_provider.dart'; +import 'package:yourssh/services/port_forward_service.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +class _FakePortForwardProvider extends PortForwardProvider { + final List _fakeForwards; + _FakePortForwardProvider(this._fakeForwards); + + @override + List get forwards => List.unmodifiable(_fakeForwards); +} + +class _FakePortForwardService extends PortForwardService { + _FakePortForwardService() + : super( + acquireTransport: (_) async => throw UnimplementedError(), + resolveHost: (_) => null, + onStatus: (_, __, {error}) {}, + onConnections: (_, __) {}, + ); + + final startedIds = []; + final stoppedIds = []; + + @override + Future start(PortForward fwd) async => startedIds.add(fwd.id); + + @override + Future stop(String forwardId) async => stoppedIds.add(forwardId); + + @override + bool isRunning(String forwardId) => false; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +final _host = Host( + id: 'h1', + label: 'db-prod', + host: '10.0.0.1', + port: 22, + username: 'admin', +); + +final _activeRule = PortForward( + id: 'r1', + label: 'postgres', + type: ForwardType.local, + localHost: '127.0.0.1', + localPort: 5432, + remoteHost: 'db-prod', + remotePort: 5432, + hostId: 'h1', + status: ForwardStatus.active, +); + +final _stoppedRule = PortForward( + id: 'r2', + label: 'socks', + type: ForwardType.dynamic, + localHost: '127.0.0.1', + localPort: 1080, + hostId: 'h1', + status: ForwardStatus.idle, +); + +// Rule for a different host — should NOT appear. +final _otherRule = PortForward( + id: 'r3', + label: 'other', + type: ForwardType.local, + localPort: 8080, + remoteHost: 'other', + remotePort: 80, + hostId: 'h99', + status: ForwardStatus.idle, +); + +// --------------------------------------------------------------------------- +// Pump helper +// --------------------------------------------------------------------------- + +Future<_FakePortForwardService> _pump(WidgetTester tester) async { + final provider = _FakePortForwardProvider([_activeRule, _stoppedRule, _otherRule]); + final service = _FakePortForwardService(); + + await tester.pumpWidget(MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + Provider.value(value: service), + ], + child: MobilePortForwardScreen(host: _host), + ), + )); + await tester.pump(); + return service; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('shows host label in header', (tester) async { + await _pump(tester); + expect(find.text('db-prod'), findsAtLeastNWidgets(1)); + }); + + testWidgets('renders active LOCAL rule mono line', (tester) async { + await _pump(tester); + expect(find.textContaining(':5432 → db-prod:5432'), findsOneWidget); + }); + + testWidgets('renders stopped DYNAMIC rule mono line', (tester) async { + await _pump(tester); + expect(find.textContaining('SOCKS5 :1080'), findsOneWidget); + }); + + testWidgets('does not render rule for a different host', (tester) async { + await _pump(tester); + expect(find.textContaining(':8080'), findsNothing); + }); + + testWidgets('shows Add forwarding rule button', (tester) async { + await _pump(tester); + expect(find.text('Add forwarding rule'), findsOneWidget); + }); + + testWidgets('tapping play icon on stopped rule calls service.start', + (tester) async { + final svc = await _pump(tester); + await tester.tap(find.byIcon(Icons.play_circle_outline)); + await tester.pump(); + expect(svc.startedIds, contains(_stoppedRule.id)); + }); + + testWidgets('tapping stop icon on active rule calls service.stop', + (tester) async { + final svc = await _pump(tester); + await tester.tap(find.byIcon(Icons.stop_circle_outlined)); + await tester.pump(); + expect(svc.stoppedIds, contains(_activeRule.id)); + }); +} From 1e552147744abc499c79d932a8ba8de8c70eb288 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:21:04 +0700 Subject: [PATCH 84/96] fix(mobile): remove coming-soon stubs, validate port-forward rule input --- .../screens/mobile_port_forward_screen.dart | 46 +++++- .../screens/mobile_terminal_screen.dart | 12 +- .../screens/port_forward_screen_test.dart | 133 ++++++++++++++++++ 3 files changed, 177 insertions(+), 14 deletions(-) diff --git a/app/lib/mobile/screens/mobile_port_forward_screen.dart b/app/lib/mobile/screens/mobile_port_forward_screen.dart index 7eb07865..b1e47021 100644 --- a/app/lib/mobile/screens/mobile_port_forward_screen.dart +++ b/app/lib/mobile/screens/mobile_port_forward_screen.dart @@ -177,6 +177,8 @@ class _AddRuleSheetState extends State<_AddRuleSheet> { final _remoteHostCtrl = TextEditingController(); final _remotePortCtrl = TextEditingController(); + String? _validationError; + @override void dispose() { _labelCtrl.dispose(); @@ -186,9 +188,35 @@ class _AddRuleSheetState extends State<_AddRuleSheet> { super.dispose(); } + /// Returns an error message if the inputs are invalid, otherwise null. + String? _validate() { + final localPort = int.tryParse(_localPortCtrl.text.trim()); + if (localPort == null || localPort < 1 || localPort > 65535) { + return 'Local port must be between 1 and 65535.'; + } + if (_type != ForwardType.dynamic) { + if (_remoteHostCtrl.text.trim().isEmpty) { + return 'Remote host must not be empty.'; + } + final remotePort = int.tryParse(_remotePortCtrl.text.trim()); + if (remotePort == null || remotePort < 1 || remotePort > 65535) { + return 'Remote port must be between 1 and 65535.'; + } + } + return null; + } + void _save() { - final localPort = int.tryParse(_localPortCtrl.text.trim()) ?? 0; - final remotePort = int.tryParse(_remotePortCtrl.text.trim()) ?? 0; + final error = _validate(); + if (error != null) { + setState(() => _validationError = error); + return; + } + + final localPort = int.parse(_localPortCtrl.text.trim()); + final remotePort = _type != ForwardType.dynamic + ? int.parse(_remotePortCtrl.text.trim()) + : 0; final label = _labelCtrl.text.trim().isEmpty ? _type.name : _labelCtrl.text.trim(); @@ -242,7 +270,11 @@ class _AddRuleSheetState extends State<_AddRuleSheet> { value: ForwardType.dynamic, label: Text('Dynamic')), ], selected: {_type}, - onSelectionChanged: (s) => setState(() => _type = s.first), + onSelectionChanged: (s) => + setState(() { + _type = s.first; + _validationError = null; + }), style: ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith((states) => states.contains(WidgetState.selected) @@ -268,6 +300,14 @@ class _AddRuleSheetState extends State<_AddRuleSheet> { numeric: true, hint: '5432'), ], const SizedBox(height: MobileTokens.space4), + if (_validationError != null) + Padding( + padding: const EdgeInsets.only(bottom: MobileTokens.space3), + child: Text( + _validationError!, + style: mobileBody(size: 13, color: MobileColors.red), + ), + ), SizedBox( width: double.infinity, child: FilledButton( diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart index 788cd643..960f5bd0 100644 --- a/app/lib/mobile/screens/mobile_terminal_screen.dart +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -22,8 +22,7 @@ import '../util/mobile_prefs.dart'; /// /// [focusSessionId] — if non-null, switch to that session on mount. /// The ⋮ menu exposes [onOpenFiles] and [onOpenPortForward] hooks; these are -/// wired by Tasks 13 & 15 respectively. Passing null shows a "coming soon" -/// SnackBar as an interim stub. +/// wired by Tasks 13 & 15 respectively. class MobileTerminalScreen extends StatefulWidget { final String? focusSessionId; @@ -150,10 +149,6 @@ class _MobileTerminalScreenState extends State { Navigator.pop(context); if (widget.onOpenFiles != null && active != null) { widget.onOpenFiles!(active.host); - } else if (widget.onOpenFiles == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Files — coming soon')), - ); } }, ), @@ -166,11 +161,6 @@ class _MobileTerminalScreenState extends State { Navigator.pop(context); if (widget.onOpenPortForward != null && active != null) { widget.onOpenPortForward!(active.host); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Port forwarding — coming soon')), - ); } }, ), diff --git a/app/test/mobile/screens/port_forward_screen_test.dart b/app/test/mobile/screens/port_forward_screen_test.dart index 2c9613c6..ef760505 100644 --- a/app/test/mobile/screens/port_forward_screen_test.dart +++ b/app/test/mobile/screens/port_forward_screen_test.dart @@ -20,6 +20,11 @@ class _FakePortForwardProvider extends PortForwardProvider { @override List get forwards => List.unmodifiable(_fakeForwards); + + final List added = []; + + @override + Future add(PortForward fwd) async => added.add(fwd); } class _FakePortForwardService extends PortForwardService { @@ -112,6 +117,26 @@ Future<_FakePortForwardService> _pump(WidgetTester tester) async { return service; } +/// Pump helper that returns both the provider and the service. +Future<({_FakePortForwardProvider provider, _FakePortForwardService service})> + _pumpWithProvider(WidgetTester tester) async { + final provider = _FakePortForwardProvider([]); + final service = _FakePortForwardService(); + + await tester.pumpWidget(MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + Provider.value(value: service), + ], + child: MobilePortForwardScreen(host: _host), + ), + )); + await tester.pump(); + return (provider: provider, service: service); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -159,4 +184,112 @@ void main() { await tester.pump(); expect(svc.stoppedIds, contains(_activeRule.id)); }); + + // ------------------------------------------------------------------------- + // Validation: add-rule dialog + // ------------------------------------------------------------------------- + + testWidgets('empty local port blocks save — provider.add not called', + (tester) async { + final result = await _pumpWithProvider(tester); + + // Open the add-rule sheet. + await tester.tap(find.text('Add forwarding rule')); + await tester.pumpAndSettle(); + + // Leave local port empty; tap "Add rule". + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + + // provider.add must NOT have been called. + expect(result.provider.added, isEmpty); + + // An inline error message should be visible. + expect( + find.textContaining('Local port must be between 1 and 65535'), + findsOneWidget, + ); + }); + + testWidgets('zero local port blocks save — provider.add not called', + (tester) async { + final result = await _pumpWithProvider(tester); + + await tester.tap(find.text('Add forwarding rule')); + await tester.pumpAndSettle(); + + // Enter "0" — out of valid range. + await tester.enterText( + find.widgetWithText(TextField, 'Local port'), '0'); + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + + expect(result.provider.added, isEmpty); + expect( + find.textContaining('Local port must be between 1 and 65535'), + findsOneWidget, + ); + }); + + testWidgets( + 'valid local port + empty remote host blocks save for LOCAL type', + (tester) async { + final result = await _pumpWithProvider(tester); + + await tester.tap(find.text('Add forwarding rule')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Local port'), '5432'); + // Leave remote host empty. + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + + expect(result.provider.added, isEmpty); + expect( + find.textContaining('Remote host must not be empty'), + findsOneWidget, + ); + }); + + testWidgets('valid inputs for LOCAL type calls provider.add', (tester) async { + final result = await _pumpWithProvider(tester); + + await tester.tap(find.text('Add forwarding rule')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Local port'), '5432'); + await tester.enterText( + find.widgetWithText(TextField, 'Remote host'), 'db-server'); + await tester.enterText( + find.widgetWithText(TextField, 'Remote port'), '5432'); + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + + expect(result.provider.added, hasLength(1)); + expect(result.provider.added.first.localPort, 5432); + }); + + testWidgets( + 'valid local port for DYNAMIC type calls provider.add without remote check', + (tester) async { + final result = await _pumpWithProvider(tester); + + await tester.tap(find.text('Add forwarding rule')); + await tester.pumpAndSettle(); + + // Switch to Dynamic tab. + await tester.tap(find.text('Dynamic')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Local port'), '1080'); + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + + expect(result.provider.added, hasLength(1)); + expect(result.provider.added.first.type, ForwardType.dynamic); + expect(result.provider.added.first.localPort, 1080); + }); } From 40a003a3a4898c066b4f2f090da12fe1797e02cc Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:29:37 +0700 Subject: [PATCH 85/96] feat(mobile): supabase sync + QR pairing screen Introduces MobileSyncScreen (Screen 09): heading, P2P QR export card (reuses the desktop P2P start-server + base64-key JSON format), E2E Supabase status card, and "Scan QR code" button. Settings "Supabase sync" and "Pair new device" rows now push MobileSyncScreen. Scan header reskinned to MobileColors/mobileHeading. --- .../mobile/screens/mobile_qr_scan_screen.dart | 13 +- .../screens/mobile_settings_screen.dart | 214 +-------- .../mobile/screens/mobile_sync_screen.dart | 425 ++++++++++++++++++ .../mobile/mobile_settings_screen_test.dart | 32 +- app/test/mobile/screens/sync_screen_test.dart | 90 ++++ 5 files changed, 547 insertions(+), 227 deletions(-) create mode 100644 app/lib/mobile/screens/mobile_sync_screen.dart create mode 100644 app/test/mobile/screens/sync_screen_test.dart diff --git a/app/lib/mobile/screens/mobile_qr_scan_screen.dart b/app/lib/mobile/screens/mobile_qr_scan_screen.dart index d6c20e8c..cea47c9e 100644 --- a/app/lib/mobile/screens/mobile_qr_scan_screen.dart +++ b/app/lib/mobile/screens/mobile_qr_scan_screen.dart @@ -6,7 +6,7 @@ import '../../providers/host_provider.dart'; import '../../services/p2p_sync_encryption.dart'; import '../../services/p2p_sync_service.dart'; import '../../services/sync_service.dart'; -import '../../theme/app_theme.dart'; +import '../theme/mobile_theme.dart'; import '../sync/transfer_code.dart'; /// Full-screen camera QR scanner for P2P host import. On the first valid code @@ -63,7 +63,16 @@ class _MobileQrScanScreenState extends State { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( - backgroundColor: AppColors.bg, title: const Text('Scan transfer QR')), + backgroundColor: MobileColors.bg, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, size: 18), + color: MobileColors.accent, + onPressed: () => Navigator.of(context).maybePop(), + tooltip: 'Back', + ), + title: Text('Scan transfer QR', style: mobileHeading()), + ), body: MobileScanner(onDetect: _onDetect), ); } diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index 349d825f..ad0a4350 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -3,10 +3,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../providers/host_provider.dart'; import '../../providers/sync_provider.dart'; -import '../../services/sync_service.dart'; -import '../../theme/app_theme.dart'; import '../../widgets/terminal_appearance_controls.dart'; import '../security/app_lock_gate.dart'; import '../theme/mobile_theme.dart'; @@ -14,12 +11,11 @@ import '../theme/mobile_tokens.dart'; import '../util/mobile_prefs.dart'; import '../widgets/list_group.dart'; import '../widgets/settings_row.dart'; -import 'mobile_qr_scan_screen.dart'; +import 'mobile_sync_screen.dart'; /// Settings tab — grouped preferences and security. /// Groups: TERMINAL · SECURITY · KEYBOARD & SYNC -/// Sync functionality (Supabase config + P2P QR import) is preserved via -/// inline bottom sheets (Task 16 will replace with MobileSyncScreen). +/// Both "Supabase sync" and "Pair new device" push [MobileSyncScreen]. class MobileSettingsScreen extends StatefulWidget { const MobileSettingsScreen({super.key}); @@ -28,13 +24,6 @@ class MobileSettingsScreen extends StatefulWidget { } class _MobileSettingsScreenState extends State { - // ── Sync form state ─────────────────────────────────────────────────────── - final _url = TextEditingController(); - final _anon = TextEditingController(); - final _code = TextEditingController(); - bool _dirty = false; - bool _pulling = false; - // ── Security ────────────────────────────────────────────────────────────── bool _appLock = true; @@ -60,27 +49,12 @@ class _MobileSettingsScreenState extends State { }); } - @override - void dispose() { - _url.dispose(); - _anon.dispose(); - _code.dispose(); - super.dispose(); - } - // ── Build ───────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final sync = context.watch(); - // Mirror the provider until the user edits a field. - if (!_dirty) { - if (_url.text != sync.supabaseUrl) _url.text = sync.supabaseUrl; - if (_anon.text != sync.supabaseAnonKey) _anon.text = sync.supabaseAnonKey; - if (_code.text != sync.syncCode) _code.text = sync.syncCode; - } - return Scaffold( backgroundColor: MobileColors.bg, appBar: AppBar( @@ -90,7 +64,7 @@ class _MobileSettingsScreenState extends State { actions: [ IconButton( icon: const Icon(Icons.tune, color: MobileColors.textMuted, size: 22), - onPressed: null, // placeholder — Task 16 + onPressed: null, // placeholder ), ], ), @@ -236,12 +210,12 @@ class _MobileSettingsScreenState extends State { leading: Icon(Icons.cloud_sync_outlined, color: MobileColors.textMuted, size: 20), title: 'Supabase sync', value: sync.isSupabaseConfigured ? 'Configured' : null, - onTap: () => _showSupabaseSyncSheet(sync), + onTap: _pushSyncScreen, ), SettingsRow( leading: Icon(Icons.qr_code_scanner, color: MobileColors.textMuted, size: 20), title: 'Pair new device', - onTap: _scanQr, + onTap: _pushSyncScreen, ), ], ); @@ -258,181 +232,11 @@ class _MobileSettingsScreenState extends State { ); } - // ── Supabase sync sheet (preserved from original) ───────────────────────── - - void _showSupabaseSyncSheet(SyncProvider sync) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: MobileColors.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (ctx) => StatefulBuilder( - builder: (ctx, setSheetState) => _SupabaseSyncSheet( - urlController: _url, - anonController: _anon, - codeController: _code, - pulling: _pulling, - onDirty: () => _dirty = true, - onSave: () async { - await _save(); - if (ctx.mounted) Navigator.of(ctx).pop(); - }, - onPull: () async { - setSheetState(() => _pulling = true); - await _pull(); - if (ctx.mounted) { - setSheetState(() => _pulling = false); - Navigator.of(ctx).pop(); - } - }, - ), - ), - ); - } - - // ── P2P QR scan (preserved from original) ──────────────────────────────── - - Future _scanQr() async { - final result = await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const MobileQrScanScreen()), - ); - if (result != null && mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(result))); - } - } - - // ── Save / pull helpers (unchanged logic) ──────────────────────────────── - - Future _save() async { - final sync = context.read(); - await sync.setSupabaseConfig(_url.text, _anon.text); - await sync.setSyncCode(_code.text); - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Sync settings saved'))); - } - } - - Future _pull() async { - final payload = await context.read().pull(); - if (!mounted) return; - if (payload != null) { - await context - .read() - .replaceAll(payload.hosts, payload.passwords); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Imported ${payload.hosts.length} hosts from cloud'), - )); - } - } else if (mounted) { - final err = context.read().error; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(err ?? 'Nothing new to pull'))); - } - } -} - -// ── Supabase sync bottom sheet ──────────────────────────────────────────────── + // ── Navigation ──────────────────────────────────────────────────────────── -class _SupabaseSyncSheet extends StatelessWidget { - final TextEditingController urlController; - final TextEditingController anonController; - final TextEditingController codeController; - final bool pulling; - final VoidCallback onDirty; - final VoidCallback onSave; - final VoidCallback onPull; - - const _SupabaseSyncSheet({ - required this.urlController, - required this.anonController, - required this.codeController, - required this.pulling, - required this.onDirty, - required this.onSave, - required this.onPull, - }); - - @override - Widget build(BuildContext context) { - final sync = context.watch(); - final viewInsets = MediaQuery.viewInsetsOf(context); - return Padding( - padding: EdgeInsets.only( - left: MobileTokens.space4, - right: MobileTokens.space4, - top: MobileTokens.space4, - bottom: MobileTokens.space4 + viewInsets.bottom, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Handle - Center( - child: Container( - width: 36, - height: 4, - margin: const EdgeInsets.only(bottom: MobileTokens.space4), - decoration: BoxDecoration( - color: MobileColors.border, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Text('Supabase Sync', style: mobileHeading()), - const SizedBox(height: MobileTokens.space2), - Text( - 'Pull hosts from your desktop via Supabase.', - style: mobileBody(size: 13, color: MobileColors.textMuted), - ), - const SizedBox(height: MobileTokens.space4), - _field(context, urlController, 'Supabase URL', 'sync-url'), - _field(context, anonController, 'Anon key', 'sync-anon'), - _field(context, codeController, 'Sync code (12 chars)', 'sync-code'), - const SizedBox(height: MobileTokens.space3), - Row( - children: [ - FilledButton(onPressed: onSave, child: const Text('Save')), - const SizedBox(width: 10), - OutlinedButton( - onPressed: pulling ? null : onPull, - child: pulling - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Pull from cloud'), - ), - ], - ), - if (sync.error != null) ...[ - const SizedBox(height: 10), - Text( - sync.error!, - style: mobileBody(size: 12, color: AppColors.red), - ), - ], - ], - ), - ); - } - - Widget _field(BuildContext context, TextEditingController c, String label, String key) { - return Padding( - padding: const EdgeInsets.only(bottom: MobileTokens.space3), - child: TextField( - key: Key(key), - controller: c, - onChanged: (_) => onDirty(), - style: const TextStyle(color: MobileColors.textPrimary, fontSize: 13), - decoration: InputDecoration(labelText: label), - ), + Future _pushSyncScreen() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileSyncScreen()), ); } } diff --git a/app/lib/mobile/screens/mobile_sync_screen.dart b/app/lib/mobile/screens/mobile_sync_screen.dart new file mode 100644 index 00000000..973cf8b4 --- /dev/null +++ b/app/lib/mobile/screens/mobile_sync_screen.dart @@ -0,0 +1,425 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../providers/host_provider.dart'; +import '../../providers/sync_provider.dart'; +import '../../services/p2p_sync_encryption.dart'; +import '../../services/p2p_sync_service.dart'; +import '../../services/sync_service.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; +import '../widgets/mobile_card.dart'; +import '../widgets/section_header.dart'; +import 'mobile_qr_scan_screen.dart'; + +/// Screen 09 — Sync / QR pairing. +/// +/// Shows: +/// - Heading + body copy about end-to-end encryption. +/// - QR card: starts a P2P server and renders the transfer code as a QR. +/// - Caption below QR. +/// - Supabase status card with an E2E badge. +/// - "Scan QR code" button → [MobileQrScanScreen]. +class MobileSyncScreen extends StatefulWidget { + const MobileSyncScreen({super.key}); + + @override + State createState() => _MobileSyncScreenState(); +} + +class _MobileSyncScreenState extends State { + final _p2p = P2PSyncService(); + String? _qrData; + String _qrStatus = 'Starting…'; + int _secondsLeft = 120; + Timer? _countdown; + + @override + void initState() { + super.initState(); + _startServer(); + } + + @override + void dispose() { + _countdown?.cancel(); + _p2p.stop(); + super.dispose(); + } + + // ── P2P server / QR build ───────────────────────────────────────────────── + + Future _startServer() async { + setState(() { + _qrData = null; + _qrStatus = 'Preparing…'; + }); + _countdown?.cancel(); + + try { + final ifaces = await _p2p.getLocalInterfaces(); + if (!mounted) return; + if (ifaces.isEmpty) { + setState(() => _qrStatus = 'No network interface found'); + return; + } + + final hostProvider = context.read(); + final hosts = hostProvider.allHosts; + final passwords = await hostProvider.loadAllPasswords(); + final payload = SyncService.buildPayload(hosts: hosts, passwords: passwords); + if (!mounted) return; + + final key = P2PSyncEncryption.generateKey(); + final encrypted = await P2PSyncEncryption.encrypt(payload, key); + final url = await _p2p.startServer( + encryptedPayload: encrypted, + hostAddress: ifaces.first.address, + ); + final qrJson = jsonEncode({'u': url, 'k': base64.encode(key)}); + + _secondsLeft = 120; + _countdown = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) { + t.cancel(); + return; + } + if (_secondsLeft <= 0) { + t.cancel(); + _p2p.stop(); + if (mounted) setState(() => _qrStatus = 'Code expired — tap Refresh'); + return; + } + setState(() => _secondsLeft--); + }); + + _p2p.onServerError = (e) { + if (mounted) setState(() => _qrStatus = 'Transfer error: $e'); + }; + + if (mounted) setState(() => _qrData = qrJson); + } catch (e) { + if (mounted) setState(() => _qrStatus = 'Error: $e'); + } + } + + // ── Format helpers ──────────────────────────────────────────────────────── + + String get _countdownText { + final m = _secondsLeft ~/ 60; + final s = (_secondsLeft % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + String _formatLastSync(DateTime? t) { + if (t == null) return 'Never'; + final now = DateTime.now(); + final diff = now.difference(t); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + + return Scaffold( + backgroundColor: MobileColors.bg, + appBar: _buildAppBar(), + body: ListView( + padding: EdgeInsets.only( + top: MobileTokens.space4, + left: MobileTokens.space4, + right: MobileTokens.space4, + bottom: MobileTokens.space5, + ), + children: [ + _buildHero(), + const SizedBox(height: MobileTokens.space5), + _buildQrSection(), + const SizedBox(height: MobileTokens.space5), + _buildStatusSection(sync), + const SizedBox(height: MobileTokens.space5), + _buildScanButton(), + ], + ), + ); + } + + // ── App bar ─────────────────────────────────────────────────────────────── + + AppBar _buildAppBar() => AppBar( + backgroundColor: MobileColors.bg, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, size: 18), + color: MobileColors.accent, + onPressed: () => Navigator.of(context).maybePop(), + tooltip: 'Settings', + ), + title: Text('Pair device', style: mobileHeading()), + actions: [ + IconButton( + icon: const Icon(Icons.more_vert, size: 20), + color: MobileColors.textMuted, + onPressed: null, // placeholder + ), + ], + ); + + // ── Hero copy ───────────────────────────────────────────────────────────── + + Widget _buildHero() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sync with Supabase', style: mobileHeading(size: 22)), + const SizedBox(height: MobileTokens.space2), + Text( + 'No account needed. Your hosts, keys & snippets stay end-to-end ' + 'encrypted across devices.', + style: mobileBody(size: 14, color: MobileColors.textMuted), + ), + ], + ); + + // ── QR section ──────────────────────────────────────────────────────────── + + Widget _buildQrSection() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader('Pair a new device'), + MobileCard( + padding: const EdgeInsets.all(MobileTokens.space5), + child: Column( + children: [ + _buildQrContent(), + const SizedBox(height: MobileTokens.space3), + Text( + 'Open YourSSH on another device and scan this', + style: mobileBody(size: 13, color: MobileColors.textMuted), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ); + + Widget _buildQrContent() { + if (_qrData != null) { + return Column( + children: [ + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(MobileTokens.radiusCard), + ), + padding: const EdgeInsets.all(MobileTokens.space3), + child: QrImageView( + data: _qrData!, + size: 200, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: MobileTokens.space2), + Text( + _countdownText, + style: mobileBody( + size: 20, + weight: FontWeight.w700, + color: MobileColors.textPrimary, + ), + ), + ], + ); + } + + // Loading / error state + return SizedBox( + height: 240, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_qrStatus.startsWith('Error') || _qrStatus.contains('expired')) + Column( + children: [ + Icon(Icons.error_outline, color: MobileColors.textMuted, size: 40), + const SizedBox(height: MobileTokens.space3), + Text( + _qrStatus, + style: mobileBody(size: 13, color: MobileColors.textMuted), + textAlign: TextAlign.center, + ), + const SizedBox(height: MobileTokens.space3), + FilledButton( + onPressed: _startServer, + child: const Text('Refresh'), + ), + ], + ) + else + Column( + children: [ + const CircularProgressIndicator(), + const SizedBox(height: MobileTokens.space3), + Text( + _qrStatus, + style: mobileBody(size: 13, color: MobileColors.textMuted), + ), + ], + ), + ], + ), + ); + } + + // ── Status section ──────────────────────────────────────────────────────── + + Widget _buildStatusSection(SyncProvider sync) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader('Cloud sync'), + MobileCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sync.isSupabaseConfigured + ? 'Supabase connected' + : 'Supabase not configured', + style: mobileBody( + size: 14, + weight: FontWeight.w600, + color: sync.isSupabaseConfigured + ? MobileColors.textPrimary + : MobileColors.textMuted, + ), + ), + if (sync.isSupabaseConfigured) ...[ + const SizedBox(height: 4), + Text( + 'Last sync · ${_formatLastSync(sync.lastSynced)}', + style: mobileBody( + size: 12, + color: MobileColors.textMuted, + ), + ), + ], + ], + ), + ), + if (sync.isSupabaseConfigured) + _E2EBadge(), + ], + ), + if (sync.isSupabaseConfigured) ...[ + const SizedBox(height: MobileTokens.space3), + _SyncStatusRow(status: sync.status), + ], + ], + ), + ), + ], + ); + + // ── Scan button ─────────────────────────────────────────────────────────── + + Widget _buildScanButton() => SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: const Icon(Icons.qr_code_scanner, size: 18), + label: const Text('Scan QR code'), + style: FilledButton.styleFrom( + backgroundColor: MobileColors.accent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: MobileTokens.space4), + textStyle: mobileBody( + size: 16, + weight: FontWeight.w600, + color: Colors.black, + ), + ), + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileQrScanScreen()), + ); + if (result != null && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result)), + ); + } + }, + ), + ); +} + +// ── Sub-widgets ─────────────────────────────────────────────────────────────── + +class _E2EBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space2, + vertical: 3, + ), + decoration: BoxDecoration( + color: MobileColors.green.withAlpha(38), + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: Border.all(color: MobileColors.green.withAlpha(76)), + ), + child: Text( + 'E2E', + style: mobileBody( + size: 11, + weight: FontWeight.w700, + color: MobileColors.green, + ), + ), + ); + } +} + +class _SyncStatusRow extends StatelessWidget { + final SyncStatus status; + const _SyncStatusRow({required this.status}); + + @override + Widget build(BuildContext context) { + final (label, color) = switch (status) { + SyncStatus.synced => ('Synced', MobileColors.green), + SyncStatus.syncing => ('Syncing…', MobileColors.yellow), + SyncStatus.error => ('Sync error', MobileColors.red), + SyncStatus.idle => ('Idle', MobileColors.textMuted), + }; + + return Row( + children: [ + Container( + width: MobileTokens.statusDot, + height: MobileTokens.statusDot, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: MobileTokens.space2), + Text(label, style: mobileBody(size: 12, color: color)), + ], + ); + } +} diff --git a/app/test/mobile/mobile_settings_screen_test.dart b/app/test/mobile/mobile_settings_screen_test.dart index 4320b37f..020969a6 100644 --- a/app/test/mobile/mobile_settings_screen_test.dart +++ b/app/test/mobile/mobile_settings_screen_test.dart @@ -4,6 +4,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/screens/mobile_settings_screen.dart'; +import 'package:yourssh/mobile/screens/mobile_sync_screen.dart'; import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/settings_provider.dart'; @@ -15,8 +16,8 @@ Future _pump(WidgetTester tester) async { final sync = SyncProvider(); final storage = StorageService(); - // Providers must wrap MaterialApp (not just home:) so that modal bottom - // sheets — which run in a separate overlay route — can still find them. + // Providers wrap MaterialApp so pushed routes (MobileSyncScreen) also find + // them in the tree. await tester.pumpWidget( MultiProvider( providers: [ @@ -55,26 +56,17 @@ void main() { ); }); - testWidgets('saving cloud config writes to the provider', (tester) async { - final sync = await _pump(tester); + testWidgets('"Supabase sync" row navigates to MobileSyncScreen', (tester) async { + await _pump(tester); - // Open the Supabase sync bottom sheet via the row tap. + // Tap the row — now pushes MobileSyncScreen instead of opening a sheet. await tester.tap(find.text('Supabase sync')); - await tester.pumpAndSettle(); + // Use pump(Duration) to avoid pumpAndSettle timing out on the P2P Timer. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); - // The sheet fields are now visible. - await tester.enterText( - find.byKey(const Key('sync-url')), 'https://x.supabase.co'); - await tester.enterText( - find.byKey(const Key('sync-anon')), 'anon-key-123'); - await tester.enterText( - find.byKey(const Key('sync-code')), '123456789012'); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(sync.supabaseUrl, 'https://x.supabase.co'); - expect(sync.supabaseAnonKey, 'anon-key-123'); - expect(sync.syncCode, '123456789012'); + // MobileSyncScreen heading must be visible. + expect(find.text('Sync with Supabase'), findsOneWidget); + expect(find.byType(MobileSyncScreen), findsOneWidget); }); } diff --git a/app/test/mobile/screens/sync_screen_test.dart b/app/test/mobile/screens/sync_screen_test.dart new file mode 100644 index 00000000..5a89a2b3 --- /dev/null +++ b/app/test/mobile/screens/sync_screen_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:yourssh/mobile/screens/mobile_sync_screen.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/sync_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; + +// ── Helper ──────────────────────────────────────────────────────────────────── + +Future _pump( + WidgetTester tester, { + bool configured = true, + SyncStatus status = SyncStatus.synced, +}) async { + // Pre-seed SharedPreferences so SyncProvider._init() reads configured values. + if (configured) { + SharedPreferences.setMockInitialValues({ + 'supabase_url': 'https://example.supabase.co', + 'supabase_anon_key': 'test-anon-key', + }); + } + + final sync = SyncProvider(); + if (status != SyncStatus.idle) sync.setStatus(status); + + final hosts = HostProvider(StorageService()); + + await tester.pumpWidget( + MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider.value(value: hosts), + ], + child: const MobileSyncScreen(), + ), + ), + ); + + // Pump a few frames to let initState + async SyncProvider._init() run, but + // do NOT call pumpAndSettle — the P2P server's Timer.periodic would stall it + // if the server happens to start successfully in the test environment. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('renders heading "Sync with Supabase"', (tester) async { + await _pump(tester); + expect(find.text('Sync with Supabase'), findsOneWidget); + }); + + testWidgets('renders "Scan QR code" button', (tester) async { + await _pump(tester); + // The button is at the bottom of the scrollable list — scroll to it. + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Scan QR code'), findsOneWidget); + }); + + testWidgets('shows E2E badge when Supabase is configured', (tester) async { + await _pump(tester, configured: true); + expect(find.text('E2E'), findsOneWidget); + }); + + testWidgets('shows connected text when Supabase is configured', (tester) async { + await _pump(tester, configured: true); + expect(find.textContaining('connected'), findsWidgets); + }); + + testWidgets('hides E2E badge when Supabase is not configured', (tester) async { + await _pump(tester, configured: false); + expect(find.text('E2E'), findsNothing); + }); + + testWidgets('renders QR pairing caption', (tester) async { + await _pump(tester); + expect(find.textContaining('another device'), findsOneWidget); + }); +} From 4db441d4b0b6f08f05dd41a9f7c5a1063b67ec77 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:35:15 +0700 Subject: [PATCH 86/96] fix(mobile): restore Supabase config entry in sync screen --- .../mobile/screens/mobile_sync_screen.dart | 154 ++++++++++++++++++ app/test/mobile/screens/sync_screen_test.dart | 70 +++++++- 2 files changed, 221 insertions(+), 3 deletions(-) diff --git a/app/lib/mobile/screens/mobile_sync_screen.dart b/app/lib/mobile/screens/mobile_sync_screen.dart index 973cf8b4..cc081a72 100644 --- a/app/lib/mobile/screens/mobile_sync_screen.dart +++ b/app/lib/mobile/screens/mobile_sync_screen.dart @@ -38,6 +38,13 @@ class _MobileSyncScreenState extends State { int _secondsLeft = 120; Timer? _countdown; + // ── Supabase config form state ──────────────────────────────────────────── + final _url = TextEditingController(); + final _anon = TextEditingController(); + final _code = TextEditingController(); + bool _editingConfig = false; + bool _pulling = false; + @override void initState() { super.initState(); @@ -48,9 +55,58 @@ class _MobileSyncScreenState extends State { void dispose() { _countdown?.cancel(); _p2p.stop(); + _url.dispose(); + _anon.dispose(); + _code.dispose(); super.dispose(); } + // ── Supabase config helpers ─────────────────────────────────────────────── + + void _prefillConfig() { + final sync = context.read(); + _url.text = sync.supabaseUrl; + _anon.text = sync.supabaseAnonKey; + _code.text = sync.syncCode; + } + + Future _saveConfig() async { + final sync = context.read(); + await sync.setSupabaseConfig(_url.text, _anon.text); + await sync.setSyncCode(_code.text); + if (mounted) { + setState(() => _editingConfig = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sync settings saved')), + ); + } + } + + Future _pullFromCloud() async { + setState(() => _pulling = true); + try { + final payload = await context.read().pull(); + if (!mounted) return; + if (payload != null) { + await context + .read() + .replaceAll(payload.hosts, payload.passwords); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Imported ${payload.hosts.length} hosts from cloud'), + )); + } + } else if (mounted) { + final err = context.read().error; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err ?? 'Nothing new to pull')), + ); + } + } finally { + if (mounted) setState(() => _pulling = false); + } + } + // ── P2P server / QR build ───────────────────────────────────────────────── Future _startServer() async { @@ -148,6 +204,8 @@ class _MobileSyncScreenState extends State { const SizedBox(height: MobileTokens.space5), _buildStatusSection(sync), const SizedBox(height: MobileTokens.space5), + _buildConfigSection(sync), + const SizedBox(height: MobileTokens.space5), _buildScanButton(), ], ), @@ -336,6 +394,102 @@ class _MobileSyncScreenState extends State { ], ); + // ── Supabase config section ─────────────────────────────────────────────── + + Widget _buildConfigSection(SyncProvider sync) { + final isConfigured = sync.isSupabaseConfigured; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader('Supabase credentials'), + if (!isConfigured || _editingConfig) + _buildConfigForm(sync) + else + MobileCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'URL: ${sync.supabaseUrl}', + style: mobileBody(size: 12, color: MobileColors.textMuted), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: MobileTokens.space2), + OutlinedButton( + onPressed: () { + _prefillConfig(); + setState(() => _editingConfig = true); + }, + child: const Text('Edit credentials'), + ), + ], + ), + ), + ], + ); + } + + Widget _buildConfigForm(SyncProvider sync) => MobileCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Enter your Supabase project URL, anon key, and 12-character sync code.', + style: mobileBody(size: 13, color: MobileColors.textMuted), + ), + const SizedBox(height: MobileTokens.space3), + _configField(_url, 'Supabase URL', 'sync-url'), + _configField(_anon, 'Anon key', 'sync-anon'), + _configField(_code, 'Sync code (12 chars)', 'sync-code'), + const SizedBox(height: MobileTokens.space3), + Row( + children: [ + FilledButton( + onPressed: _saveConfig, + child: const Text('Save'), + ), + const SizedBox(width: MobileTokens.space3), + OutlinedButton( + onPressed: _pulling ? null : _pullFromCloud, + child: _pulling + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Pull from cloud'), + ), + ], + ), + if (sync.error != null) ...[ + const SizedBox(height: MobileTokens.space2), + Text( + sync.error!, + style: mobileBody(size: 12, color: MobileColors.red), + ), + ], + ], + ), + ); + + Widget _configField( + TextEditingController controller, + String label, + String key, + ) => + Padding( + padding: const EdgeInsets.only(bottom: MobileTokens.space3), + child: TextField( + key: Key(key), + controller: controller, + style: const TextStyle( + color: MobileColors.textPrimary, + fontSize: 13, + ), + decoration: InputDecoration(labelText: label), + ), + ); + // ── Scan button ─────────────────────────────────────────────────────────── Widget _buildScanButton() => SizedBox( diff --git a/app/test/mobile/screens/sync_screen_test.dart b/app/test/mobile/screens/sync_screen_test.dart index 5a89a2b3..db1387a7 100644 --- a/app/test/mobile/screens/sync_screen_test.dart +++ b/app/test/mobile/screens/sync_screen_test.dart @@ -8,10 +8,11 @@ import 'package:yourssh/mobile/theme/mobile_theme.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/sync_provider.dart'; import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/services/sync_service.dart'; // ── Helper ──────────────────────────────────────────────────────────────────── -Future _pump( +Future _pump( WidgetTester tester, { bool configured = true, SyncStatus status = SyncStatus.synced, @@ -24,10 +25,11 @@ Future _pump( }); } - final sync = SyncProvider(); + final sync = SyncProvider(); if (status != SyncStatus.idle) sync.setStatus(status); - final hosts = HostProvider(StorageService()); + final hosts = HostProvider(StorageService()); + final syncSvc = SyncService(sync); await tester.pumpWidget( MaterialApp( @@ -36,6 +38,7 @@ Future _pump( providers: [ ChangeNotifierProvider.value(value: sync), ChangeNotifierProvider.value(value: hosts), + Provider.value(value: syncSvc), ], child: const MobileSyncScreen(), ), @@ -48,6 +51,8 @@ Future _pump( await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); + + return sync; } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -87,4 +92,63 @@ void main() { await _pump(tester); expect(find.textContaining('another device'), findsOneWidget); }); + + // ── Config form: unconfigured state shows URL/anon/code fields ──────────── + + testWidgets('shows config fields when Supabase is not configured', + (tester) async { + await _pump(tester, configured: false); + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byKey(const Key('sync-url')), findsOneWidget); + expect(find.byKey(const Key('sync-anon')), findsOneWidget); + expect(find.byKey(const Key('sync-code')), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + expect(find.text('Pull from cloud'), findsOneWidget); + }); + + // ── Config form: Save round-trip calls setSupabaseConfig + setSyncCode ──── + + testWidgets('Save calls setSupabaseConfig and setSyncCode on SyncProvider', + (tester) async { + final sync = await _pump(tester, configured: false); + + // Scroll config section into view. + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + + // Enter values into each field. + await tester.enterText( + find.byKey(const Key('sync-url')), 'https://my.supabase.co'); + await tester.enterText( + find.byKey(const Key('sync-anon')), 'my-anon-key'); + await tester.enterText( + find.byKey(const Key('sync-code')), 'ABCDEFGHIJKL'); + await tester.pump(); + + // Ensure the Save button is scrolled into view before tapping. + await tester.ensureVisible(find.text('Save')); + await tester.pump(const Duration(milliseconds: 100)); + + // Tap Save. + await tester.tap(find.text('Save')); + await tester.pump(const Duration(milliseconds: 200)); + + // Verify SyncProvider state was updated. + expect(sync.supabaseUrl, 'https://my.supabase.co'); + expect(sync.supabaseAnonKey, 'my-anon-key'); + expect(sync.isSupabaseConfigured, isTrue); + }); + + // ── Config form: configured state shows "Edit credentials" affordance ───── + + testWidgets('shows Edit credentials button when already configured', + (tester) async { + await _pump(tester, configured: true); + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Edit credentials'), findsOneWidget); + // Fields are hidden until Edit is tapped. + expect(find.byKey(const Key('sync-url')), findsNothing); + }); } From 301e6f63e23ea9aaf1b1e0de15fc37d18187dd60 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:38:59 +0700 Subject: [PATCH 87/96] fix(mobile): validate Supabase config before save Reject empty URL, non-http/https URL, and empty anon key in _saveConfig instead of writing broken state to SyncProvider. Shows an inline error (keyed sync-config-error) and keeps the form open. Sync code remains optional (unchanged). 4 new widget tests cover the three rejection paths and the valid round-trip (including setSyncCode exercised with a Crockford-safe code). --- .../mobile/screens/mobile_sync_screen.dart | 34 +++++- app/test/mobile/screens/sync_screen_test.dart | 101 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/app/lib/mobile/screens/mobile_sync_screen.dart b/app/lib/mobile/screens/mobile_sync_screen.dart index cc081a72..a8e01c0a 100644 --- a/app/lib/mobile/screens/mobile_sync_screen.dart +++ b/app/lib/mobile/screens/mobile_sync_screen.dart @@ -44,6 +44,7 @@ class _MobileSyncScreenState extends State { final _code = TextEditingController(); bool _editingConfig = false; bool _pulling = false; + String? _configError; @override void initState() { @@ -71,8 +72,30 @@ class _MobileSyncScreenState extends State { } Future _saveConfig() async { + final url = _url.text.trim(); + final anonKey = _anon.text.trim(); + + // Validate URL — must be non-empty and parse as http/https. + if (url.isEmpty) { + setState(() => _configError = 'Supabase URL is required.'); + return; + } + final uri = Uri.tryParse(url); + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + setState(() => _configError = 'URL must start with https:// (or http://).'); + return; + } + + // Validate anon key — must be non-empty. + if (anonKey.isEmpty) { + setState(() => _configError = 'Anon key is required.'); + return; + } + + // All valid — clear any previous error, persist and close the form. + setState(() => _configError = null); final sync = context.read(); - await sync.setSupabaseConfig(_url.text, _anon.text); + await sync.setSupabaseConfig(url, anonKey); await sync.setSyncCode(_code.text); if (mounted) { setState(() => _editingConfig = false); @@ -461,7 +484,14 @@ class _MobileSyncScreenState extends State { ), ], ), - if (sync.error != null) ...[ + if (_configError != null) ...[ + const SizedBox(height: MobileTokens.space2), + Text( + _configError!, + key: const Key('sync-config-error'), + style: mobileBody(size: 12, color: MobileColors.red), + ), + ] else if (sync.error != null) ...[ const SizedBox(height: MobileTokens.space2), Text( sync.error!, diff --git a/app/test/mobile/screens/sync_screen_test.dart b/app/test/mobile/screens/sync_screen_test.dart index db1387a7..a46d6576 100644 --- a/app/test/mobile/screens/sync_screen_test.dart +++ b/app/test/mobile/screens/sync_screen_test.dart @@ -151,4 +151,105 @@ void main() { // Fields are hidden until Edit is tapped. expect(find.byKey(const Key('sync-url')), findsNothing); }); + + // ── Validation: empty URL does NOT persist and shows error ──────────────── + + testWidgets('Save with empty URL does not configure SyncProvider and shows error', + (tester) async { + final sync = await _pump(tester, configured: false); + + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + + // Leave URL empty; fill in anon key. + await tester.enterText(find.byKey(const Key('sync-anon')), 'my-anon-key'); + await tester.pump(); + + await tester.ensureVisible(find.text('Save')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Save')); + await tester.pump(const Duration(milliseconds: 200)); + + // Provider must NOT be configured. + expect(sync.isSupabaseConfigured, isFalse); + // Error message must be visible. + expect(find.byKey(const Key('sync-config-error')), findsOneWidget); + }); + + // ── Validation: invalid (non-http) URL does NOT persist ─────────────────── + + testWidgets('Save with non-http URL does not configure SyncProvider and shows error', + (tester) async { + final sync = await _pump(tester, configured: false); + + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.enterText( + find.byKey(const Key('sync-url')), 'not-a-valid-url'); + await tester.enterText( + find.byKey(const Key('sync-anon')), 'my-anon-key'); + await tester.pump(); + + await tester.ensureVisible(find.text('Save')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Save')); + await tester.pump(const Duration(milliseconds: 200)); + + expect(sync.isSupabaseConfigured, isFalse); + expect(find.byKey(const Key('sync-config-error')), findsOneWidget); + }); + + // ── Validation: empty anon key does NOT persist ─────────────────────────── + + testWidgets('Save with empty anon key does not configure SyncProvider and shows error', + (tester) async { + final sync = await _pump(tester, configured: false); + + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.enterText( + find.byKey(const Key('sync-url')), 'https://my.supabase.co'); + // Leave anon key empty. + await tester.pump(); + + await tester.ensureVisible(find.text('Save')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Save')); + await tester.pump(const Duration(milliseconds: 200)); + + expect(sync.isSupabaseConfigured, isFalse); + expect(find.byKey(const Key('sync-config-error')), findsOneWidget); + }); + + // ── Validation: valid round-trip also exercises setSyncCode ────────────── + + testWidgets('Save with valid URL+anon+code calls setSyncCode', + (tester) async { + final sync = await _pump(tester, configured: false); + + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.enterText( + find.byKey(const Key('sync-url')), 'https://my.supabase.co'); + await tester.enterText( + find.byKey(const Key('sync-anon')), 'my-anon-key'); + // Use Crockford Base32 chars only (no I/L/O/U ambiguity). + await tester.enterText( + find.byKey(const Key('sync-code')), 'ABCDEFGH0JKM'); + await tester.pump(); + + await tester.ensureVisible(find.text('Save')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Save')); + await tester.pump(const Duration(milliseconds: 200)); + + // Provider fully configured and syncCode stored. + expect(sync.isSupabaseConfigured, isTrue); + expect(sync.syncCode, 'ABCDEFGH0JKM'); + // No error shown. + expect(find.byKey(const Key('sync-config-error')), findsNothing); + }); } From 66c100e1dc04074506529704076836706eef6044 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 26 Jun 2026 23:43:51 +0700 Subject: [PATCH 88/96] polish(mobile): analyze clean, close minor test gaps, fidelity pass Fix 8 lint issues in mobile test files: - unnecessary_underscores in 3 files (use named params instead of _/__) - unused_local_variable noSession in snippets test (use it as provider value) - prefer_function_declarations_over_variables in probe test (clock fn) Close tracked minor gaps: - Replace raw SizedBox(height:4) with MobileTokens.space1 in snippets screen - Add probeAll debounce unit test (concurrent duplicate ids not double-probed) - Add widget test asserting _EntryRow shows Icons.lock_outline for .env file Regression confirmed: AppLockGate wraps app in mobile_app.dart, TofuWatcher in mobile_home_shell.dart, pinch-zoom + CursorDrag in terminal screen, P2P QR import reachable via mobile_qr_scan_screen / mobile_sync_screen, no dangling refs to deleted mobile_sessions_screen.dart. --- .../screens/mobile_snippets_screen.dart | 2 +- app/test/mobile/mobile_hosts_screen_test.dart | 2 +- .../mobile/screens/hosts_screen_test.dart | 2 +- .../screens/port_forward_screen_test.dart | 8 ++--- app/test/mobile/screens/sftp_screen_test.dart | 5 ++++ .../mobile/screens/snippets_screen_test.dart | 9 ++---- .../host_reachability_probe_test.dart | 29 ++++++++++++++++++- 7 files changed, 42 insertions(+), 15 deletions(-) diff --git a/app/lib/mobile/screens/mobile_snippets_screen.dart b/app/lib/mobile/screens/mobile_snippets_screen.dart index a8286819..de95fee4 100644 --- a/app/lib/mobile/screens/mobile_snippets_screen.dart +++ b/app/lib/mobile/screens/mobile_snippets_screen.dart @@ -65,7 +65,7 @@ class _MobileSnippetsScreenState extends State { 'Snippets', style: mobileHeading(size: 30, weight: FontWeight.w800), ), - const SizedBox(height: 4), + const SizedBox(height: MobileTokens.space1), Text( subtitle, style: mobileBody(size: 13, color: MobileColors.textMuted), diff --git a/app/test/mobile/mobile_hosts_screen_test.dart b/app/test/mobile/mobile_hosts_screen_test.dart index 48078d15..c3bffa38 100644 --- a/app/test/mobile/mobile_hosts_screen_test.dart +++ b/app/test/mobile/mobile_hosts_screen_test.dart @@ -14,7 +14,7 @@ import 'package:yourssh/services/tab_metadata_service.dart'; Future _pump(WidgetTester tester, HostProvider hosts) async { final sessions = SessionProvider(SshService(StorageService()), TabMetadataService()); - final probe = HostReachabilityProbe(connector: (_, __, ___) async {}); + final probe = HostReachabilityProbe(connector: (h, p, to) async {}); await tester.pumpWidget(MaterialApp( home: MultiProvider( providers: [ diff --git a/app/test/mobile/screens/hosts_screen_test.dart b/app/test/mobile/screens/hosts_screen_test.dart index 678101d8..c8928987 100644 --- a/app/test/mobile/screens/hosts_screen_test.dart +++ b/app/test/mobile/screens/hosts_screen_test.dart @@ -17,7 +17,7 @@ import 'package:yourssh/services/tab_metadata_service.dart'; Future _pump(WidgetTester tester, HostProvider hosts) async { final sessions = SessionProvider(SshService(StorageService()), TabMetadataService()); final probe = HostReachabilityProbe( - connector: (_, __, ___) async {}, // no-op — no real network in tests + connector: (h, p, to) async {}, // no-op — no real network in tests ); await tester.pumpWidget( diff --git a/app/test/mobile/screens/port_forward_screen_test.dart b/app/test/mobile/screens/port_forward_screen_test.dart index ef760505..b4c873ba 100644 --- a/app/test/mobile/screens/port_forward_screen_test.dart +++ b/app/test/mobile/screens/port_forward_screen_test.dart @@ -30,10 +30,10 @@ class _FakePortForwardProvider extends PortForwardProvider { class _FakePortForwardService extends PortForwardService { _FakePortForwardService() : super( - acquireTransport: (_) async => throw UnimplementedError(), - resolveHost: (_) => null, - onStatus: (_, __, {error}) {}, - onConnections: (_, __) {}, + acquireTransport: (id) async => throw UnimplementedError(), + resolveHost: (id) => null, + onStatus: (fwdId, status, {error}) {}, + onConnections: (fwdId, count) {}, ); final startedIds = []; diff --git a/app/test/mobile/screens/sftp_screen_test.dart b/app/test/mobile/screens/sftp_screen_test.dart index ee43af61..3271a1d5 100644 --- a/app/test/mobile/screens/sftp_screen_test.dart +++ b/app/test/mobile/screens/sftp_screen_test.dart @@ -121,4 +121,9 @@ void main() { await _pump(tester, entries: [_dir('a'), _file('b.txt', 100)]); expect(find.textContaining('2 items'), findsAtLeastNWidgets(1)); }); + + testWidgets('sensitive file (.env) shows Icons.lock_outline', (tester) async { + await _pump(tester, entries: [_file('.env', 512)]); + expect(find.byIcon(Icons.lock_outline), findsAtLeastNWidgets(1)); + }); } diff --git a/app/test/mobile/screens/snippets_screen_test.dart b/app/test/mobile/screens/snippets_screen_test.dart index 8b884e6d..7fb720dc 100644 --- a/app/test/mobile/screens/snippets_screen_test.dart +++ b/app/test/mobile/screens/snippets_screen_test.dart @@ -112,18 +112,13 @@ void main() { }); testWidgets('shows "No active session" subtitle when no session', (tester) async { - final noSession = _FakeSessionProvider( - SshSession( - host: Host(label: 'dummy', host: '0.0.0.0', username: 'u'), - ), - ); - // Override to return null active session + final noSession = _NullSessionProvider(); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: fakeSnippets), ChangeNotifierProvider.value( - value: _NullSessionProvider(), + value: noSession, ), Provider.value(value: fakeSsh), ], diff --git a/app/test/mobile/services/host_reachability_probe_test.dart b/app/test/mobile/services/host_reachability_probe_test.dart index 2788ca5b..a3dfd40f 100644 --- a/app/test/mobile/services/host_reachability_probe_test.dart +++ b/app/test/mobile/services/host_reachability_probe_test.dart @@ -6,7 +6,7 @@ import 'package:yourssh/mobile/services/host_reachability_probe.dart'; void main() { test('online reports ms from injected clock', () async { var t = DateTime(2026, 1, 1); - final clock = () => t; + DateTime clock() => t; final probe = HostReachabilityProbe( connector: (h, p, to) async { t = t.add(const Duration(milliseconds: 24)); @@ -23,4 +23,31 @@ void main() { await probe.probe('h2', '10.0.0.2', 22); expect(probe.pingFor('h2').state, HostReachState.offline); }); + + test('probeAll debounce — duplicate id is not probed twice concurrently', + () async { + var callCount = 0; + final completer = Future.delayed(const Duration(milliseconds: 10)); + + final probe = HostReachabilityProbe( + connector: (h, p, to) async { + callCount++; + await completer; + }, + ); + + // Call probeAll twice with the same host id before the first completes. + probe.probeAll([ + (id: 'h1', host: '10.0.0.1', port: 22), + ]); + probe.probeAll([ + (id: 'h1', host: '10.0.0.1', port: 22), + ]); + + // Wait for the in-flight probe to finish. + await Future.delayed(const Duration(milliseconds: 30)); + + // Despite two probeAll calls the connector must have fired exactly once. + expect(callCount, 1); + }); } From 203c88b601bacdb9554763248f5acb4904378cd0 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sat, 27 Jun 2026 00:28:20 +0700 Subject: [PATCH 89/96] fix(mobile): wire KeyGenService in bootstrap, persist generated key passphrase, remove dead-end controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: add KeyGenService to MobileBootstrap providers list — opening the Generate sheet no longer throws ProviderNotFoundException at runtime. Finding 2: capture addKeyFromFile return value in _GenerateSheetState._generate and call keyProv.savePassphrase when passphrase is non-empty — mirrors the Import flow; generated passphrase-protected keys are now usable after restart. Finding 3: remove all no-op / misleading interactive controls — - port_forward_screen: replace empty PopupMenuButton with SizedBox - sync_screen: remove placeholder more_vert IconButton from AppBar - terminal_screen: remove dead Split IconButton (mobile has no split) - hosts_screen: replace no-op _CircleButton header items with non-interactive _DecorativeCircle; remove now-dead _CircleButton class - settings_screen: remove no-op tune AppBar action; render Auto-lock row as display-only (drop onTap/chevron since the feature is not built) Finding 4: add MobileColors.fieldFill (0xFF1C1C1E) and MobileColors.tabInactive (0xFF6D6D72) tokens; replace all hardcoded occurrences in mobile_hosts_screen, mobile_keys_screen, and mobile_tab_bar with the tokens. Tests: extend mobile_bootstrap_test to assert KeyGenService resolves from the provider tree; add two unit tests in keys_screen_test asserting savePassphrase is called iff passphrase is non-empty. --- app/lib/mobile/mobile_bootstrap.dart | 4 + .../mobile/screens/mobile_hosts_screen.dart | 39 +++--- .../mobile/screens/mobile_keys_screen.dart | 7 +- .../screens/mobile_port_forward_screen.dart | 8 +- .../screens/mobile_settings_screen.dart | 10 +- .../mobile/screens/mobile_sync_screen.dart | 8 +- .../screens/mobile_terminal_screen.dart | 6 - app/lib/mobile/theme/mobile_theme.dart | 4 + app/lib/mobile/widgets/mobile_tab_bar.dart | 2 +- app/test/mobile/mobile_bootstrap_test.dart | 6 +- app/test/mobile/screens/keys_screen_test.dart | 111 ++++++++++++++++++ 11 files changed, 148 insertions(+), 57 deletions(-) diff --git a/app/lib/mobile/mobile_bootstrap.dart b/app/lib/mobile/mobile_bootstrap.dart index 2c83a03e..569eeaf4 100644 --- a/app/lib/mobile/mobile_bootstrap.dart +++ b/app/lib/mobile/mobile_bootstrap.dart @@ -11,6 +11,7 @@ import '../providers/port_forward_provider.dart'; import '../providers/session_provider.dart'; import '../providers/settings_provider.dart'; import '../providers/sync_provider.dart'; +import '../services/key_gen_service.dart'; import '../services/port_forward_service.dart'; import '../services/sftp_transfer_service.dart'; import '../services/ssh_service.dart'; @@ -36,6 +37,7 @@ class MobileBootstrap { late final SftpTransferService transfer; late final PortForwardProvider portForwardProvider; late final PortForwardService portForwardService; + late final KeyGenService keyGen; MobileBootstrap() { storage = StorageService(); @@ -59,6 +61,7 @@ class MobileBootstrap { portForwardProvider.setStatus(id, status, error: error), onConnections: (id, n) => portForwardProvider.setConnections(id, n), ); + keyGen = KeyGenService(); hostProvider.onHostDeleted = (id) => unawaited(portForwardService.stopForHost(id)); unawaited(portForwardProvider.ready.then( @@ -97,6 +100,7 @@ class MobileBootstrap { Provider.value(value: transfer), ChangeNotifierProvider.value(value: portForwardProvider), Provider.value(value: portForwardService), + Provider.value(value: keyGen), ]; /// Disposes the objects exposed via `Provider.value` (which does not dispose diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index 23c9e046..bbc10999 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -171,17 +171,9 @@ class _MobileHostsScreenState extends State { ], ), ), - // TODO(later): wire overflow menu — visual placeholder. - _CircleButton( - icon: Icons.more_horiz, - onTap: () {}, - ), + const _DecorativeCircle(icon: Icons.more_horiz), const SizedBox(width: MobileTokens.space2), - // TODO(later): wire user/profile action — visual placeholder. - _CircleButton( - icon: Icons.person_outline, - onTap: () {}, - ), + const _DecorativeCircle(icon: Icons.person_outline), ], ), ), @@ -202,7 +194,7 @@ class _MobileHostsScreenState extends State { prefixIcon: const Icon(Icons.search, color: MobileColors.textFaint, size: 20), filled: true, - fillColor: const Color(0xFF1C1C1E), + fillColor: MobileColors.fieldFill, contentPadding: const EdgeInsets.symmetric( vertical: MobileTokens.space3), border: OutlineInputBorder( @@ -496,25 +488,22 @@ final class _HostRow extends _ListRow { // ── Helper widgets ──────────────────────────────────────────────────────────── -class _CircleButton extends StatelessWidget { - const _CircleButton({required this.icon, required this.onTap}); +/// Non-interactive decorative circle icon — purely visual, no tap target. +class _DecorativeCircle extends StatelessWidget { + const _DecorativeCircle({required this.icon}); final IconData icon; - final VoidCallback onTap; @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: MobileColors.surface, - shape: BoxShape.circle, - border: Border.all(color: MobileColors.border), - ), - child: Icon(icon, color: MobileColors.textMuted, size: 18), + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: MobileColors.surface, + shape: BoxShape.circle, + border: Border.all(color: MobileColors.border), ), + child: Icon(icon, color: MobileColors.textMuted, size: 18), ); } } diff --git a/app/lib/mobile/screens/mobile_keys_screen.dart b/app/lib/mobile/screens/mobile_keys_screen.dart index ed864347..64af55a8 100644 --- a/app/lib/mobile/screens/mobile_keys_screen.dart +++ b/app/lib/mobile/screens/mobile_keys_screen.dart @@ -300,7 +300,10 @@ class _GenerateSheetState extends State<_GenerateSheet> { ); } if (!mounted) return; - await keyProv.addKeyFromFile(result.privateKeyPath, name); + final entry = await keyProv.addKeyFromFile(result.privateKeyPath, name); + if (_passphraseCtrl.text.isNotEmpty && mounted) { + await keyProv.savePassphrase?.call(entry.id, _passphraseCtrl.text); + } if (mounted) Navigator.pop(context); } catch (e) { if (mounted) setState(() => _error = e.toString()); @@ -601,7 +604,7 @@ class _MobileField extends StatelessWidget { hintStyle: mobileBody(size: 15, color: MobileColors.textFaint), filled: true, - fillColor: const Color(0xFF1C1C1E), + fillColor: MobileColors.fieldFill, contentPadding: const EdgeInsets.symmetric( horizontal: MobileTokens.space3, vertical: MobileTokens.space3, diff --git a/app/lib/mobile/screens/mobile_port_forward_screen.dart b/app/lib/mobile/screens/mobile_port_forward_screen.dart index b1e47021..136b2e62 100644 --- a/app/lib/mobile/screens/mobile_port_forward_screen.dart +++ b/app/lib/mobile/screens/mobile_port_forward_screen.dart @@ -52,13 +52,7 @@ class MobilePortForwardScreen extends StatelessWidget { ], ), centerTitle: true, - actions: [ - PopupMenuButton( - icon: const Icon(Icons.more_vert, - color: MobileColors.textMuted, size: 20), - itemBuilder: (_) => const [], - ), - ], + actions: const [SizedBox(width: 8)], elevation: 0, ), body: Column( diff --git a/app/lib/mobile/screens/mobile_settings_screen.dart b/app/lib/mobile/screens/mobile_settings_screen.dart index ad0a4350..85f9c7d5 100644 --- a/app/lib/mobile/screens/mobile_settings_screen.dart +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -61,12 +61,7 @@ class _MobileSettingsScreenState extends State { backgroundColor: MobileColors.bg, surfaceTintColor: Colors.transparent, title: Text('Settings', style: mobileHeading()), - actions: [ - IconButton( - icon: const Icon(Icons.tune, color: MobileColors.textMuted, size: 22), - onPressed: null, // placeholder - ), - ], + actions: const [], ), body: ListView( padding: EdgeInsets.only( @@ -181,10 +176,9 @@ class _MobileSettingsScreenState extends State { }, ), SettingsRow( - leading: Icon(Icons.lock_clock_outlined, color: MobileColors.textMuted, size: 20), + leading: const Icon(Icons.lock_clock_outlined, color: MobileColors.textMuted, size: 20), title: 'Auto-lock', value: 'After 1 min', - onTap: () {}, // placeholder — future enhancement ), ], ); diff --git a/app/lib/mobile/screens/mobile_sync_screen.dart b/app/lib/mobile/screens/mobile_sync_screen.dart index a8e01c0a..e2da4fe8 100644 --- a/app/lib/mobile/screens/mobile_sync_screen.dart +++ b/app/lib/mobile/screens/mobile_sync_screen.dart @@ -247,13 +247,7 @@ class _MobileSyncScreenState extends State { tooltip: 'Settings', ), title: Text('Pair device', style: mobileHeading()), - actions: [ - IconButton( - icon: const Icon(Icons.more_vert, size: 20), - color: MobileColors.textMuted, - onPressed: null, // placeholder - ), - ], + actions: const [SizedBox(width: 8)], ); // ── Hero copy ───────────────────────────────────────────────────────────── diff --git a/app/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart index 960f5bd0..a8ee134c 100644 --- a/app/lib/mobile/screens/mobile_terminal_screen.dart +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -387,12 +387,6 @@ class _TerminalHeader extends StatelessWidget { ], ), ), - IconButton( - icon: const Icon(Icons.call_split_outlined, - color: MobileColors.textMuted, size: 20), - tooltip: 'Split', - onPressed: () {}, - ), IconButton( icon: const Icon(Icons.more_vert, color: MobileColors.textMuted, size: 20), diff --git a/app/lib/mobile/theme/mobile_theme.dart b/app/lib/mobile/theme/mobile_theme.dart index 141795db..62878434 100644 --- a/app/lib/mobile/theme/mobile_theme.dart +++ b/app/lib/mobile/theme/mobile_theme.dart @@ -17,6 +17,10 @@ abstract final class MobileColors { static const red = Color(0xFFFF6464); static const blue = Color(0xFF4DA3FF); static const yellow = Color(0xFFFFCE35); + /// Input field fill color. + static const fieldFill = Color(0xFF1C1C1E); + /// Tab bar inactive icon/label color. + static const tabInactive = Color(0xFF6D6D72); } /// Builds the dark [ThemeData] used by the mobile UI. diff --git a/app/lib/mobile/widgets/mobile_tab_bar.dart b/app/lib/mobile/widgets/mobile_tab_bar.dart index b5a7ddcc..be7ced9a 100644 --- a/app/lib/mobile/widgets/mobile_tab_bar.dart +++ b/app/lib/mobile/widgets/mobile_tab_bar.dart @@ -48,7 +48,7 @@ class MobileTabBar extends StatelessWidget { final MobileTab current; final ValueChanged onSelect; - static const _inactiveColor = Color(0xFF6D6D72); + static const _inactiveColor = MobileColors.tabInactive; @override Widget build(BuildContext context) { diff --git a/app/test/mobile/mobile_bootstrap_test.dart b/app/test/mobile/mobile_bootstrap_test.dart index ab998d69..9df49b6b 100644 --- a/app/test/mobile/mobile_bootstrap_test.dart +++ b/app/test/mobile/mobile_bootstrap_test.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:yourssh/mobile/mobile_bootstrap.dart'; import 'package:yourssh/providers/port_forward_provider.dart'; +import 'package:yourssh/services/key_gen_service.dart'; import 'package:yourssh_snippets/yourssh_snippets.dart'; void main() { @@ -44,7 +45,7 @@ void main() { test('port-forward provider is in the providers list', () { final b = MobileBootstrap(); - expect(b.providers.length, greaterThanOrEqualTo(13)); + expect(b.providers.length, greaterThanOrEqualTo(14)); }); testWidgets('providers resolve from a child context', (tester) async { @@ -63,5 +64,8 @@ void main() { // would throw here even though field-access checks above would pass. expect(Provider.of(childCtx, listen: false), isNotNull); expect(Provider.of(childCtx, listen: false), isNotNull); + // Regression: KeyGenService was missing from the provider list prior to + // the fix; opening the Generate sheet would throw ProviderNotFoundException. + expect(Provider.of(childCtx, listen: false), isNotNull); }); } diff --git a/app/test/mobile/screens/keys_screen_test.dart b/app/test/mobile/screens/keys_screen_test.dart index 25f5551a..7dcdadca 100644 --- a/app/test/mobile/screens/keys_screen_test.dart +++ b/app/test/mobile/screens/keys_screen_test.dart @@ -1,5 +1,8 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -12,6 +15,7 @@ import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/services/key_gen_service.dart'; import 'package:yourssh/services/storage_service.dart'; + // ── Fake KeyProvider seeded with 2 keys ────────────────────────────────────── class _FakeKeyProvider extends KeyProvider { @@ -22,6 +26,32 @@ class _FakeKeyProvider extends KeyProvider { List get keys => _seed; } +/// Tracks savePassphrase calls so tests can assert them. +class _TrackingKeyProvider extends KeyProvider { + final List<(String id, String pp)> savedPassphrases = []; + SshKeyEntry? _lastAdded; + + _TrackingKeyProvider() { + savePassphrase = (id, pp) async => savedPassphrases.add((id, pp)); + } + + @override + List get keys => _lastAdded == null ? [] : [_lastAdded!]; + + /// Override to avoid disk I/O in tests. + @override + Future addKeyFromFile(String path, String label) async { + _lastAdded = SshKeyEntry( + label: label, + algorithm: KeyAlgorithm.ed25519, + publicKey: '', + privateKeyPath: path, + ); + notifyListeners(); + return _lastAdded!; + } +} + // ── Fake KeyGenService — ssh-keygen present ─────────────────────────────────── class _FakeKeyGen extends KeyGenService { @@ -29,6 +59,25 @@ class _FakeKeyGen extends KeyGenService { Future probeSshKeygen() async => true; } +/// Fake KeyGenService that immediately returns a canned key path without +/// touching the filesystem. +class _InstantKeyGen extends KeyGenService { + @override + Future probeSshKeygen() async => false; + + @override + Future generateEd25519({ + required String name, + String passphrase = '', + required String dir, + }) async { + return const GeneratedKey( + privateKeyPath: '/tmp/fake_key', + publicKeyLine: 'ssh-ed25519 AAAA fake@test', + ); + } +} + // ── Pump helper ─────────────────────────────────────────────────────────────── Future _pump( @@ -87,6 +136,12 @@ Future _pump( // ── Tests ───────────────────────────────────────────────────────────────────── void main() { + setUpAll(() { + // Disable Google Fonts runtime network fetching so tests don't make + // real HTTP requests (which are blocked in the test environment). + GoogleFonts.config.allowRuntimeFetching = false; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); testWidgets('shows title "Keys"', (tester) async { @@ -147,4 +202,60 @@ void main() { // subtitle "1 key · 1 in use" expect(find.textContaining('1 in use'), findsOneWidget); }); + + // ── Regression: generated passphrase must be persisted ─────────────────────── + + // ── Passphrase persistence is tested as a unit test (no widget needed) ─────── + // The _GenerateSheetState._generate() code path is: + // entry = await keyProv.addKeyFromFile(path, name) + // if (passphrase.isNotEmpty && mounted) keyProv.savePassphrase?.call(entry.id, passphrase) + // We verify this contract directly, avoiding google_fonts/path_provider + // complexity in a widget test. + + test('generate with passphrase: savePassphrase is called with key id', () async { + final trackingProv = _TrackingKeyProvider(); + final keyGen = _InstantKeyGen(); + final tmp = Directory.systemTemp.createTempSync('ys_test_'); + + const passphrase = 'super_secret'; + const name = 'my_test_key'; + + // Mirror the production code in _GenerateSheetState._generate(): + final result = await keyGen.generateEd25519( + name: name, + passphrase: passphrase, + dir: tmp.path, + ); + final entry = await trackingProv.addKeyFromFile(result.privateKeyPath, name); + if (passphrase.isNotEmpty) { + await trackingProv.savePassphrase?.call(entry.id, passphrase); + } + + expect(trackingProv.savedPassphrases, hasLength(1)); + expect(trackingProv.savedPassphrases.first.$1, equals(entry.id)); + expect(trackingProv.savedPassphrases.first.$2, equals(passphrase)); + }); + + test('generate without passphrase: savePassphrase is NOT called', () async { + final trackingProv = _TrackingKeyProvider(); + final keyGen = _InstantKeyGen(); + final tmp = Directory.systemTemp.createTempSync('ys_test_'); + + const passphrase = ''; + const name = 'my_test_key'; + + final result = await keyGen.generateEd25519( + name: name, + passphrase: passphrase, + dir: tmp.path, + ); + final entry = await trackingProv.addKeyFromFile(result.privateKeyPath, name); + if (passphrase.isNotEmpty) { + await trackingProv.savePassphrase?.call(entry.id, passphrase); + } + + expect(trackingProv.savedPassphrases, isEmpty); + // entry is still created + expect(entry.label, equals(name)); + }); } From 03dad5c8a78d23afd773e54482307a4d0e710158 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sat, 27 Jun 2026 09:19:08 +0700 Subject: [PATCH 90/96] fix(mobile): folder filter chips use uniform large pill style --- .../mobile/screens/mobile_hosts_screen.dart | 4 ++- app/lib/mobile/widgets/tag_chip.dart | 36 ++++++++++++++++--- app/test/mobile/widgets/primitives_test.dart | 15 ++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/app/lib/mobile/screens/mobile_hosts_screen.dart b/app/lib/mobile/screens/mobile_hosts_screen.dart index bbc10999..fc0ba603 100644 --- a/app/lib/mobile/screens/mobile_hosts_screen.dart +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -221,7 +221,7 @@ class _MobileHostsScreenState extends State { // ── Folder chips ───────────────────────────────────────────── if (tags.isNotEmpty) SizedBox( - height: 34, + height: 38, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( @@ -232,6 +232,7 @@ class _MobileHostsScreenState extends State { const EdgeInsets.only(right: MobileTokens.space2), child: TagChip( label: 'All', + large: true, selected: _tagFilter == null, onTap: () => setState(() => _tagFilter = null), ), @@ -242,6 +243,7 @@ class _MobileHostsScreenState extends State { right: MobileTokens.space2), child: TagChip( label: tag, + large: true, selected: _tagFilter == tag, onTap: () => setState(() => _tagFilter = _tagFilter == tag ? null : tag), diff --git a/app/lib/mobile/widgets/tag_chip.dart b/app/lib/mobile/widgets/tag_chip.dart index 10928473..fd03a4c6 100644 --- a/app/lib/mobile/widgets/tag_chip.dart +++ b/app/lib/mobile/widgets/tag_chip.dart @@ -5,25 +5,51 @@ import '../theme/mobile_tokens.dart'; /// Compact pill for a host tag or a filter chip. [selected] fills it with the /// accent color; tappable when [onTap] is provided. +/// +/// [large] switches to the folder-filter style used in the Hosts header +/// (uniform 15/7 padding, 13px text, no border) so short labels like "All" +/// stay pill-shaped instead of collapsing to a circle. The default (badge) +/// style is the tiny tag pill shown on host cards. class TagChip extends StatelessWidget { final String label; final bool selected; + final bool large; final VoidCallback? onTap; - const TagChip({super.key, required this.label, this.selected = false, this.onTap}); + const TagChip({ + super.key, + required this.label, + this.selected = false, + this.large = false, + this.onTap, + }); @override Widget build(BuildContext context) { final fg = selected ? Colors.black : MobileColors.textMuted; - final bg = selected ? MobileColors.accent : MobileColors.surface; + final bg = selected + ? MobileColors.accent + : (large ? MobileColors.fieldFill : MobileColors.surface); final chip = Container( - padding: const EdgeInsets.symmetric(horizontal: MobileTokens.space2, vertical: 3), + padding: large + ? const EdgeInsets.symmetric(horizontal: 15, vertical: 7) + : const EdgeInsets.symmetric( + horizontal: MobileTokens.space2, vertical: 3), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(MobileTokens.radiusPill), - border: Border.all(color: MobileColors.border), + border: large ? null : Border.all(color: MobileColors.border), + ), + child: Text( + label, + style: TextStyle( + color: fg, + fontSize: large ? 13 : 11, + fontWeight: large + ? (selected ? FontWeight.w600 : FontWeight.w500) + : FontWeight.normal, + ), ), - child: Text(label, style: TextStyle(color: fg, fontSize: 11)), ); if (onTap == null) return chip; return InkWell( diff --git a/app/test/mobile/widgets/primitives_test.dart b/app/test/mobile/widgets/primitives_test.dart index cef7d837..cff85590 100644 --- a/app/test/mobile/widgets/primitives_test.dart +++ b/app/test/mobile/widgets/primitives_test.dart @@ -63,6 +63,21 @@ void main() { final deco = container.decoration as BoxDecoration; expect(deco.color, MobileColors.surface); }); + + testWidgets('large filter chip uses 13px text, no border, fieldFill bg', + (tester) async { + await tester.pumpWidget( + _wrap(const TagChip(label: 'All', large: true, selected: false))); + final container = tester.widget(find.byType(Container).first); + final deco = container.decoration as BoxDecoration; + expect(deco.border, isNull); // folder chips have no border + expect(deco.color, MobileColors.fieldFill); + final text = tester.widget(find.text('All')); + expect(text.style?.fontSize, 13); + // padding wide enough that a 3-char label stays a pill, not a circle + expect(container.padding, + const EdgeInsets.symmetric(horizontal: 15, vertical: 7)); + }); }); // ── SectionHeader ───────────────────────────────────────────────────────── From 655a66b5c9d3eefc83bd1c4b2a366a9e46ae9d92 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sat, 27 Jun 2026 09:22:54 +0700 Subject: [PATCH 91/96] fix(mobile): snippets category filter chips use uniform large pill --- app/lib/mobile/screens/mobile_snippets_screen.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/lib/mobile/screens/mobile_snippets_screen.dart b/app/lib/mobile/screens/mobile_snippets_screen.dart index de95fee4..0fc7fd99 100644 --- a/app/lib/mobile/screens/mobile_snippets_screen.dart +++ b/app/lib/mobile/screens/mobile_snippets_screen.dart @@ -77,7 +77,7 @@ class _MobileSnippetsScreenState extends State { // ── Category filter chips ──────────────────────────────────────── if (tags.isNotEmpty) SizedBox( - height: 36, + height: 38, child: ListView( padding: const EdgeInsets.symmetric( horizontal: MobileTokens.space4), @@ -85,6 +85,7 @@ class _MobileSnippetsScreenState extends State { children: [ TagChip( label: 'All', + large: true, selected: _selectedTag == null, onTap: () => setState(() => _selectedTag = null), ), @@ -92,6 +93,7 @@ class _MobileSnippetsScreenState extends State { const SizedBox(width: MobileTokens.space2), TagChip( label: tag, + large: true, selected: _selectedTag == tag, onTap: () => setState(() => _selectedTag = tag), ), From fbcf2616f71dd5706b3302d35f17cf1c76ec0165 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sat, 27 Jun 2026 09:28:24 +0700 Subject: [PATCH 92/96] feat(mobile): add Android launcher icon (adaptive amber terminal glyph on black) --- .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 11896 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 5785 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 18527 bytes .../ic_launcher_foreground.png | Bin 0 -> 39503 bytes .../ic_launcher_foreground.png | Bin 0 -> 70673 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 ++++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 3438 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 1849 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 4834 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 9442 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 14729 bytes .../app/src/main/res/values/colors.xml | 4 ++ app/assets/android_adaptive_fg.png | Bin 0 -> 388079 bytes app/pubspec.lock | 48 ++++++++++++++++++ app/pubspec.yaml | 11 ++++ 15 files changed, 72 insertions(+) create mode 100644 app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/android/app/src/main/res/values/colors.xml create mode 100644 app/assets/android_adaptive_fg.png diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..ba11f5fbd7949566f3c5c4cd12b6f2fdbe254c98 GIT binary patch literal 11896 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 Ef+c1kivR!s literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb3755fb3f401d2fc9df468a38cccb1ca17e544 GIT binary patch literal 39503 zcmcGV1Cu5{6Tnxuwr$(CZQC|(Z9Qw-wr*|Pw(Z+n``!PCcvCf1QXCib4b^3)=0?%A%KyBh zF-TMH#l}q85)>qaG_TC4H1J@BTbKu{23z;%$$7rHDP#QejECOGG) zzOf3rk#OS~bHBJNcTl>u2C8?0OTQJg7g04J`fhx+Rs9YTXX)GFq`NwNyubVcdDi+2 z8+#s?Ja<^)9r6=?qjz%y!Z@|>7%AxP_dz(hY(X>J#i?WbR1!$}7P=Sf+Ut+8_}nsw znc^fGV#7*w54a#G@65Wb9g(RsTUxDf2c3AlgI1iJDiO)ti7U4cNNp(Zg)!pH&cwCf zZn3-g^5&5Hx=G!&?YS#8aQIrTvv=wJd}X+Of334$cOG4_8-(Bj@IW*s62`_MM2f)c zwmolP&>A$p33@#3$m|D1IISUobl|t)_o)WjTZcdvIb+vnQtw^sB=Os>V+vbZ22UOc zb`lXSDF`N?j!X;B=GuGT9|(KW)NHPZL5@931!LE!-$wTWLkIr;6IbvW_8`9rD5hz` zXy0}ERtH0Oojo3ILb8Irlxet=U&2ADG^+h>VTYLtFnYCT?X8Tdq^iB}l&_E~-2V(UO>0=$`ZxmALKBt*hlS9q%t?j`o=uk*t)>rkCKZ^%pk$CJX*uhHh ztfwi$D}`yw6=K48bV8TF>I48wY;rK*M=Pl3P`TV?NL-9_lIyglHS#M?I~KmnEKBEdsZ>l zB_Ugq@Nkyb%elG`>L|l=h*_q;B707!~f0ZdpGC1%WCn8D7rC-kY0E0nStN@ z;JnUjf6d>2`}h!9^!FlNmLtb}`eISTvH=l~#5&*h>5bhhj~FE3FJlITj8}#?TQV}rO|qnS z5@{)E&xgN)dqt^N+2|;Y1iC3KP}c@qPv{qQt?+Gm{MbTy-N;d2;m%pZ^T%ppM~7TG zZx6om`j9y+>+_7Prmg#t`8hW>JWzTV=BjeIUmGO3;-~u4FbPt#moBxlxD1X>f@Tua z4USWA?cVdkZ$qZ+7Z8@i3#){vGB>?P1o1!Shs5G#xmw*g*}fiKwMQCrTnMt(z-K^@ z{f}L@L>)jR+3rgy-er83eS8LQgDB`e_bLWmx556;H9X&*_o?H1znOni3(=1L%Lu3U zUDDBKx=SU=11a->9PYy~Io77@n@$qjtP<1Q!OcWz3JIhTm`6~{64?%wjt^=BiqDuZ z#7Nw4iszb=J`(TjdqL+ue&rzAkrXCJ)B_`mU(Pc$fY*Lk$?1zMBk6(4=(E`-5@puFRR4Ui7 zA-$r}REkTXs5Opwc)qtmE#aDQ)rMw>@q^`@(bHII*@;qOVN$*h|Es6P9Vq8*>0)ry zm-0IWju!C!{({MS8yTMEdwh+m_zZ`EKRllU#m|HY`IL(%;{)Jh^6KV~Z&RjoG{`A@JON|@3umKjoGCR-*+@i9u(7%eCMS{0woyr+}B8@B;PO1DC`R_kc)c z7dFhf5@aJq0gGjaALToF?FODy%W@n(r$-k#V$s%8*^$QBi23+bDhMLwp5vvU7CbRO z%g7)n>;m(#?>s@xJt#n$ex|{jPr6k(6+8ktQ_#u1mabfSk~O4Tkm9ny(c(!U@>H6u zz*}L(hX8`T(EoPqgKBqY*$=m^cNQ({s zeEapl=P`={tPe--i&e-L1IUGCP7)R~Jpvx^4Ag3Q!|t4d`xe5*il?V!3oW&%A`_iH zKK5v2X9w;$qVMi5>3`&YI!QxKe`u8=d^ER&kMdbqah+_}bf$GLu2~3=ID5Ntphs4b z+vxA=eAR#NpAl*ld;51i8~XuXjXifQIUB?7Y~tD%R-TWNSa6%hF&<2Ghn`+407o3` zgutd_eF~oPP`H6bMt_24)FDs9T>Z(T`F++-*W7kp1hyAl7%ahQzmG3C3rg=ZzJIU( zd?iBgIH1w%eJIoWRONls3J&M7>qIlIwh>68`T1j%yH0^h2rxq4PIELa6aL;XbuUrWH28z6~R zi)&GdkA4wFj?iVpV*Ek$Xar8jAm)ZRk(3+b}X#`yEzSSt32>tni-I zFyjF`f8e~fpRfEg0!ht6Lqb&v+v$>?4nwX`&i(Cu?Rwgv-H2J8g>{2fj>;s1MqV!p#ElgumynU2;U4%QMYEj2(1kN4v2|!-L9g%s+?_Q0{qC^Met2*njWPsE&}#V_ zdP@JjQ0QqSwi_;FB~YO!GRBR@FmwVgqMTZU4pAMKok4n7L=8sxNm?gELWL}cI?>ze z&0RniP-$k&Q8E?-4l&;|O!SHBj(cefv|MQYPpwI4ysjqpPrQZ=`1D`qa`rZ>1lTqr zx;fs&YR_se@{KgiCGz?GfFY~K$t?1g{8QC96=~M%YAgL^Vm`2iJB8Y~;7xu5I|oz9DT>f#PtiZce!jlkbOSU@a`KXDtBi2Vc2*X>eS}C!eVxB%6;#5o;ad! zY5njkRi$>^(bD4Cy3U)DYuN1muad)ilA1;DRgBII)=a+4Of=HapUyfyt2~UJGeEx$ z_^&7HcPYj?RE3wJ3gA!_L#5_BaHz&gG(2lInr`Dq-@epg5rui4wqv<-fo99Zk>ji+ zjb{lN{6B(6;x#O)otckWvO$;V91p*t;=pHm1`#E%C1GNsLI{hu)F@1K4IK$6>X#pJ;6_F;10H~qYJ zZ*~UnV1$hcFISj?Gj&q?9Z|y_2mV~l-Z0-mhBJpq&2l~tVj^)gbW5+5%^t9OPi)DG z`3SOxzYvr5TUCi?u0vG}mTPXfMNL4yB@aJB^{+y-;%^VsX|KOkjq8O?r<>lX$fphK zU*elMFydP5Zt2A$C9#(6JkFH|i&owu@OS^XzA6Fv5taLwL{@djU6Opl2YyK=?%FA%w9ESMM$gc^>ye!XC+W$)HTAwb;enuw#IUO(%?b zU5HQzvPHqmE z6De!}815>MyqiZ}!rww=tF0)x5aiLW&FJYHxpt%KpQ#~xSjGZ!4i8z;4U_zB^DsQ6 zc1PNrEls|$8Ff!KlBjIBOr&7Sl;6SN7q7ysjY;~0MUa_u6r=c%qg38=WPT-1;zF&S z-rh3sZl>Fo9dk$PLyRd{uBkH!aekXsslCT9c{}$T835m0q@b6=qEy~bu5eVF%lJv+ z2)LqQ`p)cVFgPSsT8SihdM8`sCIAN5bY;~$%3aoYtU1pD+{BXXxe|b}5gt4d(!>jf z{2;?Rs#;1WBTr1*Lsb zlk!!LTU;aI;*EoEw7k}uBM=xbMbeINRs-pK;|$*RlmnZ_ca7_WIHeSGfksy1N|Btf zR<%((o%C_eC?&I<_+a=}Z1lSPvAnoU#U!8kD?OjV3n>(Fivrv8nAp?G zBC9Tj?SUh4mr&FN&Dv`kr}oTr!9&l1b!IxY4c}!9E|BOFg4fRtPs(z3 zt7pSy!EJSv?V=M-Kl16w++OpYfi>ep6X`GnnX6_f(aUbd&&(&OSOBwrYYwf0yZlt^ zfK%WK)B+_7g_I|9NtG$1F_KQjM!TG#U5nF07Xb<^OE3)jwVYUKTP6kQ zf`iY$+8vhr!TV{!^QSXxb+Itac$I?9xK1poSSQ?ecIbjAO>#kwul|7XrD---s zaqDkaFM)rS&uZ^RoDB8j#zC9vN(yw-5v@pqx%L%3>Xeq^u_bLO9PK_U!z!dpIF*%P zF4ix8Tb_rXC~EDJDY&2MtfbGpYynQ3{_wLFsLz`B%q=3GI}Rq#He2J-j25OuQ%)u= z^g}&YzgKaNlHXQusq%RSIK){ zZ|48ZJ02Z`EE+FLA=1+9_CT>NsPtehEP3>S&NZxP$dat^aW-v-o68cscx|VLJAb;) zTDC)I*Y-bjK72vk-;mFK*C+5-dPc6Fv+DrR+k(XE+Wd&Swn?RK#-=uEX&5=6hQ6{kCn;etz5{R!61g$Q~Os8l7*I174ukp3QC;Y1(`h8 z-$^v^SHnGop!Lc-lxgXiSY+2}Wu=V-5<|1;UDkNZJq9>4o= z$7n4jyi335tw^+1DKj3Fog{l}Gkel+FHI`ifibxnzb+nU+v=CUEp|on<-!(VPPZxd z=(l{paST38<%#>;5o#l(fn`w>mV>w=1Na*Bbcm(dFajHlkJI08GrL^}7992+ja5(P zrlwQ06W1R(m+U*PA6%Qc->v=)&%+9yw%?o_)KPp)@^`s-pSXreMb@}7>p)D3(6 za8Fu63Ty7SVp0AL`!}5(mM#`xcXx4kyZ++?6~5hiNM2MOR zx*D^Ilw_1^N`UX@M-=%@$2oZ~0!F92w;0&l{BH_ta*&2bYX*FaXoQSc7FPzAetHWg z*9PAa!05K>JE-&Z0qXv-DhtcP{M+thl!4vBXZ!tz*KXT@{{7@ns9&BR5_pSx)c97x z7xj7~?Xn@od(2d8y7elPd@SV2x4T<^}G zyV2f0hmWtoo8+bYWR>whqr|GsIc{)-$6*9@{q?8#_ZqItJvUqSCy(fa>QqkN)MBO| zYVSxDu79(i6su*9zhi=MzCJXb^?E%gTV{CYerV5c{+mvJYuCfN7JdIAU>J!#y_C!R z*m+CtD-qb8xgD=71OgLd(2jY-IfFU1cWx3uw?0!?wwSV50V$M9KbO1*6mB?iLfW0m zT3h>C^6+tVbbF|?!mbm;Ruq#R3vhOG#Ti(e8#nK9z3yinp*YRMdv`#U5*a_7sog&NcnFTCaIBM zN+IXcND}rw+2c6#7(Kgcn?HS;&d#(j%DCSj8~2!4v4ZWj7CWvom93 zYsUpHJQqt0*{Rdw_e$y%NSUCMnE^)H!f%dqctWN!%p>*PLrgfbnyr<5MCNF<2GR1u z-#_^51huIl#f2(JGKu^Y$>NZF1IhY?8t(BDG6zZYQWs0snurRlPR1j#Hh|ko#n9AS zT6|PFNYx`RA&HBi|ne_ng?SDs{6T55)~s`4*obQH;F; z6vot4Sn?zl9pdQii9!I*-OL=U>=hK-Dpa9GYlXDs+x7eNZ8!Y{1^~VI-!H_YldizQMaK9K?`hS9gw?stBxQ8+)k_JirD@aYZooCUEUpuTGpni161*S5jEfHI<5xHc%xW+oP5#K4* z88C1Vgc*}HZohQgqS?kzB~4Hy3(vxQLM{5RJa0pXj@rIMq%u!RC0}+ncO7P3BDsPM zQf`yDH~i*qrz13Ok?<_M|Q9NW{wb)OC$-dM$QHVb0SXoYF z3Oj%&Vn;D5-X)?K#8?O%GD(z_EfSPDYgrLBY5XL_&wQ#;&L*DALYOM$#=`GSX&Um7 z11k21R#coOEaBxZNC_vp^I5b_e_j+S7Y!hy;uv5BF%HWbIul+fYLRdS*t-Gf@7hfF z4afq(Y%~@={s5*jFwIS;^eAv`9S9LDYm51>>CxT;1NSuNx;XM`74JoPhx6MT~iJ<2)A|w z{;023P*^9NQ`kli;eqpIw=u<&XR1FPc4#FJ8V$O5mDASHlN?hU;gVMt3CSK<;$w&=lLob?{z=VMmhR-;z zm5>`BYwJQkypdT1WpoI3}YhR=}*;Y05bt5T=aH^z(6 zMv3MVof6&1Z{crweh<8p+(;{iV6u}+2obe==CbQ4VquCsciH53sKe81z3bok7BtBz zta7r!Ny4k~&9k4{ljzg!$94`XQE^y_5laU zMX~w&ap`ArCa5y&^L|QGJg1>qyLVw21djGxn84jJ?!TKw%yZ1AMhAnt2$gn5?}?w0 zb~m!iPYe{_;C$9d^r3$8JY%MzscUTDR!H{ zunhfT)AL?r`2m7t5je0Er*Ne$@Mjm!!ibi}`R$g(%anD%johwWs;9|(GDwLVRI2t_ zVO24vYj=O@C(|rjY^}?P+06#$JCVI=aFsTvulhQU(Py$ym(@{yzeGSVu>ENlB&_xKf;FhauaPKefCvn4ks_U+>|i~p zmR_1;1|Nfvk%fdy(Oc87087wmw8*@~prqy833L+&997!SS;?Vwt}VyU??ay-Ey5X# z6~=m>0s}*HV+4ptM`Ud%1N~;tv6eJ3DXo;safh2CmyHngC9GVGK5Wd{B1w*yDpv^^ zRIz4dfnPx22bO}cq15~nwH#>89)AO&2OA+TE&wqV{`(~MJ8<0Pq6Nh1>ViC3ClPLE zL_jo_iX5$WRj6Ou{YXT;(^-o+D#7G#YX*}Lu^rFo#QG)-UO`_vf6ZwasH5#`?6}B2 zQFoJbcIh*dZ!~c;;;R|6U3d*RJx-IB{{kxISMxcAwq zb`fUL0=YFkn!-C%Q1%hy7Ivw-xF>Sly`0PAF>`&2h}bjx9%I3Cw1A70U9&bGXpbZ+ zmp=ntYvQncA;fYOL4f0pArRkp5I$hvzRG5uL02x}1QK3Wc!+-jdHHoYW3#%98Lb=tZ4h zoqU3UpN6DA)5sx+9);auf^c117v<-fhd#dbWKtCg)y@VoT&nKk2;XWv-(VPGcQ5u9 z=wCmP%3`c{%3sIC-_XDPmtCl(a9%h{v4->nB+GhJz|GJXi$EWnGX;Y_r@%t&m$`a$9-L0VWV`|>d}L|#?&K|&z+b`RMNU0RA!ZP* zH=>n1^8!QuK3H;Qi2^$eN{sl$1~r6StQ*dPqbCrI-gM$`?a|CB%sg(Go8wG10y)qG zFQ@X*2uoxE#*SFhTJIPcneUXIv{cg&7ha6ezdjw04NI%!N@`tV=KvNz@(YGA9Hw$` zsXs(*aQFP+uR(9 zHdHXWeODlWwt`f9)!SLo(F&3c4*wDD#8FTrJNvl10xNUP__}S5)o<^zr*bub&*$nb(-?}1j1q{k!;-L4APc@uE&OKP0-+3xv47Wf` zK+)5V%ce!&yYBR7n}YTFXNTS9Ifk;ow7AF=?TTYXDDg;dj^Z_2<3kC zi)Z=9pgsNsIv2EMwVVq6e%I4|p91OLT3lb!%;&DgRl+sOrIpC5i(JiqI*KBl{ev=iolcCdkM(Fy}6vPH8y)*5#=sLG}6CcF|lTs_(F^A&) zL=^rMtCp}xxA^4x+&?v}IKU!m(1LJvbP|eQWVUFs?{##Gu>ZpBzXge>d6?$TENcPA znB-{-2|2toC4^}6BUYW{Cpo=wjwZa~_-dTToD3P;b_%<2=81W$5?8;4DN#{~SKV2* zh^h;6@}TluQ0uF#N=k_^X1}x^az4=tippES;{xoO)p&wEUgJ4Iz9??WdKgb(dp)P+ zS@MnGvVKA6`Vo=fiqfUWnF%AM@EP2uodTwmhWT^p3F#)Jv~j-WM+sj!?bea>z)-w# z7us@3XaHpg-32QpliqKK$!be6*Ga*<6Gaxl44G}ndNLaY(pt(G?NW=CqQPS6!6-YV z*oJ!Z{@MWL>Y4VQRd3cTwLon3P6`*iOm+mLWnoY{{ZB$h6CP`7UlC&A;v-Eq1RjO7addu$Q=LcCeTWg^QF<+ok-)+RIU1z)z}OwR8q zGi&+88(SV(e0*-Xo4trVPKpEBtk4=_Km7;BD;6>w+?$Z-vof+V6QxfSaSp%8Z~J|M7#{=plGb@gM63i9L2sI)F5GX@;0rp zk@b1r+oXZY9mBB&^vrq!j2y9Oi26?wUxps2Y})I5YLUJ3+?zRT9mkux+du>y^|7XD zPKgA74PLC5b3sXXU_D(#CYP=#(#aZ4$ez|wbsp<2)6(Zqd3f4QE6!;1OH$ANfyWLt zlFPJRiW>{&k4^Tx7W;~*4NW>}(#BL1I`SmfE?KQdeqBuu+_lYtQQ;yoh`9(mxi~sp z-iVJc^4iszU-JZ!Z>jmPnbLyc3R*j=o*Da_E@A9~?v<>LOfuxfGR;|#(%%>o9gly}?Oa+J{-M~n;W5kPH zq>%b~4CNyd&GMjn+V8@-Bn=l6dh=cICRxew!qZ&Z{4-1jWvbM!a?`Jh53zT!tPJd1 zdXFr4*yGuZ7V;rbG4wU|CIE#f@KE*6243K;H zNlHUq*%?8GxN`}jg%4xJ;P#(otd~B;+k)Da)}ka`)5qvz7duPk8^0e z3q&@HVh9!T6uN%fl&Axy3398$_^YAvVIz2`xoXuAh1_IkOfMn!l#Vb+OZZ5Q*59zJ zC@2`zVJqqS7TSbEO8OWz?&HnkHGA3786qymi$=ln*)`3Loi_z6{yAF7&NK12Zv;O7kG($RZ~?B{dcq8yTg({>A|;s^27QlSZ3G2W=@X=M^<>FAoo^1kBR zKxuC?+#-}0RbAR`a=uQ!3X(mGM+(z0aS5@y^Io2wHr$ieYln?jn)NU$m&?EPn>rF; zsSWdHOcLgXQqh5L$=3B#5sQe&_BL|w|1yz`;|(#+g&B&)k3c0jdO&H=iz?oOW}3K! zO{S;TnTICwv*j4OpT&{o^Qy=TsLRt1IyEESi5oP>R;AP-64PlZFGwJl6^#fx=+4P3 zA%~2ZxJnu^iyfrfa#$ConaiW}vX~?!vo8-KdH|YvB;{@w-AlvcPd@B=5zT08;FXdD zRclnjlU+{ues=Nu%eTHM>wKiC2^kja&KPNs9qe}9zcIanEpPi&eYn?ue^d>zyw5qJB;gJd?%0a?ZbXLG-plp7A4H&~AHT24A}9v__A(S|J$0%;lMvc@95Ce^q)>*u>p zgA(>aD7vgQW#4BFe{>?mgSd#vESQzlr;+690X>B_%JV|hZ)5ODFCNy9zz}y7Tb1#c zbtj5=tX{H6(F)n;9ngzQ)L?$SXM8VO!gEb;;2}D|Y&Ye*pyOT2si~|oD8tW{4SOOP z8|9;Nw~tSJBrcPtIyU;p`iS&PXCNl;jOzwh$Ni^;Ub*VnHXZ~;PN*EGf}WI`H)u_x zqfNfH*-q9os(&J4=a__4v$68tD(DBO(1pt&K-jQcQ`n~#Q7`LRF$S*}EI7pL{opU@ zm(2;OE4Z@Gs%daQh6f31U?TZ4&iO7LAT}$p6NiT##iithULly3*e)nameg4@B9yx| zu08U1*qvA;`ykp_qZQeu9RpG3N)Ksft948s8vyj{`9m3G=*IYwh)MdK_pq_4d+fvt z366{_Sn04#)4n3tP>~oB-=)5JQxT+s!sL<8W8(%B0U|>ITdsusS>wS>Hx*25u0ogL z=MWh(lLF45Vy1E)@Ygv3TjbLo#qZ&XC}-i(vViJnOGcXw53|CiELCgQF=cBGuGk~Z zql|s>lA*GtKbwNlz7KZ_C=LC@$PK0Jr)MqPVnsnB(V#>%OYE;Eow`TQZQxz2Xnmjdi z&!^Y>3&vJ38J84dGcjT$MR>k6;@Tv`Z@G7sT;1~Lu(L&(C}mYxg$dYSG+c==@@S8h|@ywcaY*|<0*p?*Y$;mjC?*0}dhko{I zYL~!lW5KLayR3CNY76;~5y~iSV3s4fRtd;3`DleLofDm%4T8Ss{ww80`K->&YE_R^e z;t?*L$<;ve0rm@EIm&eVjFYPD_FdQP_A^rP_MAVgZz~;I86+5kXf9&X(c+Ilcnu}1 zgp~W_RxLRW#e-`zQGk2-^Y52qbNn2A74mw*K>ez%6+#mI87a(;FULzwe^xf~H`sZh z7XCTRJxRI3L>pI0Obc;S^+H%W)qkhXZ>|w2wK*A+4y~~REpa|J2Y49zZ;P`j5>#e8 zzqk{0nGF^>4vAWIlyq(29EM?Wm3W}(^l}s2i@j-l{oj1wvU*BGvn>7hpK7Hx9wpzl z4ZY9bAtrm8V8?>3R5b2WW@Joq@h_qtDx`KR-VZZAD^b2CX=Zt!zIrifB0YU1|AtU% z@Vbl{C+BBIYtT6PrKb}GNp2Bpoj%;Q++iy7_>gIbb>6_af4`l?^j^03{sifHaNKu$ zzqgCZ(@AIJxLMj5<$&Z;5GXbkXDd19;LT3uO)^l3l!t8-3!AQ{%1^L?lgD=($|DoH z5!NzE8GpcrhQzVsh1x}9GF$Tw{z?p?T);dXH%BGsFh%4AKl0#E2Eu|)3OG)7Uc0`9 zsp*w0kS9s~jUvUEGd&Z2g=uR9T+<}+W9=p~$V_>S&?V4Z#3UGmMO2}>Jz3BNit`Tu zX0lM6Z|^(7D_bMS!vvaw12?KO20`m+v{`m{ZCwv^s=d-3wzsF3|C!rZ(at@;|!AEG9@{C2#VW2;%<>Cj*n*UYNr7 zYbUtza$dqn)r3v4{f>}F8B(8^Q4C-4JeBfGHz0gz;R`xci=JWnmcYh9RkXr{GkF;0XA#UZ6E|>%0kEO z!Vi5R|Au9S@i=M@g&z&SGAUi>k;!w*lguY>KI|!>EzeJA9@5GKh)4s9U%P@l#PK#iq|^< zLG{Yj(ahsINmS`Ykzd{|wL8446i!-+h%cj`?RmfWDO(x=T znbHtVc_MP z;1NUhn`Ij%)DO-q$r$kgSuDcOn?Uezl>BJ%6glN8?~?{N5`$7w3@ZuG#=A%>ve_w= zvDlErBo#G+3pMrN^Vy57S;Bu5B{<*OlSozUpc*FEWB^ioxwL0hY~Q?Q6D~7MA_8h3e{CzY3X3a%{y`KHF!`0|B)n(O{NsqZJ23d72A4Z$;kuG`zjpz5?wNkB7H^j)wD z6rFk2LXe#P2cxniBd|hmv?l}M*UPyE_Nvi9^#0{bM91C}MFtm8WmG+e&N(A+nG#bc zd9hdij8`t-I6c5+#c|%NM|>Vg!AxR&oqP&Fxo$+;D7Qm+q>sYxv~KSj98o~Omh+?N z&xGsuG;ydZb-LEAHi-c4tyXCWb(sXfwzfJqJ@fFl(R4r^4b&g&i`F9VrAo4g;^D>rPoDTQ~MBV{B$q8lD@hn7>Am`@}f%blzN;Tz8* zpLVu(4X)OrbF3=g&6=#6HKi5;BkBLc?@uTxZv)4|Qc*BMP{1aiO1L5}VE_*gm=K=iD6KP5>F zG?FCNbE``u_fGibn(Vu6@ALL!%ir%}M=og)%R%!;*kQy7%DNLy%W{lw9HRx@*9d{~ z$89rifHB?tCsqwC2f;k3Av|F{?`EVG4i}G;{;03X4vuTj$I4I@2=uvIF?C#TL~u}p z=uqldN7d=oA8N?%QCq`&L&coK@T+P41`Z)7`ZwQBd%X>ANjgiEJ9@H~QMwVsSl%48NqP0&4 zr{2Y%RU~e=PNoe(6aXfdNPI^Xh=EJ^1L9d*IMm7Pz`+Sdo2&I-Pgc-vqP_C z8cc~v;BJ!li~i3$D0~HcSG*|cdNUuvm;nGmOml{S7yaA>j5cI zc1iGf5qEWvq7kGzEp^iOTsun_Aq|m6id2OUfTc~Kv_|nJ%W$57F`QhGg_@!ea5z?y zzy~XP&b|z0AT#C;?GlM=cxkRnQV=gfd{d;@6#TohzXv5lj?jBmQ&p>ZPwzeehjTNY zZ7e<_0!HCb4iGj42uidM3m69T6n9Bx>E9(3c?SXVkNg;p!2@~YicyHEXiz3#i+@p6 zYxDbmv2pgCjEGKQbzN7;KTSDE`e#v2)1_h$Ea0lI=LBj0nr7hd}_iX)6)){ zonjyq<#HZ&mXG~}@EL)Z2ALK-b%9<8{toEnAw|#O`wCOO{iv}&X-j|p*7okctOT!i zJH4VYczAQ(gu!X~%#3-|1KIsn`&VZ=LtciP^314h9|*J3DX#t{5gBWe(U6nw^nS?= zIp%k9q#H3)JXs5=^elRZrJ(CkaHGlP@!lhu$A;xzOfDtX_8G`u-kX~S!(u8qVzTA)E5%{+eFBAAWO6%0Wopa=D4@0zmP!P*x0XMjN=BK9#+9L>*}NnL zi?v3bAJC#0>7!V{@qr~4$B3fI*xM>0h`DeMGFSD8$yoWAjvr#;Hn7YsN%!R{tY+JF z>y624bnr#8((AihwbF$m-}Y77$z1m1>gei?#WmGgr^4lL;t|D)%(62wWmrti)crz} z(Qy@dG^9P1<-SDg(zDU=j3z&eyn*w>lQUr}=&Ln46s8N5RpGQIf|)y(=tb4`acJ`$ zYrjJx-r)_t)86&lPZ*e7epbmvVk2&840z-iG@_-QFt*nw@Z`jd{@9Z7SSVxF0OonX zP^hNv0eW+`EJoo$uEkjSpZ!!hfh#;91_bzE3kr(SU}#)L{V}=93`MPyM})B^n?!<(KDv`yO>p50sxn)^X)52)r6JY$mi1QUD z?8}NZK5)Q8)i;0>B7^X$2r9lDN+h0Z(*4X^yN?r_FT=R^6K@VjKFmQ6>V~i+uFbw$ z_JL9ARYFHv?KCshf`1Xzvw`>zhD-iag%!bdyxy}8IWKDLwr$&-*tRCN?MyPk6Wg|( zOl;e>ZS%`}@Bdz_PkC3L-qm$n727X@J(;8@^3EfKp0sY${s5wjuejtteGg8i#^@r6a3y6abWJOXNCxiw)0b^RDrs=9yZq1c zzu6yWDYFl<7w=n2@45^(4H;Q>$=$iamrP`!)DT^C^PRIjWOs<^e=ls?&mQ!iZ?~O| zC2SqacNmH+QJa}yIULcVz0HPcU_eMKiOIn69%S(xA_1_hGGHnsR2;bu>%*ADCK9V& z>ua{35U2SclrROKdv2^w_IsA4@G=qm!=aRu7+Jo;gz&$WKP~25#h0_@B;xSq_D{hH ze6D){yA2GCRnyl_t26O&O1&5{eIqKWC}tD-q$uPx^|34XD-$fZu3_To$9B61rc>Y8sgy1lg&i+AJ*TLim9iaeq(6jOZ)UUXBrF z@|4Jj=n7tGysB~?0P>M2Nkd43%qpui13JDC4&(M5_MU^G_6E+kS~Aru{--xHEF7z1 zn6`E_vA`qbZ3uwMg9Lf4E-Q>K2c$8slROd5()t6uMwhea?x(4w<*!r9A$`BeXTN8A z)}_{;&IJ+uA^=MU^?X}dM2d!@;-C(H9$+W(yfRqqCK2aFO=d?C&jMJ+dZk6Gwl3DS zhx+#Vu$=Yo-@R%1!;Wu4uE`T6-KY>L&V3jgFJSorzOG%&2L3mGy3j_k7qY7pPc+5d?J1jig;UV+*=W$3ZGUSR-H41NF)wD@DF#OhUS0D zyBYkweLIWF=zp%Sk?o=%Z85rn4~!D7JSd^~`|p!Nv``#2x7_au<>22PLAXM{v1Sr1vD8QMkme+^Li{e5OZ_`_o;It2O;c%6Dgd1#x2Mo81d&$~c^uMb*B8 zxx^b$&8#Mz08oU!=oJITpFznR{x3=PI*yCb*L|o?$(fOx2xP8e|M<$ht3!FCH~oK%~lqc+seS_aS9!WITzn8Yu*oHUL5=`*hQ4}Ems-R*V0qCP3b z6)qojyUo7OP=0QwmFbmGU7?^4s_;VqHYs>j)T)Xe>?u98S5D<_~v3@)1Z_qc#)nj$Gg z!<&Jh*zS759It*&PaEIukPX&RW6iy)TR zGcw(*mK?+2T;S=WI9uhWV&2@<`Tt*x%zQFiBsyuI6aBmAfUr{n0%kIvm^HXYYRPPw~#` zx^IbUyC(uWJ)Q;<+RjtrEO?$ppJ9>RN^r|ln`%@<%J^&AU^TwAFjO75I!GZA-*4

O5$ingnvNN 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 Pc|7Vs9p!pO>&X8B19dy9 literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..fae3ca0b75e2b83cf04d1fdff6d2d2d2e5ae5365 GIT binary patch literal 70673 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¨4N_j_$=|Y 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 i|0wJSFjw>cPhEuwC$K0x + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4b7b0906d62b1847e87f15cdcacf6a4f29..685bcb39192d206384f2f9f13bcad99bc4fe4da8 100644 GIT binary patch literal 3438 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 AQ2+n{ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b79bb8a35cc66c3c1fd44f5a5526c1b78be..ed2743b7d627f7373bd745aee782aae2db44bd73 100644 GIT binary patch delta 1845 zcmV-52g>-m1Gx^68Gi-<00374`G)`i2LMS#K~#90)mm$86jc;HXJ#K|+1&!2mPgVU z1cQ(i(8ORGh`ibo+VWNq!!I>a5RiaSpg^FsK*A&aV`8Fzj8D)2vPJCn4-fsJlolxx z0udiXG0+y;?QZGr++*(S%hJ>yh_B4{uYX*#4=f3YIoYm!q8R7ERPa5I z2X@lB!ZEq}qdT>mR^-%(z#PMUu6$Kol!-O001_cG0_JegWzoX4NJYsur1aM{G5=*VCaI4kBoO4Ns9a#SE~NPwB1U5l z1WJ`}GX2o+sYIwnoxYpln+79e;L^t$t)+oluX`YVpP1;Bc~?p>&_MNPe@lS9a&4zhJ27T?GeU#6H{ z7MN*MAhFbB&=knj1d3%CT^|e+`?J?QfC|$=XACT{rMQq)Ae%C7$oTowhA#g0$MA{f zo8cRoz?)*4gxbXcunyOFX78Bq^1S$T#gbv0%l7G z>y(w-_bs93@@gm!Y|9J!U6|u@pj5XluYaMmCPVI&2Ag#{o#s4np(WH*Rs%(+D)Q?6 zZjiiA7!jz*tuL*iq6=3;1L{tsX3E5jK9rJ;rdt8cIEm7IP!!mb8_do^$?HTZur)VW zz8{Luw}b|6CA1i2uR?MEx52O24_8CY3l30mU`t-m=Y|}gQ;-5%avNstW`&n-hJUXQ z0Q}_Mv?HLL1$Zja8w0))SG$^a9qV}a@N2%0c`3;2q!HyWWG%gPGhFk?!RxS@1;&(( zs`+^z2uz>knYw$4?}$=E0)mbnY71P99DnrCb=WNSr@1Pj<{b6}3MYCB0-JJ!J{L&5 zZATAlTX^tZapR51wSj@JJBrwBZoQEKqJKw>};ddS)q9cnE(*Ai|V zDD0q}*DbumA{R>ULNg|Mrk>g;1-XRs*$DBOKYXv`Y)jEl4*1W&=K z4N}nO1c}RSN0hZKJknZx?rLN}XWob{UM|&}#gQ^K<13snq#&>%r@`w&iGMff>|t$- zYTHZn;3q$oHNNb*AEBvDwO8XfXCL3IN^6= z9%snGFRX1VIs1#jZSc${seYsf#_DU$`r1VD0RZcYyl;+L{l~a|RS2|1&Ta*e3}@ox zbwGhJ8M>m>wy?In=-jo)C4ckyi1CU+Y8k~?_aU_g+h^d3p(qgx(h?wzp^+t(9%`Le zHr2iK^yTj3jlXtVNhQrcbZ=hyRL^o01e=N;10p8ZHAjy2vpxgBu=jL2om4g5id=8H z6|s%5YMVX%0@V*o%)EUrq|)m{B+jIvA<;~ejbc1p6JI8jW8lpa#eYz@JSgfy9=9NJ zOsY&8W;a2AA0+QSi2gZhC_W7_CT=esw!S*hUK@JI9`7qAw;*mS9kv!_Ch~W6tsT+Z z`huImg^o6MfBMdhtV&*kd~bHvflYJ9)Q}+Pl!bKP=8A7$g+>2r6Kwk_Et$;KM+6j* zE8(%m?$7jX31>s}`oWHxfc31vs=g9~A(h_(Omcug--JM&tz`u3blp!l~{LXwu-!+(srVXBM z_AEp|1xl!Dx}hB!;zazVfTlW(TDxMmzrGy#eCNr|n)WWaD=sYojl{Yy+IR5J6Zx+O jeeF0{Y%skC`%m^SGSj53A)I!M00000NkvXXu0mjfAMuC4 literal 442 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 diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d34e7a88e3f88bea192c3a370d44689c3c..aeb6088c0331feaf603535aa5e222f95aeed4410 100644 GIT binary patch literal 9442 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 literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372eebdb28e45604e46eeda8dd24651419bc0..f20465353b73029c3872526dd49558dcd05d633d 100644 GIT binary patch literal 14729 zcma)DQ*P~IjQ`@#}+h^{5Ij`p-$xGI^vh(fiBrDmG zit-W&usE;)002QsQdIe0+w*?~8uDMQykP?c0B|Wvi3+KD{JHdnNz)Ze_Ro56pO<-Z zp@0-tWhIJ?kd8mB0VOUVoduULks&;51xy9dMxL}zaO~}H7wm6`0*S!6Vd`CQ5+k6= zY3U2{3r13E$w&!7Pkp^>rhJ~axPc{STg#i*U3#Z)zVUw7n^omkTqRR#CDb9|!<^!S zk{mt##-zL0J;plyECIu(C#C6B*HIGX`3pJo z*UtkM?fV!*y2kRM!ZjEjgYIo4iwvD%1q5wDMNEjv0rNPu9n;wdicRx~ettO4q&Q}A zG_D+^;-jnMj3B59!+5p?X3rpAB|BXTZc0XfjdqKN%-uSXC3I^+)%9gMLUIA?ZVo4>qOq_EAoCnqwgQ2Vmv1I^+b z;!Rl9C=U-TYrhK@hxfe|{HH$z{OpaNQ@{72{696VCG3ezdzcMpuy%IZR4VL0jxxI( z4$ifgA@hwv1&h3D+Fn8;ZY&5v`6!vPu=e(9{-C4E{`}kXHk$1<4-E@1=LC}48df3d zi&ugx*rQ5F=|#=i$y7LeJzV6}Gc?Wz(R5Zy6~h!TI05tcOMuUy8^)Vlx)FNwNBy)= z5X?oF;T}IsDJB{Wj4!q&Tv`QvBZHnd&PMacro{J4v<$xH`>}++l}xviB^ElQ$MIge zYm3iu%<^Q~Og(Lc%O5<8M=~N`VM+3YOYhbBPQD1=Uh*%OPVlCrBXyy~5#_~bmtxdN z?;l_ZDOGm?V#?8tJ(q9-gFn!B=6t#@Vg6iSt=!!nKeP6YG`l7)cMQZn?Iw+C53%@> z76q*|*UQHPlvU|7+P+nuzf871RQOp851+QL-n9jOUSgWE^zb0&Xtum|BXv1Ti=8Kc z7yz^ei7X{mDx(WcqhIXH2ad#PbGjyBo&Z{j)5#mrf>9@(M=}{H`;5xM2n_juHGCyS zGR{)mQuugQob&i583#)_b+U8=W>4ELMJOmrn`^2A?(L#n@#m2Y4Pv1JS%C94Kxils zvx7DH6{o@TEB?pC4XEagj^ov?_6>qDqX5sEOfBDa|LG1tX|ekRA<4}&V{SH*FX+Hm z-CPzD##Zo45jRH2=vEQJmN(nxj=_O zsGF1rXMbWfS4HZ*R0}lwlRA+42|<63yP$Q<(o3*i-XSI|)F&k$7IMZSgXq3aEv+m> ztGlXjyN@Ly+}Ll)V<~@+m!hQu10?u#?A&r@XuFrAdCJ!hZ*o@ULfU}n--~{<9 z84?(zT5h8kml^x!+Gn`RpZ_A1@s1_4LECJ}`ST}OCZ-r;6r6lkq3_Yo)P zCY#H}d{sl^rl@c*1PqU&*f>oBk7Z$V$}fh@HH4HopUJ4dgcM8N6D;J?ooCDOD~W47 z%}e`&W3SUV?pQ85rPl!gEv`3qibmDnAeb4zn2%-d17{SCxQgsEWi1E^l!T3maCodr zf03SOylMoP{gV!eTLf6n-M;yXZr}T*)#vY{yki-)(0WEd)0li^BP*;UFAvHG-^ zWz%2>9i8iPj5A5DDe>WK*2ktata~Gyfw-YN7E9L5Cu5Tq$8@u92D3e-4}cDbo3!}} zzn}sr&xtGx@=qFanZ{xeWS1uTmu2T8zmo&!9DVh(BzB($205GM{+?UQvsVlo7dMHEhaXP;KtTpSjb1U7oN%_ z_5r*l#V^HtNMY&V_&4h*F>IX9O;B!q(D=|Z!I7wyctCB2ij9s@Cb7qMLYwn>32^h} z`E&C%FwOuH(qniQYO1Q{l=Fva?%x%IGJZ#%BJ3kcW{@9FGi@EFXJOX3&-&lX!`cL2 zud&@nJngWjpwq@WdBjjJy<|1xoo;T2L^&_R?G4+E1)n5bNlhRFx4WF2VX;gT1BvKH z!g+^P-wq#va2%}skEw~-x<=Z&ox`#|=88R|xr2>+!q92x%!%kIO!`W%earS2yzh4e zUx(S*dZ$wd`1^RTpiwznuP!gQ9N*t>y4||(qpuu(madm?u&eiPgu9?djPqm>L8aWg zBU0CEGnDxBp~ri``;;+lM~ zdbb*23`)iX2uK4c-g+(PM_+OkK|1O~MD#VqTrSfz4fh-OK*JoZt?R8lEGaB!s}t2S zKSamFVI*7sXoQQx``NY}gp9_hr^-T@R1O;YOQU@MM^M#ODIx6qmxzMkv5Ggh%-kq1 zFZ#`E>Wvb*5CySX5ZblmtqVw$PO_8$K9;l*z%=}LPRES<8_5y_6IN3{pfXg5=(MHik z%8=01%$x=NZxnb7Oo{v@Q2pl~zi00F;abArp2;47BY$^wFxUvS-)%}-96r8gzy*OG zm!ih6^oXR!U(#bTJ@}0TK<;5;d0)-tBk) zPQcKDjp%l2of-1AQ}nN2*fxO1s`VAKRIY{V!O zo?=p1PY)At(&7MgE(MkT_zFSpy~D7g-(iso5%-jsl4s4iv|y638e$)sXh_o8AOV;x z^)CT6inU8H-t;#k@I9cQmkY+>ph+{wdE0(bU?sUGkhPNS1VAVgh0^RrgVW2q8)&8A zPQFOtQE!I`H$^p+svnAU0mVAy8*C|nXHb$S3!2q@&t$DXS7cf1RnIFd(Q7a^x; z$`)q_0*v^MXuQ_|3gu{N!Rw$2Sv{cD`R}#dKKSFZmNuPeP#?Au`PJx;sFf_K`zTI$ zaqPd;5u%G5pssuh1WaT;rVb+j?eQo!=iQNaPEiDsTc`z*UImiq) z%|zd|VYDVde$)*vOD}0QkCQ%|kQTeP1U1hv1zX6?zI`+fK}`KJ zvgboNMHcI7*w&rQ(+^1J4uv^cMWb7}bZQ2}m@5lED5aSlC+|az{UvYzKseFb_8ZV$ zrE9`^YwyEgu!f#f06LUwk(EQ6)BkKwC`T<7G74E7h>nfJo{w-Q6VIvMGH>L<&v-?f z`q9~qy)1Ub)_cWY53{$NC21YY#&nhu7nv%(O*d&k_(%VsO|2ueA!E|Ep+6#J49cor zDo?`!$e=yfrivKpC_{-TuRnh*#V;S}>Y+CODj#UoGHE3Hr?nO9PuJP!Zaj*>%JA&# z)UY5Y6`K$BP|`b$vt5~%;%OQ3LobknHs8hFN6ORQP6kNf`RQ@4=>^!p4Dzb|iPC33 z!QUR^u%5caDC+{C?fkQi`Wsy7!FkOl)hYX`Yh2duJt`1}MwVZjfssM_oW8D2yM}xJ za!y!SL{K}vh<%d?%bG&od}ConPyqM7kVphiP|kLVKF)%a%U(wqx|jnYEAs_9_6xiL+z*FgtSE%P**h_WwOf%Q@q=lpspJ4Ri?}uhBv1iu(cH>mP&f+#(yJ z^)qqji*lW9Q38my7aqf2CIZf!7BovAKp!8WhxT)0%PNu}lYC$V-@yWYAED?M>GLvP zG4ci#Z(2nyk$t$htGIa-!X=U9VSQ=~C$Z$=pdnwyJA*c1qc;tu^UG-3hpy(w^Nxk& zow2yfoW0i(j|Cx805S8fd%Gw?!u)L0@3mI{S*eQjj#zrpzF|HI8-JUjI+y~J3MU4Lj+iX+GZx^)?mm0 zlEh?Vnb7AkGNU~Gf|ZXiR>0M!1%WB-6Gk5cBOM2pE9YpV2{_MlEXdmdi-PMr?IsVO+A6nf-M`jiYE_8b?>z&1M4NDKO=4}kZC}X#Q zluEFJAq&b0{vdUzR$@}EUCo4^P;=j~mfKdIN5e}d%Ud8?PlZn+EcBZzG}0Y39~0b; ze1XudW22>xKh2s={aFzHU;yv#o59)u6#N_L)*APhPn=3gmad8u~ zaj5+r9C6<1`Ah0FXGy0kxyck$LqW3MTW5t0F+!?X2#(#pRb3m`iDhsb^#O)dGjomh zbpk^vS(WA!wDUMq&g6nV@|cUZpyegV#A4VlP&T9d+K{JD|DKQd#KV(0L=z<%yYkim z_bj;@U}^&JVmnF@*M$1aD2)GY*0O|mKQ5*m5t~mu-%s+BXmQzv-(iEKt+`t+)ZsV0 z3{(YfK@ z35n8Gk8E==yTF40zCnpE+b(*1kxuL0Us|U;8(_IsC*sy zD5BYNi_ALtBC)SSY^jkZHGRNX$E9XZ0WHA2y@Pdl$caaTatgc+oLt_aI&?!3CHCe<(19;v9Djr$QbY!67nr z1S@C7RCvQTC5-V=CefoBRR$AfXNcDeF?8`yX&;4qM2c}fg2GG1PkAfQ=_u4W+|@G9Tsi>Y?# z#MgAuw~+3OX^ht6RgdFE1%Dx!P&zReFwwArg(QQ#`~QqK>3Tow9O3^2)C0qdAy2o#$06Ae#>Y}9 ze)3tn^{1M#%O=tdM>V_UVl;y0UM2P4l$xq{**!{hf6bL^U;AE-$|BuhX-A~`VwZI2 zL1~IZYY^?TmnGd4#~b{G_g&Qz=7C;W0}h~&mP;Z6!CQ%1MS!Uo`1Wl&6o4ix9tb=>HYKqg33{cBwLszvNRCx;~P%7bHL`&l%)(M zP>QD30BroG`3x?130Hl8y_ltv03fQl*!6dab+8 zEX~==G~bSyqI&+urj(7wrlPq_ zroo$Q2{j_Aft@;^ijPj2KehN@to!S`?q`%~Tbr!=K#TNXLBkhny42)EugS+QlZLR1 zfLOI_|FC=8o3N$nCRbya~uS4uaK_X{GuzN8x3`v~Nqe6nN%s^`7+{z9|mZ_OX z-J81X$l=9xJbko2&m_lqVt^F|G+%;n9}2I7B#a_A7Z96>m!4T_uH3YpYO4ilxV^hg zgB$-)Bb->sufOvj;p1Kybx`vHQ@?A%uw$4EjP8v@mg9Ni^;8q0Z#$jCUblj~{3Uo0 zAmd{Nwym~QYId6@cXFE6@->$(S90t)nuYXYu(}dD$4<)mU{(ZSsui5w#_Q$e2?$LdXYJPrtEp}jrL_0sM}K9W$Dyq zt0a1*0ex-;N9>vL1EuHo2T+uLF5{{h0+&9k%M?hMviTQ8Z=+97OyAPPnEPu$@ zxy@X!t*1S&!it z0gm~nl)*k)T28qe;i+##@uwEuz})%LgiXf6{TMNo6LPKcY9)-?0~WBTJszgKBNKC@ z(QPdyIbs~=`u=RjLY!8;NXZ+VDR?bDlV;E6^dMd=$ha&AjgpElnfW1TAc=HCj5#=D z=yIB`tsXO#wKA)#s(7XgS`3{f7D!}}d1DwmNFC2i(^p9Kk2*R3a{Z6!nmuIhysgU> zA_eGm=6tpD1Oa$-Tm>(ba-wFrE%2+kRzP`yziLxawO_T5z5mert5<6H&mPdRy!>hc@0ioF~hg=mc+0i$6eew6&4+EMN)#{Rmw0oTHS_HfWox* zvW(IRXEMWueM~m&0Q<112!pZIr@sb`l7^IZp2J-f>N0f)Lo&s-6{GI-u$T!jQkjnT z15{7pwgs_RYv5J)OI2BmjpYED3?J&pQcx*3QGC_IHUsuf1bbV>MjK=bv5doGc&4jX z|Lv2P?cuYW1eF;>C6ZNVPm!TfV(ip3>d2peVBdlbu~Y@RPTO|B#_ZCG_P}K zWA~^7y9YAjbx7XR}7W#ZR6RbJ(G@ISbRP6CnSou!=g#s+t$E-Rp`Ju~m z`$divgWkGW;1?h^2vkM6_74Mu4-vC|2HYXKif<92zKMsEelt1f#^f(6u0M_iA>_a< z64m{^ z%EqhpzcrfZaAYkLF#CPd)eA!04~Z=XuRTnVX}bQ_D~e8AflOofGLiI;qOq^cTDo}L z5hfQh&?TyRTe?L~1=%w9|A0CbB8K+XPa=7^TA1td_=OLiq^N$6xGIfR%8BFjBr``%qpYOuR%+uFYq4}w*8q7#9!WGCrV zcP)f$<*_}=ZF<(3{8A4O`?)rtTF%CK_`@iWP1X^xpvGmYc}8A%k-)_Jk5mrF!QSrv zg~F8@?tyoRV)V+3vh+X=J*mGvkW?j9Q$OD}qHQa>==f`2{q*DASA<4WQ*aKv48n*o zC4)cC>>%-tBNA>vF+}!bJ3FQlp=dk4^4tFj5hddkF`4TK;vXhOHts;h`Nh8mpBbku zf6qf>_rISb)iWf@8TJwXuH|-M>CI+dPGND5tr7J2xJy4P(?|@1=gzu(cj;E3q;9y( zE>tb@WzCM&12&a^QOB%X@`s{XbvzS1y*3S?8qM~WD%BIwVYwR}(_t^=u9cdXb%JfHd)&!8qOj7%TNSP=aMV)s!Nlq<{v3j{O4X5sEZ9 z7e9t~=tFsgVy!H*uROWbL5E2kp#5QCk7XgZUC-BVx-sp0fc$%RW_X8q^i##LEvZJ9}?wmIR(tMwL8E?Qh! z-GSeix#(7fFA~FQIeq6ZE7oT-(n=`qm{Tpg8&VOv-;P73;U)X|N6z}JMa{j10Jbq? zPT5SbLXdoXpwu%WVsmjZPT#E`ZaKPrsuMP^N)q)cQATw^R#0z*mVwk6574F^MJ!$2 z?S}p;q@bZA(6O6;npx4Iy#-&M2;SZ%XLBTMt2la6gYHSI3cPH)q0lw|xUKuFiB{wo z`q?8LEdDc93R+`g2n5>}fbW)@Gas`v8}K_egU!5ua306{J}VqhvL{*%JUBkF@h zoYLSpWG(CXtJL;&NdLmy#>9(>zjt)Q2z(sg0U;!i^BNNAPsqZI;mZ; zi^U9*>)ADD#bSL@1-|Sb57EyH1k6c`$}2m5+J#pkjC1DSgJE6a!I<=cZO0!u zE>$-J0?Yt*73-$VW?A4Vr)aE*9=@*3XUO<(6!cS+0FrW0Iv%s5KViR$*_NK%Xd#QF zxP=C=0;IPo)G3Cv1b);O)Vm$G3E1d&o$4ukkc3M9kkUf6gg4S93Q8j+sWYXH&4iZb z96&6eBrdRT)}iHT_nRt~F|#Q%R5F?_5#RY`FhR!;@nFF?gT-B7r+6+*gVa zx~qjsWs5=CnOt|u9KD*Gx7Ki*h^<}Tv4#joSn>g3206zjmy8m?Z6w#RI*|CiL9?D} zf0yY05WvujF>#yd=QP?feFQmZ!c%E#g@r+>N>rFP9Qo_qeET8&G=}JQI8i@M78O9F z6MKf`=@sJS`V?k0?b=E6FYD}zT?&q_IE_=f?L9vWO*q&0Ys?D_bHB+7pAJNtC;&jp zO*a>61tc-5Lh3bKxYU_-m8Sa*8txSP2gvIausFryrEjQ`6-~<#4cvv0@$YZIpRb^b@iZ;Re=F8f?f*X8|C<3aS>pEWRZsA= zLjSeBbP&Q!lnG~xR@@{qDukvRq)tAByq1y%Hdv>;$ zqkg`BA?ReS(p4yz`4AdbdNi9QnXBbHI<)x+jhCl! z4Eebx{!-n;G##}C`EJLa>do}qJB!(~2&@pbQBoczB`Fzx`c8!KPzDwO@)YQ;=(#auh)3MW@je&dh z==W6@5Fpkr=%BTqRHRIl`!EsA9Blw?E7@d8pf0fD8I1K$@k~DdAYjT`SJDk^;d_N3 zS4P9UJ}jDfp#L0}e2#PWo@%Q%el6Db8Wm6rkR1|t)$LtyfS8l)S!HPjH(?Hv+GLD0 zt#)K?9h;tOS)N{XQQAy7W9$^FIsi2&?;hh3UL%{+oW(eL3i-#hihmptI}sHhh8Cp3 zFvm*V4e*^S!`dK&;H7ePh~A-WIsPb%0d7`tk6_S!_5KME%n&~j+If;_)Zm77(ygsF z9~A#LG00C*A{FAl9l);~Rse2$+Ld}Il7D{x(6!^AsV6a?()9mY7?OIO8UzGtHDs%7 z{VW^9$*80(K*=CfH&2`MaXq=jMprlIeBc72Q3@J*|J>lMTEMM!j>@{0KRCg2&XFl8 zlg4=6g%Lu;Ork6pp%!}raVVF2)OVssm2BG<}7 zgdYvyKwaEacRFx}gynF6iA&wg3@N@bi2>+~OUF_~MU)Xf*ZVQwzq@zGIj;1c2b^s$ z51%|;0!o-be|2Xp;MRjQP;RYa4xUQ}+%{{#$oP^^JjOiZ@f$zI94oBqY;O8k5C(aK zlMp@RL(j|Yzlgy)8D5-SWGS}22f21qAVJ=CyE|@P#9+#AGqxQkyGI`3S)4VQ@jk8= zyRp+2hmk4Z2-O!=Zd!%V4OCh7E72|1>1;d~C}>i~YCTN`PrV&rOjfXOwp^b&z4IqB zI#PU*sJN$Z8C%q{nm~ut-LPPN(gLBz8Bx0Z+-z<8A?9((pWN^e^L9$4T^iR503Fv$ zqyb9t6&3Yf$0gNQ8eNv&uiW3bW(t+IoZZ!i$_nRo0c|~nKn*H)tFj^kTUCZIy%Z+B zhtA%ZG!kH$oB#2xt63z`S*z?HFbtkPD<19Gwx_>TExcVd_aOpT8{5iPSkdTv?$MQY zXjq5+=^uGDq_h0-w!!dS#84M}pku0mJHKd&{m|TY{Cw{lQ_DmY}BN@d`v8HmYqWo5p2)@G+2aj(NtirG>5)kwNMVm<)`fY?c@& zw3AQH3C|=eBP!Y3N1qy<6dG(gerCJ2-1dq?+Z>+DfVeEj*RSgs+e||I;F+B$RS@V9 zSxqFyXz^M_1^DRg&8%0Jlrrd$ku0YW4Gb4R065(vS75FIF<^g3!sa1yZQl7Vte5Dk zl<)h+tqyjr{|7iVnL4uwtD51)g-RX=A_sK_pMDx*`nGK!yTZ;kLTkFgf^aRCVwnm(!wJ9yy;pU`&Lh0&U;)w;3clsLByp;;L z?|exDK<1fc^{@wtAmJ-jvKoq)_rE>YS?>KVCRp;LSm=VR)CupFps412z+NQx2m-^C zsF9+s9wk7E-DIfVkH0RoIb87u)8GQE9y1<3woZQ#M{=eOxM!4$0Y>c$qgK4vI_hps zRfp)F;&N?=?nEK26sMG(iJpK4twdLwiULNAk{!L6JzaUuNbAbij$p=*U<1RNG z6(;21`omgb|9TLCAUjUt6xrp0AX(wKT|mmT!9_uRRE;H^$w#+hvNdjWxhh8AoV>`W z&Zb(C1?tiR)(pIgT&pjN?Ti=53XqOhIrLZbdxj6^>A zj=KF?%hhMj2k)t^5|2=ygyu;HiT+Yz)pX*))#sKe<{0$^+LPlqJGs}ZmI>p zz+E{gXpLz8FY^36C!dG}t#U}9QkU>19cL6d8BQ{`_V?>X3W``cic8S#RIO$| zYSBfm=!|dC{D5!q7!73$k%DDC(ial@DxW80xLyk1^JwqSNPCLW@bTrpC{sO>P^6*c zJ}fmha(x9te4fmDJ=HdzdhQjjHh3;yWEA*ra<;yUKd6M{@lcsTqav2k_NEbGNHUM zXMa6dM_PhjNk#?fD~oj|Yeg8oAi>ouuDCvv+#S_?J-k*Bc;%a3pmj@HEjx0%FkcH~ z_9e_h&b(25;&zHyx-b^1)?K{ZtGs7Uza4#DABDVhN%OoV)W%g7q!CNj2(7^SHaR$Xb(LNyB!LtbJS5Iq^j z{SbJnZ8pypc_}Y&L+on)^BOKkN4S7$khTYi?wLQk;DM|Qm(Y|%Pa_^o2>qH_l9Co0 z9d#g#S(j1(9f%;5yGz}PWW>kE&dY)tTgLF*8rhYxq(axAP4an%r;b4ZMXuE@QZ`IDL`V0{hda$vgr)GgtoBEwhS(m$$x8E>NC=E8C8F06y1QK7Yew2)vRs@=H(LFDyKq(2!wP z3)vVk;tja9Hj*t%uv3O!wB3GqBoyZ zDIF8Yg`5Q;faZ}D3u73YtZL`pDIPQlDKEx(VAc;%vSQXL{XlmiB4$>YshWgVQY2p9 z0Hq-CH?ZsH^X}x^QDTnu9goIqW*;6`a{Wn~jV-xtnG#>Gs;^kaA}BN>?5+%fig+6i z!h)$(btA0YyhNp7|KSKi-Y94~z zAg9Ych0ycx?NCgu`saSDy`g5K{kcVVPI~|!Orn+f1C2tKi;Y5yMl=WvYXZlD2)46B zDI!}rTZI}7UC&}lfE#m%vQv|>R93N~HO47K8YFz-O5b-2O3!`YokQTWB5$2#Xdejq za`KL-)!5qg`J1qJ^=k|fMgsTGxWzk-Pg!TJbE!Gt?<>(+YQ`P{oh?|qkG^YKCi!j}dqj#Sw3JK=d!_hA!BVFl~KMHWp-$wb;sx}V{=Zhk7S zwGt&5&>68h!S_!pDwQHZ10rO;Zl6MP@3Ouo6ONNlf4|++)I(eXUP22Vn6w2sDE~{CV3A4*~p_*bz04=_P2AVbLw%AyHV1K}IYmpba>wSBu9EMnpHa zd~OP)OD8@G*PZXZur?4|_e@5m!JVa_Hgr;|l^kR&CO*@WU=2EjA#qC!)l#p7CO>No zd|7((GF}%8TuKwI$Si1=@o6JUYs9TGS7D;ZKOQE&G(Wxzay<(xO>qk|tpWp2u(l2Z z+_MGPVrW3u{8RGizlRUiGnz=z`6X6o`d6=?Do7lnljQm*38P{(Jb~4e2Ua``gVN< zA5)oz*BTo3!=A-kBQuk`C~nztxoTpt;;HLOS50x1j3j@UyL^L!OHaG!WBY@y)~$QAfqd z-G0oR1U+627orQ6TdX(8Q+jW8%0-bfz!qqiD*Sng_d}2s>yEMxDRzt>LaWx%E1dQ4 zQi?2fTWs@k`7S3B)C?-TP@C>O(tl?rjO3hB-1{d{&yCH%@OO)zaZ4+W9H^0h*zBh^ z_X-Dg0)af;UgtCjnl|IItGRI(mX{yBPuTh&7d)oMh(Tkms-`^;EUH$>ly8NRsaD2! z%tVg93NyfB%X^kx2fsR$dpYGQ=#DT5mh|W3$i@LuE)gB z49vrAzIjb`s&sMBC{5f^UTTv3;g~s1CUt{Mg4eNeGDdxezPr9`UICN6fwlG-`O;jt zJA=_05tu{ahE&L})?X!e4M_ZV(#`~6YUt@S{l5E4d;uaqwJ}Tq|0d?BJlHKO*6Mu;u3aug z$fqAZBoC&8*osgUed^FTnp;S8X&gVY6;Cm3qUcqJ#J~`^n@wvNm-2G^$i9|^mF<_& zK753fPa9{c*nP|)OmC!%z5Q~+%Iz#s)bJhbYI4_Sw%(e^{S*Zul94VLw_=khPxP<; zDwYI*s;SN3Q7G3wxFlAacr&}|QX0+FgSW{~uu-cRu43g88=}LSp?6Tz`7%}9aQ5Q} z!@WG!_0V9g58$*+mXS}YBB@hwG*n{CfX_n;mdl&>XDHBh?KTh>PUu>63e4E<4_ZPP zol+MJM88J|F>?+8n zWUElmf%Wq@U)vakfS2icS@*i8USMNA7U2L`+=c(`1I@s@J#UHMzUr!%Y|nAW3B>Fe z<5Pp0$VSbe^!$;LsYFwdX{E`k%)nd;mOIOG#UU&7lO8;mfTGjCWRxq!(l%V?H0D4$FoOUWRT zf|878ol)MfMw*}HUaFT)WnRhwap+1cv6tbq8dxQ!QK*%IcJ&z&L0kP{BM-h6y*E;0 zJvQOM=6ABtsuW)_jZ&lI2s1hzRI^Ay#duY22xZw}o!gU-g<|PGkdX?&&N!L_hQ(73U^Q@9Q_U&O79y|38=LWvn@ z{ev(RRJ$Q5B%{C)(Ff(5b0q?_v)6-UV%>#}ev1~LcZdc7V14J6xx1UB%XoXuxKHuq z0cx{b-mX>j&ZQLG0Cv_kzmZ$pY^O1MkgwdGuH(?MKtbEoB~&*>89!k(BeS%98aLir zlaPY_;z0MtbK%RVX-lWid<}uMr94T36JsfSZb0{DTlb^Lcay)buB}+VH)4gh#|AFV zw&R?(Rcsa?Z~{}v&Q4KD2~0@dwspjZpc)+V%W4JeVb=wYNhTdHANmKunk%l`+I}>v*sSAq5dG537>$FPq_nq#u z9);K)lhGFG+70OGMvy2~2UE`WrxV51#$CzO_I8WPmhUpb6Yqzz`N1K!Y99qW1Q_y7 zQLN9`(e~e))J%U8d_+6cWx>{*2I$LR#%L?e)TdCY?l$JhPu)Vsu>hQOryL_&s>iKQ zz6OCpzUkyvDO}R`&iQP%8D}V`9R4eeI*@_gEqp3bkaS_*BrIvj9YvHN3%@B<&9)WE z)^78kb%8n@2lmDR(=^3y2t%FYeD0jrY*<+IkT*A2$^SJT{x1&2TYtt|Y<-;HGvNO$ PmH;U+dC_WNgP{KaPpmV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` diff --git a/app/android/app/src/main/res/values/colors.xml b/app/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..beab31f7 --- /dev/null +++ b/app/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/app/assets/android_adaptive_fg.png b/app/assets/android_adaptive_fg.png new file mode 100644 index 0000000000000000000000000000000000000000..b36a933bea1d98d9e34e829fb9705ed2bc3e2528 GIT binary patch literal 388079 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�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

!*;jG^P*;jI)koBFp(Y+7qZqtM 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 literal 0 HcmV?d00001 diff --git a/app/pubspec.lock b/app/pubspec.lock index 6c44ee8e..3ff62d06 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -81,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -298,6 +322,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -512,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" integration_test: dependency: "direct dev" description: flutter @@ -933,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" postgrest: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 9a61d1e8..0bbd53be 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -146,6 +146,17 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 fake_async: ^1.3.1 + flutter_launcher_icons: ^0.14.1 + +# Android launcher icon (run: dart run flutter_launcher_icons) +# Adaptive icon: amber terminal glyph (padded foreground) on the app's black. +flutter_launcher_icons: + android: true + ios: false + image_path: "assets/logo.png" + adaptive_icon_background: "#000000" + adaptive_icon_foreground: "assets/android_adaptive_fg.png" + min_sdk_android: 21 flutter: uses-material-design: true From 39f90188b409565bc48ad7d3f7031d049ee4d429 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sat, 27 Jun 2026 15:29:45 +0700 Subject: [PATCH 93/96] fix(mobile): balance filter chips (min-width, centered Inter text) --- app/lib/mobile/widgets/tag_chip.dart | 27 ++++++++++++-------- app/test/mobile/widgets/primitives_test.dart | 6 +++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/lib/mobile/widgets/tag_chip.dart b/app/lib/mobile/widgets/tag_chip.dart index fd03a4c6..0fb2222a 100644 --- a/app/lib/mobile/widgets/tag_chip.dart +++ b/app/lib/mobile/widgets/tag_chip.dart @@ -31,8 +31,12 @@ class TagChip extends StatelessWidget { ? MobileColors.accent : (large ? MobileColors.fieldFill : MobileColors.surface); final chip = Container( + // Large (folder/category filter) chips get a min width + centered text so + // short labels like "All"/"logs" don't look cramped next to longer ones. + constraints: large ? const BoxConstraints(minWidth: 64) : null, + alignment: large ? Alignment.center : null, padding: large - ? const EdgeInsets.symmetric(horizontal: 15, vertical: 7) + ? const EdgeInsets.symmetric(horizontal: 14, vertical: 8) : const EdgeInsets.symmetric( horizontal: MobileTokens.space2, vertical: 3), decoration: BoxDecoration( @@ -40,16 +44,17 @@ class TagChip extends StatelessWidget { borderRadius: BorderRadius.circular(MobileTokens.radiusPill), border: large ? null : Border.all(color: MobileColors.border), ), - child: Text( - label, - style: TextStyle( - color: fg, - fontSize: large ? 13 : 11, - fontWeight: large - ? (selected ? FontWeight.w600 : FontWeight.w500) - : FontWeight.normal, - ), - ), + child: large + ? Text( + label, + textAlign: TextAlign.center, + style: mobileBody( + size: 13, + color: fg, + weight: selected ? FontWeight.w600 : FontWeight.w500, + ).copyWith(height: 1.0), + ) + : Text(label, style: TextStyle(color: fg, fontSize: 11)), ); if (onTap == null) return chip; return InkWell( diff --git a/app/test/mobile/widgets/primitives_test.dart b/app/test/mobile/widgets/primitives_test.dart index cff85590..18baebe3 100644 --- a/app/test/mobile/widgets/primitives_test.dart +++ b/app/test/mobile/widgets/primitives_test.dart @@ -74,9 +74,11 @@ void main() { expect(deco.color, MobileColors.fieldFill); final text = tester.widget(find.text('All')); expect(text.style?.fontSize, 13); - // padding wide enough that a 3-char label stays a pill, not a circle + expect(text.textAlign, TextAlign.center); + // padding + min width so a short label stays a balanced pill, not a circle expect(container.padding, - const EdgeInsets.symmetric(horizontal: 15, vertical: 7)); + const EdgeInsets.symmetric(horizontal: 14, vertical: 8)); + expect(container.constraints, const BoxConstraints(minWidth: 64)); }); }); From 204739a0398c17eb59905ef49771c638f0eac2d9 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Tue, 30 Jun 2026 21:57:33 +0700 Subject: [PATCH 94/96] chore: sync generated desktop plugin registrants --- app/macos/Flutter/GeneratedPluginRegistrant.swift | 10 ++++++++++ app/macos/Podfile.lock | 7 ------- app/windows/flutter/generated_plugin_registrant.cc | 6 ++++++ app/windows/flutter/generated_plugins.cmake | 2 ++ 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index e64b7a2f..7d2153b2 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,32 +6,42 @@ import FlutterMacOS import Foundation import app_links +import device_info_plus import file_picker import file_selector_macos import flutter_secure_storage_macos import hotkey_manager_macos +import local_auth_darwin import local_notifier +import mobile_scanner import network_info_plus import package_info_plus +import passkeys_darwin import screen_retriever import shared_preferences_foundation import sqlite3_flutter_libs +import ua_client_hints import url_launcher_macos import webview_flutter_wkwebview import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PasskeysPlugin.register(with: registry.registrar(forPlugin: "PasskeysPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + UAClientHintsPlugin.register(with: registry.registrar(forPlugin: "UAClientHintsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index 8e03f69a..9c623fc2 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -14,9 +14,6 @@ PODS: - hotkey_manager_macos (0.0.1): - FlutterMacOS - HotKey - - local_auth_darwin (0.0.1): - - Flutter - - FlutterMacOS - local_notifier (0.1.0): - FlutterMacOS - network_info_plus (0.0.1): @@ -69,7 +66,6 @@ DEPENDENCIES: - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) - network_info_plus (from `Flutter/ephemeral/.symlinks/plugins/network_info_plus/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) @@ -100,8 +96,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral hotkey_manager_macos: :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos - local_auth_darwin: - :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin local_notifier: :path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos network_info_plus: @@ -130,7 +124,6 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe - local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e network_info_plus: 21d1cd6a015ccb2fdff06a1fbfa88d54b4e92f61 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index f0967f3c..72a029a3 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -25,8 +27,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); LocalNotifierPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalNotifierPlugin")); + PasskeysWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PasskeysWindowsPlugin")); ScreenRetrieverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index 5ddde140..62f7699d 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -7,7 +7,9 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_secure_storage_windows hotkey_manager_windows + local_auth_windows local_notifier + passkeys_windows screen_retriever sqlite3_flutter_libs url_launcher_windows From 4aaad91d4517db34070253bc326e80751080c245 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Tue, 30 Jun 2026 21:57:33 +0700 Subject: [PATCH 95/96] chore: keep leftover local debug probes + temp review diff (throwaway, safe to delete later) --- .review_diff_tmp.txt | 962 ++++++++++++++++++ .../local_shell_probe_test.dart | 47 + app/integration_test/pty_probe_test.dart | 44 + 3 files changed, 1053 insertions(+) create mode 100644 .review_diff_tmp.txt create mode 100644 app/integration_test/local_shell_probe_test.dart create mode 100644 app/integration_test/pty_probe_test.dart diff --git a/.review_diff_tmp.txt b/.review_diff_tmp.txt new file mode 100644 index 00000000..b9210e77 --- /dev/null +++ b/.review_diff_tmp.txt @@ -0,0 +1,962 @@ +diff --git a/app/lib/models/ssh_session.dart b/app/lib/models/ssh_session.dart +index bc52353..092c4ae 100644 +--- a/app/lib/models/ssh_session.dart ++++ b/app/lib/models/ssh_session.dart +@@ -1,6 +1,7 @@ + import 'package:uuid/uuid.dart'; + import 'package:xterm/xterm.dart'; + import 'agent_forwarding_state.dart'; ++import 'connection_log.dart'; + import 'host.dart'; + import 'terminal_session.dart'; + +@@ -33,6 +34,24 @@ class SshSession implements TerminalSession { + /// SessionProvider.handleAgentForwardingEvent. + AgentForwardingState agentForwardingState = AgentForwardingState.off; + ++ /// Human-readable trace of the connect attempt, shown by the "Show logs" ++ /// panel on the connecting screen. Bounded to [kMaxConnectionLogLines]. ++ final List connectionLog = []; ++ ++ /// Appends a connection-log line, trimming the oldest entries past the cap. ++ void logConnection(ConnectionLogLevel level, String message, {DateTime? at}) { ++ connectionLog.add(ConnectionLogLine( ++ time: at ?? DateTime.now(), ++ level: level, ++ message: message, ++ )); ++ if (connectionLog.length > kMaxConnectionLogLines) { ++ connectionLog.removeRange(0, connectionLog.length - kMaxConnectionLogLines); ++ } ++ } ++ ++ void clearConnectionLog() => connectionLog.clear(); ++ + SshSession({ + String? id, + required this.host, +diff --git a/app/lib/providers/session_provider.dart b/app/lib/providers/session_provider.dart +index 5acbafa..b359ae9 100644 +--- a/app/lib/providers/session_provider.dart ++++ b/app/lib/providers/session_provider.dart +@@ -5,6 +5,7 @@ import 'package:yourssh_rdp/yourssh_rdp.dart'; + import 'package:yourssh_vnc/yourssh_vnc.dart'; + import '../models/agent_forwarding_state.dart'; + import '../models/audit_event.dart'; ++import '../models/connection_log.dart'; + import '../models/host.dart'; + import '../models/local_session.dart'; + import '../models/rdp_session.dart'; +@@ -457,18 +458,29 @@ class SessionProvider extends ChangeNotifier { + jumpLookup: (id) => jumpHostLookup?.call(id), + keyLookup: (id) => keyLookup?.call(id), + ); ++ session.logConnection(ConnectionLogLevel.info, ++ 'Connecting to ${host.host}:${host.port} as ${host.username}'); ++ session.logConnection( ++ ConnectionLogLevel.info, 'Auth method: ${host.authType.name}'); ++ if (jumpChain.isNotEmpty) { ++ session.logConnection(ConnectionLogLevel.info, ++ 'Tunneling via ${jumpChain.map((h) => h.host.label).join(' → ')}'); ++ } ++ _safeNotify(); + await _ssh.connect( + host, + keyEntry: keyEntry, + jumpChain: jumpChain, + verifyHostKey: hostKeyVerifier != null +- ? (keyType, fp) => hostKeyVerifier!(host.host, host.port, keyType, fp) ++ ? (keyType, fp) => ++ _verifyAndLog(session, host.host, host.port, keyType, fp) + : null, + verifyHopHostKey: hostKeyVerifier != null + ? (hop, keyType, fp) => +- hostKeyVerifier!(hop.host, hop.port, keyType, fp) ++ _verifyAndLog(session, hop.host, hop.port, keyType, fp) + : null, + ); ++ session.logConnection(ConnectionLogLevel.success, 'Connection established'); + session.status = SessionStatus.connected; + audit?.record(AuditEvent.now( + type: AuditEventType.connect, host: host, sessionId: session.id)); +@@ -516,6 +528,7 @@ class SessionProvider extends ChangeNotifier { + } + } catch (e) { + if (!_sessions.contains(session)) return; ++ session.logConnection(ConnectionLogLevel.error, 'Connection failed: $e'); + final maxAttempts = reconnectAttempts?.call() ?? 0; + final isUnlimited = maxAttempts == 0; + // A jump-chain config error (deleted bastion, cycle) can never succeed +@@ -545,10 +558,42 @@ class SessionProvider extends ChangeNotifier { + } + } + ++ /// Wraps the host-key verifier so the TOFU check is reflected in the ++ /// session's connection log (shown by the "Show logs" panel). ++ Future _verifyAndLog( ++ SshSession session, String host, int port, String keyType, Uint8List fp) async { ++ session.logConnection( ++ ConnectionLogLevel.info, 'Verifying host key ($keyType) for $host:$port'); ++ _safeNotify(); ++ final ok = await hostKeyVerifier!(host, port, keyType, fp); ++ session.logConnection( ++ ok ? ConnectionLogLevel.success : ConnectionLogLevel.warn, ++ ok ? 'Host key accepted' : 'Host key rejected'); ++ _safeNotify(); ++ return ok; ++ } ++ ++ /// Manual retry from the connecting screen's "Retry" button. Reuses the ++ /// existing session/tab (preserving its terminal and metadata) rather than ++ /// closing and reopening, and cancels any pending auto-reconnect first. ++ void reconnectSession(String sessionId) { ++ final session = sshSessions.where((s) => s.id == sessionId).firstOrNull; ++ if (session == null || session.status == SessionStatus.connecting) return; ++ _reconnectTimers.remove(sessionId)?.cancel(); ++ _countdownTimers.remove(sessionId)?.cancel(); ++ session.status = SessionStatus.connecting; ++ session.errorMessage = null; ++ session.logConnection(ConnectionLogLevel.info, 'Retrying connection…'); ++ _safeNotify(); ++ unawaited(_doConnect(session, session.host, attempt: 1)); ++ } ++ + void _scheduleReconnect(SshSession session, Host host, {required int attempt}) { + session.reconnectCount++; + final delay = (attempt * 2).clamp(2, 60); + session.status = SessionStatus.connecting; ++ session.logConnection( ++ ConnectionLogLevel.warn, 'Reconnecting in ${delay}s (attempt $attempt)'); + _safeNotify(); + + _startCountdown(session, delay, attempt); +diff --git a/app/lib/widgets/terminal_view.dart b/app/lib/widgets/terminal_view.dart +index 6211a18..2a5fef9 100644 +--- a/app/lib/widgets/terminal_view.dart ++++ b/app/lib/widgets/terminal_view.dart +@@ -7,6 +7,7 @@ import '../models/host.dart'; + import '../models/ssh_session.dart'; + import '../providers/command_history_provider.dart'; + import '../providers/host_provider.dart'; ++import '../providers/session_provider.dart'; + import '../providers/settings_provider.dart'; + import '../providers/shell_integration_provider.dart'; + import '../services/hotkey_service.dart'; +@@ -14,6 +15,7 @@ import '../theme/terminal_themes.dart'; + import '../util/terminal_appearance.dart'; + import 'command_gutter.dart'; + import 'record_button.dart'; ++import 'session_connecting_view.dart'; + import 'suggestion_popup.dart'; + import 'terminal_context_menu.dart'; + +@@ -23,27 +25,14 @@ class SessionTerminalView extends StatelessWidget { + + @override + Widget build(BuildContext context) { +- return switch (session.status) { +- SessionStatus.connecting => _statusView(Icons.sync, 'Connecting to ${session.host.host}…', Colors.orange), +- SessionStatus.error => _statusView(Icons.error_outline, session.errorMessage ?? 'Connection error', Colors.red), +- SessionStatus.disconnected => _statusView(Icons.link_off, 'Disconnected', Colors.grey), +- SessionStatus.connected => _TerminalWidget(key: ValueKey(session.id), session: session), +- }; +- } +- +- Widget _statusView(IconData icon, String message, Color color) { +- return Container( +- color: Colors.black, +- child: Center( +- child: Column( +- mainAxisSize: MainAxisSize.min, +- children: [ +- Icon(icon, color: color, size: 40), +- const SizedBox(height: 12), +- Text(message, style: TextStyle(color: color, fontFamily: 'monospace', fontSize: 13)), +- ], +- ), +- ), ++ if (session.status == SessionStatus.connected) { ++ return _TerminalWidget(key: ValueKey(session.id), session: session); ++ } ++ final sessions = context.read(); ++ return SessionConnectingView( ++ session: session, ++ onClose: () => sessions.closeSession(session.id), ++ onRetry: () => sessions.reconnectSession(session.id), + ); + } + } + +=== NEW FILES === + +diff --git a/app/lib/models/connection_log.dart b/app/lib/models/connection_log.dart (NEW) +diff --git a/app/lib/models/connection_log.dart b/app/lib/models/connection_log.dart +new file mode 100644 +index 0000000..ef9f187 +--- /dev/null ++++ b/app/lib/models/connection_log.dart +@@ -0,0 +1,22 @@ ++/// A single line in a session's connection log — the human-readable trace of a ++/// connect attempt (resolve → TCP → host-key verify → auth → shell) surfaced by ++/// the "Show logs" panel on the connecting screen. ++library; ++ ++enum ConnectionLogLevel { info, success, warn, error } ++ ++class ConnectionLogLine { ++ final DateTime time; ++ final ConnectionLogLevel level; ++ final String message; ++ ++ const ConnectionLogLine({ ++ required this.time, ++ required this.level, ++ required this.message, ++ }); ++} ++ ++/// Cap on retained connection-log lines. A flapping host with verbose reconnect ++/// chatter must not grow this list without bound. ++const int kMaxConnectionLogLines = 200; + +diff --git a/app/lib/widgets/session_connecting_view.dart b/app/lib/widgets/session_connecting_view.dart (NEW) +diff --git a/app/lib/widgets/session_connecting_view.dart b/app/lib/widgets/session_connecting_view.dart +new file mode 100644 +index 0000000..408d560 +--- /dev/null ++++ b/app/lib/widgets/session_connecting_view.dart +@@ -0,0 +1,444 @@ ++import 'dart:math' as math; ++ ++import 'package:flutter/material.dart'; ++import 'package:flutter_svg/flutter_svg.dart'; ++ ++import '../models/connection_log.dart'; ++import '../models/host.dart'; ++import '../models/ssh_session.dart'; ++import '../services/os_detection.dart'; ++import '../theme/app_theme.dart'; ++ ++/// Full-area screen shown inside a session tab while an SSH session is ++/// connecting, has errored, or has disconnected. Mirrors mstsc-style connect ++/// UX: a host card, an animated node → line → terminal connection graphic, a ++/// "Show logs" panel over [SshSession.connectionLog], and Close/Retry actions. ++class SessionConnectingView extends StatefulWidget { ++ final SshSession session; ++ final VoidCallback onClose; ++ final VoidCallback onRetry; ++ ++ const SessionConnectingView({ ++ super.key, ++ required this.session, ++ required this.onClose, ++ required this.onRetry, ++ }); ++ ++ @override ++ State createState() => _SessionConnectingViewState(); ++} ++ ++class _SessionConnectingViewState extends State ++ with SingleTickerProviderStateMixin { ++ late final AnimationController _anim; ++ bool _showLogs = false; ++ ++ @override ++ void initState() { ++ super.initState(); ++ _anim = AnimationController( ++ vsync: this, ++ duration: const Duration(milliseconds: 1400), ++ ); ++ _syncAnimation(); ++ } ++ ++ @override ++ void didUpdateWidget(SessionConnectingView old) { ++ super.didUpdateWidget(old); ++ _syncAnimation(); ++ } ++ ++ /// Spin only while connecting; freeze for error/disconnected states. ++ void _syncAnimation() { ++ final connecting = widget.session.status == SessionStatus.connecting; ++ if (connecting && !_anim.isAnimating) { ++ _anim.repeat(); ++ } else if (!connecting && _anim.isAnimating) { ++ _anim.stop(); ++ } ++ } ++ ++ @override ++ void dispose() { ++ _anim.dispose(); ++ super.dispose(); ++ } ++ ++ Color get _nodeColor => switch (widget.session.status) { ++ SessionStatus.error => AppColors.red, ++ SessionStatus.disconnected => AppColors.textTertiary, ++ _ => AppColors.blue, ++ }; ++ ++ @override ++ Widget build(BuildContext context) { ++ final session = widget.session; ++ final host = session.host; ++ final status = session.status; ++ final isError = status == SessionStatus.error; ++ final isDisconnected = status == SessionStatus.disconnected; ++ final canRetry = isError || isDisconnected; ++ ++ final message = switch (status) { ++ SessionStatus.error => session.errorMessage ?? 'Connection error', ++ SessionStatus.disconnected => 'Disconnected', ++ _ => 'Connecting…', ++ }; ++ final messageColor = switch (status) { ++ SessionStatus.error => AppColors.red, ++ SessionStatus.disconnected => AppColors.textSecondary, ++ _ => AppColors.textSecondary, ++ }; ++ ++ return Container( ++ color: AppColors.bg, ++ alignment: Alignment.center, ++ padding: const EdgeInsets.all(24), ++ child: ConstrainedBox( ++ constraints: const BoxConstraints(maxWidth: 520), ++ child: Container( ++ decoration: BoxDecoration( ++ color: AppColors.card, ++ borderRadius: BorderRadius.circular(16), ++ border: Border.all(color: AppColors.border), ++ ), ++ padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), ++ child: Column( ++ mainAxisSize: MainAxisSize.min, ++ crossAxisAlignment: CrossAxisAlignment.start, ++ children: [ ++ _header(host), ++ const SizedBox(height: 22), ++ _ConnectionGraphic( ++ animation: _anim, ++ active: status == SessionStatus.connecting, ++ nodeColor: _nodeColor, ++ ), ++ const SizedBox(height: 14), ++ Text(message, style: TextStyle(color: messageColor, fontSize: 12.5)), ++ const SizedBox(height: 18), ++ Row( ++ children: [ ++ _PillButton(label: 'Close', onTap: widget.onClose), ++ if (canRetry) ...[ ++ const SizedBox(width: 10), ++ _PillButton( ++ label: 'Retry', ++ accent: true, ++ onTap: widget.onRetry, ++ ), ++ ], ++ ], ++ ), ++ if (_showLogs) ...[ ++ const SizedBox(height: 16), ++ _LogsPanel(lines: session.connectionLog), ++ ], ++ ], ++ ), ++ ), ++ ), ++ ); ++ } ++ ++ Widget _header(Host host) { ++ return Row( ++ crossAxisAlignment: CrossAxisAlignment.center, ++ children: [ ++ _OsAvatar(detectedOs: host.detectedOs), ++ const SizedBox(width: 14), ++ Expanded( ++ child: Column( ++ crossAxisAlignment: CrossAxisAlignment.start, ++ mainAxisSize: MainAxisSize.min, ++ children: [ ++ Text( ++ host.label, ++ maxLines: 1, ++ overflow: TextOverflow.ellipsis, ++ style: const TextStyle( ++ color: AppColors.textPrimary, ++ fontSize: 15, ++ fontWeight: FontWeight.w600, ++ ), ++ ), ++ const SizedBox(height: 2), ++ Text( ++ 'SSH ${host.host}:${host.port}', ++ maxLines: 1, ++ overflow: TextOverflow.ellipsis, ++ style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), ++ ), ++ ], ++ ), ++ ), ++ const SizedBox(width: 12), ++ _PillButton( ++ label: _showLogs ? 'Hide logs' : 'Show logs', ++ onTap: () => setState(() => _showLogs = !_showLogs), ++ ), ++ ], ++ ); ++ } ++} ++ ++class _OsAvatar extends StatelessWidget { ++ final String? detectedOs; ++ const _OsAvatar({required this.detectedOs}); ++ ++ @override ++ Widget build(BuildContext context) { ++ final asset = osIconAsset(detectedOs); ++ return Container( ++ width: 44, ++ height: 44, ++ decoration: BoxDecoration( ++ color: Colors.black, ++ borderRadius: BorderRadius.circular(12), ++ ), ++ alignment: Alignment.center, ++ child: asset != null ++ ? SvgPicture.asset( ++ asset, ++ width: 22, ++ height: 22, ++ colorFilter: ++ const ColorFilter.mode(AppColors.textPrimary, BlendMode.srcIn), ++ ) ++ : const Icon(Icons.dns_outlined, size: 22, color: AppColors.textPrimary), ++ ); ++ } ++} ++ ++/// node (link icon, spinning arc while active) ── line ── terminal node. ++class _ConnectionGraphic extends StatelessWidget { ++ final Animation animation; ++ final bool active; ++ final Color nodeColor; ++ ++ const _ConnectionGraphic({ ++ required this.animation, ++ required this.active, ++ required this.nodeColor, ++ }); ++ ++ @override ++ Widget build(BuildContext context) { ++ return SizedBox( ++ height: 48, ++ child: AnimatedBuilder( ++ animation: animation, ++ builder: (context, _) { ++ final t = animation.value; ++ return Row( ++ children: [ ++ _node( ++ child: _spinningRing(t), ++ ), ++ Expanded( ++ child: CustomPaint( ++ size: const Size(double.infinity, 48), ++ painter: _LinePainter( ++ progress: active ? t : null, ++ color: nodeColor, ++ ), ++ ), ++ ), ++ _node( ++ color: AppColors.cardHover, ++ child: const Icon(Icons.terminal, size: 20, color: AppColors.textPrimary), ++ ), ++ ], ++ ); ++ }, ++ ), ++ ); ++ } ++ ++ Widget _spinningRing(double t) { ++ final icon = const Icon(Icons.link, size: 18, color: Colors.white); ++ if (!active) return icon; ++ return CustomPaint( ++ painter: _ArcPainter(turn: t, color: Colors.white), ++ child: SizedBox(width: 38, height: 38, child: Center(child: icon)), ++ ); ++ } ++ ++ Widget _node({Color? color, required Widget child}) { ++ return Container( ++ width: 38, ++ height: 38, ++ decoration: BoxDecoration( ++ color: color ?? nodeColor, ++ shape: BoxShape.circle, ++ ), ++ alignment: Alignment.center, ++ child: child, ++ ); ++ } ++} ++ ++class _ArcPainter extends CustomPainter { ++ final double turn; ++ final Color color; ++ _ArcPainter({required this.turn, required this.color}); ++ ++ @override ++ void paint(Canvas canvas, Size size) { ++ final center = size.center(Offset.zero); ++ final radius = size.width / 2 - 1; ++ final paint = Paint() ++ ..color = color ++ ..style = PaintingStyle.stroke ++ ..strokeWidth = 2.5 ++ ..strokeCap = StrokeCap.round; ++ final start = turn * 2 * math.pi; ++ canvas.drawArc( ++ Rect.fromCircle(center: center, radius: radius), ++ start, ++ math.pi * 0.6, ++ false, ++ paint, ++ ); ++ } ++ ++ @override ++ bool shouldRepaint(_ArcPainter old) => old.turn != turn || old.color != color; ++} ++ ++class _LinePainter extends CustomPainter { ++ /// Animation phase in [0,1] while connecting; null when frozen. ++ final double? progress; ++ final Color color; ++ _LinePainter({required this.progress, required this.color}); ++ ++ @override ++ void paint(Canvas canvas, Size size) { ++ final y = size.height / 2; ++ final base = Paint() ++ ..color = AppColors.border ++ ..strokeWidth = 2 ++ ..strokeCap = StrokeCap.round; ++ canvas.drawLine(Offset(0, y), Offset(size.width, y), base); ++ ++ if (progress == null) return; ++ // A short glowing segment travels left → right along the line. ++ final dotX = progress! * size.width; ++ final glow = Paint() ++ ..color = color ++ ..strokeWidth = 3 ++ ..strokeCap = StrokeCap.round; ++ final half = size.width * 0.12; ++ canvas.drawLine( ++ Offset((dotX - half).clamp(0, size.width), y), ++ Offset((dotX + half).clamp(0, size.width), y), ++ glow, ++ ); ++ } ++ ++ @override ++ bool shouldRepaint(_LinePainter old) => ++ old.progress != progress || old.color != color; ++} ++ ++class _LogsPanel extends StatelessWidget { ++ final List lines; ++ const _LogsPanel({required this.lines}); ++ ++ @override ++ Widget build(BuildContext context) { ++ return Container( ++ constraints: const BoxConstraints(maxHeight: 180), ++ width: double.infinity, ++ decoration: BoxDecoration( ++ color: AppColors.bg, ++ borderRadius: BorderRadius.circular(8), ++ border: Border.all(color: AppColors.border), ++ ), ++ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ++ child: lines.isEmpty ++ ? const Text( ++ 'No logs yet', ++ style: TextStyle(color: AppColors.textTertiary, fontSize: 12, fontFamily: 'monospace'), ++ ) ++ : SingleChildScrollView( ++ reverse: true, ++ child: Column( ++ crossAxisAlignment: CrossAxisAlignment.start, ++ children: [for (final l in lines) _line(l)], ++ ), ++ ), ++ ); ++ } ++ ++ Widget _line(ConnectionLogLine l) { ++ final color = switch (l.level) { ++ ConnectionLogLevel.success => AppColors.accent, ++ ConnectionLogLevel.warn => AppColors.orange, ++ ConnectionLogLevel.error => AppColors.red, ++ ConnectionLogLevel.info => AppColors.textSecondary, ++ }; ++ const base = TextStyle(fontSize: 12, fontFamily: 'monospace', height: 1.35); ++ return Padding( ++ padding: const EdgeInsets.symmetric(vertical: 1), ++ child: Row( ++ crossAxisAlignment: CrossAxisAlignment.start, ++ children: [ ++ Text(_hms(l.time), style: base.copyWith(color: AppColors.textTertiary)), ++ const SizedBox(width: 10), ++ Expanded(child: Text(l.message, style: base.copyWith(color: color))), ++ ], ++ ), ++ ); ++ } ++ ++ String _hms(DateTime t) { ++ String two(int n) => n.toString().padLeft(2, '0'); ++ return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; ++ } ++} ++ ++class _PillButton extends StatefulWidget { ++ final String label; ++ final bool accent; ++ final VoidCallback onTap; ++ const _PillButton({required this.label, this.accent = false, required this.onTap}); ++ ++ @override ++ State<_PillButton> createState() => _PillButtonState(); ++} ++ ++class _PillButtonState extends State<_PillButton> { ++ bool _hovered = false; ++ ++ @override ++ Widget build(BuildContext context) { ++ final accent = widget.accent; ++ final bg = accent ++ ? (_hovered ? AppColors.accentDim : AppColors.accent) ++ : (_hovered ? AppColors.cardHover : AppColors.bg); ++ final fg = accent ? Colors.black : AppColors.textPrimary; ++ return MouseRegion( ++ cursor: SystemMouseCursors.click, ++ onEnter: (_) => setState(() => _hovered = true), ++ onExit: (_) => setState(() => _hovered = false), ++ child: GestureDetector( ++ onTap: widget.onTap, ++ child: Container( ++ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), ++ decoration: BoxDecoration( ++ color: bg, ++ borderRadius: BorderRadius.circular(8), ++ border: Border.all(color: accent ? AppColors.accent : AppColors.border), ++ ), ++ child: Text( ++ widget.label, ++ style: TextStyle(color: fg, fontSize: 13, fontWeight: FontWeight.w500), ++ ), ++ ), ++ ), ++ ); ++ } ++} + +diff --git a/app/test/models/connection_log_test.dart b/app/test/models/connection_log_test.dart (NEW) +diff --git a/app/test/models/connection_log_test.dart b/app/test/models/connection_log_test.dart +new file mode 100644 +index 0000000..c6d5258 +--- /dev/null ++++ b/app/test/models/connection_log_test.dart +@@ -0,0 +1,53 @@ ++import 'package:flutter_test/flutter_test.dart'; ++import 'package:yourssh/models/connection_log.dart'; ++import 'package:yourssh/models/host.dart'; ++import 'package:yourssh/models/ssh_session.dart'; ++ ++SshSession _session() => SshSession( ++ host: Host(id: 'h1', label: 'Box', host: 'example.com', port: 22, username: 'root'), ++ ); ++ ++void main() { ++ group('SshSession.logConnection', () { ++ test('starts empty', () { ++ expect(_session().connectionLog, isEmpty); ++ }); ++ ++ test('appends a line with the given level and message', () { ++ final s = _session(); ++ s.logConnection(ConnectionLogLevel.info, 'Connecting…'); ++ s.logConnection(ConnectionLogLevel.success, 'Connected'); ++ ++ expect(s.connectionLog, hasLength(2)); ++ expect(s.connectionLog.first.level, ConnectionLogLevel.info); ++ expect(s.connectionLog.first.message, 'Connecting…'); ++ expect(s.connectionLog.last.level, ConnectionLogLevel.success); ++ }); ++ ++ test('uses the provided timestamp when given', () { ++ final s = _session(); ++ final t = DateTime(2026, 6, 23, 10, 30, 45); ++ s.logConnection(ConnectionLogLevel.info, 'x', at: t); ++ expect(s.connectionLog.single.time, t); ++ }); ++ ++ test('bounds the log to the most recent kMaxConnectionLogLines', () { ++ final s = _session(); ++ for (var i = 0; i < kMaxConnectionLogLines + 50; i++) { ++ s.logConnection(ConnectionLogLevel.info, 'line $i'); ++ } ++ expect(s.connectionLog, hasLength(kMaxConnectionLogLines)); ++ // Oldest lines dropped; the newest line is retained. ++ expect(s.connectionLog.last.message, 'line ${kMaxConnectionLogLines + 49}'); ++ expect(s.connectionLog.first.message, 'line 50'); ++ }); ++ ++ test('clearConnectionLog empties the buffer', () { ++ final s = _session() ++ ..logConnection(ConnectionLogLevel.info, 'a') ++ ..logConnection(ConnectionLogLevel.error, 'b'); ++ s.clearConnectionLog(); ++ expect(s.connectionLog, isEmpty); ++ }); ++ }); ++} + +diff --git a/app/test/providers/session_provider_connection_log_test.dart b/app/test/providers/session_provider_connection_log_test.dart (NEW) +diff --git a/app/test/providers/session_provider_connection_log_test.dart b/app/test/providers/session_provider_connection_log_test.dart +new file mode 100644 +index 0000000..b2478e6 +--- /dev/null ++++ b/app/test/providers/session_provider_connection_log_test.dart +@@ -0,0 +1,123 @@ ++import 'dart:async'; ++import 'dart:typed_data'; ++ ++import 'package:dartssh2/dartssh2.dart'; ++import 'package:flutter_test/flutter_test.dart'; ++import 'package:shared_preferences/shared_preferences.dart'; ++import 'package:yourssh/models/connection_log.dart'; ++import 'package:yourssh/models/host.dart'; ++import 'package:yourssh/models/ssh_key.dart'; ++import 'package:yourssh/models/ssh_session.dart'; ++import 'package:yourssh/providers/session_provider.dart'; ++import 'package:yourssh/services/ssh_service.dart'; ++import 'package:yourssh/services/storage_service.dart'; ++import 'package:yourssh/services/tab_metadata_service.dart'; ++ ++class _NullClient implements SSHClient { ++ @override ++ dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); ++} ++ ++class _FakeSsh extends SshService { ++ _FakeSsh() : super(StorageService()); ++ bool failConnect = false; ++ ++ /// Keeps connect() pending so the session stays in `connecting`. ++ Completer? connectGate; ++ ++ /// Keeps openShell pending so a connected session doesn't drop. ++ Completer? shellGate; ++ ++ @override ++ Future connect( ++ Host host, { ++ SshKeyEntry? keyEntry, ++ List jumpChain = const [], ++ Future Function(String keyType, Uint8List fingerprint)? verifyHostKey, ++ Future Function(Host hop, String keyType, Uint8List fp)? ++ verifyHopHostKey, ++ }) async { ++ final gate = connectGate; ++ if (gate != null) await gate.future; ++ if (failConnect) throw Exception('refused'); ++ return _NullClient(); ++ } ++ ++ @override ++ Future openShell(SshSession session, ++ {bool useTmux = false, String termType = 'xterm-256color'}) async { ++ final gate = shellGate; ++ if (gate != null) await gate.future; ++ } ++ ++ @override ++ void disconnectSession(String sessionId) {} ++ @override ++ void disconnect(String hostId) {} ++} ++ ++Host _host() => Host(label: 'prod', host: 'p.com', username: 'root'); ++ ++List _messages(SshSession s) => ++ s.connectionLog.map((l) => l.message).toList(); ++ ++void main() { ++ TestWidgetsFlutterBinding.ensureInitialized(); ++ setUp(() => SharedPreferences.setMockInitialValues({})); ++ ++ test('successful connect logs the connecting and established steps', () async { ++ final p = SessionProvider(_FakeSsh(), TabMetadataService()); ++ await p.connect(_host()); ++ final s = p.sshSessions.single; ++ ++ final msgs = _messages(s); ++ expect(msgs, contains('Connecting to p.com:22 as root')); ++ expect(msgs, contains('Connection established')); ++ p.dispose(); ++ }); ++ ++ test('failed connect logs an error line', () async { ++ final p = SessionProvider(_FakeSsh()..failConnect = true, TabMetadataService()); ++ await p.connect(_host()); ++ final s = p.sshSessions.single; ++ ++ expect(s.status, SessionStatus.error); ++ expect(_messages(s).any((m) => m.startsWith('Connection failed:')), isTrue); ++ expect(s.connectionLog.last.level, ConnectionLogLevel.error); ++ p.dispose(); ++ }); ++ ++ test('reconnectSession retries an errored session and clears the error', () async { ++ final ssh = _FakeSsh()..failConnect = true; ++ final p = SessionProvider(ssh, TabMetadataService()); ++ await p.connect(_host()); ++ final s = p.sshSessions.single; ++ expect(s.status, SessionStatus.error); ++ ++ // Next attempt succeeds and the shell stays open. ++ ssh.failConnect = false; ++ ssh.shellGate = Completer(); ++ p.reconnectSession(s.id); ++ await Future.delayed(Duration.zero); ++ ++ expect(s.status, SessionStatus.connected); ++ expect(s.errorMessage, isNull); ++ expect(_messages(s), contains('Retrying connection…')); ++ p.dispose(); ++ }); ++ ++ test('reconnectSession is a no-op while already connecting', () async { ++ final ssh = _FakeSsh()..connectGate = Completer(); ++ final p = SessionProvider(ssh, TabMetadataService()); ++ // Don't await: the gated connect() keeps the session in `connecting`. ++ unawaited(p.connect(_host())); ++ await Future.delayed(Duration.zero); ++ final s = p.sshSessions.single; ++ expect(s.status, SessionStatus.connecting); ++ ++ final before = s.connectionLog.length; ++ p.reconnectSession(s.id); ++ expect(s.connectionLog.length, before); // no "Retrying…" line appended ++ p.dispose(); ++ }); ++} + +diff --git a/app/test/widgets/session_connecting_view_test.dart b/app/test/widgets/session_connecting_view_test.dart (NEW) +diff --git a/app/test/widgets/session_connecting_view_test.dart b/app/test/widgets/session_connecting_view_test.dart +new file mode 100644 +index 0000000..9272b25 +--- /dev/null ++++ b/app/test/widgets/session_connecting_view_test.dart +@@ -0,0 +1,90 @@ ++import 'package:flutter/material.dart'; ++import 'package:flutter_test/flutter_test.dart'; ++import 'package:yourssh/models/connection_log.dart'; ++import 'package:yourssh/models/host.dart'; ++import 'package:yourssh/models/ssh_session.dart'; ++import 'package:yourssh/widgets/session_connecting_view.dart'; ++ ++SshSession _session({ ++ SessionStatus status = SessionStatus.connecting, ++ String? error, ++}) { ++ final s = SshSession( ++ host: Host(id: 'h1', label: 'Thang MacBook Pro', host: 'thngs-macbook-pro', port: 22, username: 'thang'), ++ status: status, ++ ); ++ s.errorMessage = error; ++ return s; ++} ++ ++Future _pump( ++ WidgetTester tester, ++ SshSession session, { ++ VoidCallback? onClose, ++ VoidCallback? onRetry, ++}) async { ++ await tester.pumpWidget(MaterialApp( ++ home: Scaffold( ++ body: SessionConnectingView( ++ session: session, ++ onClose: onClose ?? () {}, ++ onRetry: onRetry ?? () {}, ++ ), ++ ), ++ )); ++ await tester.pump(); // start the spinner animation; don't settle (it repeats). ++} ++ ++void main() { ++ testWidgets('shows the host label and SSH host:port subtitle', (tester) async { ++ await _pump(tester, _session()); ++ expect(find.text('Thang MacBook Pro'), findsOneWidget); ++ expect(find.text('SSH thngs-macbook-pro:22'), findsOneWidget); ++ }); ++ ++ testWidgets('connecting state shows Close but not Retry', (tester) async { ++ await _pump(tester, _session(status: SessionStatus.connecting)); ++ expect(find.text('Close'), findsOneWidget); ++ expect(find.text('Retry'), findsNothing); ++ }); ++ ++ testWidgets('error state shows the error message and a Retry button', (tester) async { ++ await _pump(tester, _session(status: SessionStatus.error, error: 'Auth failed')); ++ expect(find.text('Auth failed'), findsOneWidget); ++ expect(find.text('Retry'), findsOneWidget); ++ expect(find.text('Close'), findsOneWidget); ++ }); ++ ++ testWidgets('Show logs toggles the connection log panel', (tester) async { ++ final s = _session() ++ ..logConnection(ConnectionLogLevel.info, 'Resolving thngs-macbook-pro') ++ ..logConnection(ConnectionLogLevel.success, 'TCP established'); ++ await _pump(tester, s); ++ ++ // Hidden by default. ++ expect(find.text('Resolving thngs-macbook-pro'), findsNothing); ++ ++ await tester.tap(find.text('Show logs')); ++ await tester.pump(); ++ ++ expect(find.text('Resolving thngs-macbook-pro'), findsOneWidget); ++ expect(find.text('TCP established'), findsOneWidget); ++ }); ++ ++ testWidgets('Close and Retry fire their callbacks', (tester) async { ++ var closed = 0, retried = 0; ++ await _pump( ++ tester, ++ _session(status: SessionStatus.error, error: 'x'), ++ onClose: () => closed++, ++ onRetry: () => retried++, ++ ); ++ ++ await tester.tap(find.text('Retry')); ++ await tester.tap(find.text('Close')); ++ await tester.pump(); ++ ++ expect(retried, 1); ++ expect(closed, 1); ++ }); ++} diff --git a/app/integration_test/local_shell_probe_test.dart b/app/integration_test/local_shell_probe_test.dart new file mode 100644 index 00000000..a76792db --- /dev/null +++ b/app/integration_test/local_shell_probe_test.dart @@ -0,0 +1,47 @@ +// Throwaway diagnostic: boot the real app and open a local shell through the +// real SessionProvider/LocalShellService path, then report the session status +// and terminal buffer. Delete after debugging. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh/main.dart' as app; +import 'package:yourssh/models/local_session.dart'; +import 'package:yourssh/providers/session_provider.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('probe real local shell flow', (tester) async { + app.main(); + await tester.pump(const Duration(seconds: 2)); + + final context = tester.element(find.byType(Navigator).first); + final sessions = Provider.of(context, listen: false); + + await sessions.newLocalSession(); + // Let the PTY live (or die) for a few seconds of real time. + for (var i = 0; i < 25; i++) { + await tester.pump(const Duration(milliseconds: 200)); + await Future.delayed(const Duration(milliseconds: 200)); + } + + final local = sessions.sessions.whereType().toList(); + // ignore: avoid_print + print('PROBE: ${local.length} local session(s)'); + for (final s in local) { + // ignore: avoid_print + print('PROBE: status=${s.status} error=${s.errorMessage}'); + final buf = s.terminal.buffer; + final lines = []; + for (var y = 0; y < buf.height; y++) { + final line = buf.lines[y].toString().trimRight(); + if (line.isNotEmpty) lines.add(line); + } + // ignore: avoid_print + print('PROBE: terminal content:\n${lines.join('\n')}'); + } + }, timeout: const Timeout(Duration(minutes: 3))); +} diff --git a/app/integration_test/pty_probe_test.dart b/app/integration_test/pty_probe_test.dart new file mode 100644 index 00000000..f199e18e --- /dev/null +++ b/app/integration_test/pty_probe_test.dart @@ -0,0 +1,44 @@ +// Throwaway diagnostic: probe Pty.start on this machine to find out why the +// local shell exits immediately. Delete after debugging. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_pty/flutter_pty.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('probe local shell spawn', () async { + final shell = Platform.environment['SHELL'] ?? '/bin/zsh'; + // ignore: avoid_print + print('PROBE: spawning "$shell" (HOME=${Platform.environment['HOME']})'); + + final pty = Pty.start( + shell, + arguments: const [], + columns: 80, + rows: 24, + environment: {...Platform.environment, 'TERM': 'xterm-256color'}, + ); + + final output = StringBuffer(); + final sub = pty.output + .cast>() + .transform(const Utf8Decoder(allowMalformed: true)) + .listen(output.write); + + final exit = await pty.exitCode + .timeout(const Duration(seconds: 5), onTimeout: () => -9999); + + // ignore: avoid_print + print('PROBE: exitCode=$exit (−9999 means still alive after 5s = OK)'); + // ignore: avoid_print + print('PROBE: output so far: ${jsonEncode(output.toString())}'); + + await sub.cancel(); + if (exit == -9999) pty.kill(); + }, timeout: const Timeout(Duration(minutes: 2))); +} From c86f12b3d03bd6f26eeef485b2a2142e1898dd9a Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Tue, 30 Jun 2026 21:59:09 +0700 Subject: [PATCH 96/96] chore: drop throwaway debug probes + temp review diff, gitignore them --- .gitignore | 5 + .review_diff_tmp.txt | 962 ------------------ .../local_shell_probe_test.dart | 47 - app/integration_test/pty_probe_test.dart | 44 - 4 files changed, 5 insertions(+), 1053 deletions(-) delete mode 100644 .review_diff_tmp.txt delete mode 100644 app/integration_test/local_shell_probe_test.dart delete mode 100644 app/integration_test/pty_probe_test.dart diff --git a/.gitignore b/.gitignore index 98d73852..073c6e57 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,11 @@ app/build # Review/scratch artifacts .tmp-review/ +.review_diff_tmp.txt + +# Throwaway local debug probes (self-labelled "delete after debugging") +app/integration_test/local_shell_probe_test.dart +app/integration_test/pty_probe_test.dart # Dart package build artifacts (packages/ subdirectories only) packages/*/.dart_tool/ diff --git a/.review_diff_tmp.txt b/.review_diff_tmp.txt deleted file mode 100644 index b9210e77..00000000 --- a/.review_diff_tmp.txt +++ /dev/null @@ -1,962 +0,0 @@ -diff --git a/app/lib/models/ssh_session.dart b/app/lib/models/ssh_session.dart -index bc52353..092c4ae 100644 ---- a/app/lib/models/ssh_session.dart -+++ b/app/lib/models/ssh_session.dart -@@ -1,6 +1,7 @@ - import 'package:uuid/uuid.dart'; - import 'package:xterm/xterm.dart'; - import 'agent_forwarding_state.dart'; -+import 'connection_log.dart'; - import 'host.dart'; - import 'terminal_session.dart'; - -@@ -33,6 +34,24 @@ class SshSession implements TerminalSession { - /// SessionProvider.handleAgentForwardingEvent. - AgentForwardingState agentForwardingState = AgentForwardingState.off; - -+ /// Human-readable trace of the connect attempt, shown by the "Show logs" -+ /// panel on the connecting screen. Bounded to [kMaxConnectionLogLines]. -+ final List connectionLog = []; -+ -+ /// Appends a connection-log line, trimming the oldest entries past the cap. -+ void logConnection(ConnectionLogLevel level, String message, {DateTime? at}) { -+ connectionLog.add(ConnectionLogLine( -+ time: at ?? DateTime.now(), -+ level: level, -+ message: message, -+ )); -+ if (connectionLog.length > kMaxConnectionLogLines) { -+ connectionLog.removeRange(0, connectionLog.length - kMaxConnectionLogLines); -+ } -+ } -+ -+ void clearConnectionLog() => connectionLog.clear(); -+ - SshSession({ - String? id, - required this.host, -diff --git a/app/lib/providers/session_provider.dart b/app/lib/providers/session_provider.dart -index 5acbafa..b359ae9 100644 ---- a/app/lib/providers/session_provider.dart -+++ b/app/lib/providers/session_provider.dart -@@ -5,6 +5,7 @@ import 'package:yourssh_rdp/yourssh_rdp.dart'; - import 'package:yourssh_vnc/yourssh_vnc.dart'; - import '../models/agent_forwarding_state.dart'; - import '../models/audit_event.dart'; -+import '../models/connection_log.dart'; - import '../models/host.dart'; - import '../models/local_session.dart'; - import '../models/rdp_session.dart'; -@@ -457,18 +458,29 @@ class SessionProvider extends ChangeNotifier { - jumpLookup: (id) => jumpHostLookup?.call(id), - keyLookup: (id) => keyLookup?.call(id), - ); -+ session.logConnection(ConnectionLogLevel.info, -+ 'Connecting to ${host.host}:${host.port} as ${host.username}'); -+ session.logConnection( -+ ConnectionLogLevel.info, 'Auth method: ${host.authType.name}'); -+ if (jumpChain.isNotEmpty) { -+ session.logConnection(ConnectionLogLevel.info, -+ 'Tunneling via ${jumpChain.map((h) => h.host.label).join(' → ')}'); -+ } -+ _safeNotify(); - await _ssh.connect( - host, - keyEntry: keyEntry, - jumpChain: jumpChain, - verifyHostKey: hostKeyVerifier != null -- ? (keyType, fp) => hostKeyVerifier!(host.host, host.port, keyType, fp) -+ ? (keyType, fp) => -+ _verifyAndLog(session, host.host, host.port, keyType, fp) - : null, - verifyHopHostKey: hostKeyVerifier != null - ? (hop, keyType, fp) => -- hostKeyVerifier!(hop.host, hop.port, keyType, fp) -+ _verifyAndLog(session, hop.host, hop.port, keyType, fp) - : null, - ); -+ session.logConnection(ConnectionLogLevel.success, 'Connection established'); - session.status = SessionStatus.connected; - audit?.record(AuditEvent.now( - type: AuditEventType.connect, host: host, sessionId: session.id)); -@@ -516,6 +528,7 @@ class SessionProvider extends ChangeNotifier { - } - } catch (e) { - if (!_sessions.contains(session)) return; -+ session.logConnection(ConnectionLogLevel.error, 'Connection failed: $e'); - final maxAttempts = reconnectAttempts?.call() ?? 0; - final isUnlimited = maxAttempts == 0; - // A jump-chain config error (deleted bastion, cycle) can never succeed -@@ -545,10 +558,42 @@ class SessionProvider extends ChangeNotifier { - } - } - -+ /// Wraps the host-key verifier so the TOFU check is reflected in the -+ /// session's connection log (shown by the "Show logs" panel). -+ Future _verifyAndLog( -+ SshSession session, String host, int port, String keyType, Uint8List fp) async { -+ session.logConnection( -+ ConnectionLogLevel.info, 'Verifying host key ($keyType) for $host:$port'); -+ _safeNotify(); -+ final ok = await hostKeyVerifier!(host, port, keyType, fp); -+ session.logConnection( -+ ok ? ConnectionLogLevel.success : ConnectionLogLevel.warn, -+ ok ? 'Host key accepted' : 'Host key rejected'); -+ _safeNotify(); -+ return ok; -+ } -+ -+ /// Manual retry from the connecting screen's "Retry" button. Reuses the -+ /// existing session/tab (preserving its terminal and metadata) rather than -+ /// closing and reopening, and cancels any pending auto-reconnect first. -+ void reconnectSession(String sessionId) { -+ final session = sshSessions.where((s) => s.id == sessionId).firstOrNull; -+ if (session == null || session.status == SessionStatus.connecting) return; -+ _reconnectTimers.remove(sessionId)?.cancel(); -+ _countdownTimers.remove(sessionId)?.cancel(); -+ session.status = SessionStatus.connecting; -+ session.errorMessage = null; -+ session.logConnection(ConnectionLogLevel.info, 'Retrying connection…'); -+ _safeNotify(); -+ unawaited(_doConnect(session, session.host, attempt: 1)); -+ } -+ - void _scheduleReconnect(SshSession session, Host host, {required int attempt}) { - session.reconnectCount++; - final delay = (attempt * 2).clamp(2, 60); - session.status = SessionStatus.connecting; -+ session.logConnection( -+ ConnectionLogLevel.warn, 'Reconnecting in ${delay}s (attempt $attempt)'); - _safeNotify(); - - _startCountdown(session, delay, attempt); -diff --git a/app/lib/widgets/terminal_view.dart b/app/lib/widgets/terminal_view.dart -index 6211a18..2a5fef9 100644 ---- a/app/lib/widgets/terminal_view.dart -+++ b/app/lib/widgets/terminal_view.dart -@@ -7,6 +7,7 @@ import '../models/host.dart'; - import '../models/ssh_session.dart'; - import '../providers/command_history_provider.dart'; - import '../providers/host_provider.dart'; -+import '../providers/session_provider.dart'; - import '../providers/settings_provider.dart'; - import '../providers/shell_integration_provider.dart'; - import '../services/hotkey_service.dart'; -@@ -14,6 +15,7 @@ import '../theme/terminal_themes.dart'; - import '../util/terminal_appearance.dart'; - import 'command_gutter.dart'; - import 'record_button.dart'; -+import 'session_connecting_view.dart'; - import 'suggestion_popup.dart'; - import 'terminal_context_menu.dart'; - -@@ -23,27 +25,14 @@ class SessionTerminalView extends StatelessWidget { - - @override - Widget build(BuildContext context) { -- return switch (session.status) { -- SessionStatus.connecting => _statusView(Icons.sync, 'Connecting to ${session.host.host}…', Colors.orange), -- SessionStatus.error => _statusView(Icons.error_outline, session.errorMessage ?? 'Connection error', Colors.red), -- SessionStatus.disconnected => _statusView(Icons.link_off, 'Disconnected', Colors.grey), -- SessionStatus.connected => _TerminalWidget(key: ValueKey(session.id), session: session), -- }; -- } -- -- Widget _statusView(IconData icon, String message, Color color) { -- return Container( -- color: Colors.black, -- child: Center( -- child: Column( -- mainAxisSize: MainAxisSize.min, -- children: [ -- Icon(icon, color: color, size: 40), -- const SizedBox(height: 12), -- Text(message, style: TextStyle(color: color, fontFamily: 'monospace', fontSize: 13)), -- ], -- ), -- ), -+ if (session.status == SessionStatus.connected) { -+ return _TerminalWidget(key: ValueKey(session.id), session: session); -+ } -+ final sessions = context.read(); -+ return SessionConnectingView( -+ session: session, -+ onClose: () => sessions.closeSession(session.id), -+ onRetry: () => sessions.reconnectSession(session.id), - ); - } - } - -=== NEW FILES === - -diff --git a/app/lib/models/connection_log.dart b/app/lib/models/connection_log.dart (NEW) -diff --git a/app/lib/models/connection_log.dart b/app/lib/models/connection_log.dart -new file mode 100644 -index 0000000..ef9f187 ---- /dev/null -+++ b/app/lib/models/connection_log.dart -@@ -0,0 +1,22 @@ -+/// A single line in a session's connection log — the human-readable trace of a -+/// connect attempt (resolve → TCP → host-key verify → auth → shell) surfaced by -+/// the "Show logs" panel on the connecting screen. -+library; -+ -+enum ConnectionLogLevel { info, success, warn, error } -+ -+class ConnectionLogLine { -+ final DateTime time; -+ final ConnectionLogLevel level; -+ final String message; -+ -+ const ConnectionLogLine({ -+ required this.time, -+ required this.level, -+ required this.message, -+ }); -+} -+ -+/// Cap on retained connection-log lines. A flapping host with verbose reconnect -+/// chatter must not grow this list without bound. -+const int kMaxConnectionLogLines = 200; - -diff --git a/app/lib/widgets/session_connecting_view.dart b/app/lib/widgets/session_connecting_view.dart (NEW) -diff --git a/app/lib/widgets/session_connecting_view.dart b/app/lib/widgets/session_connecting_view.dart -new file mode 100644 -index 0000000..408d560 ---- /dev/null -+++ b/app/lib/widgets/session_connecting_view.dart -@@ -0,0 +1,444 @@ -+import 'dart:math' as math; -+ -+import 'package:flutter/material.dart'; -+import 'package:flutter_svg/flutter_svg.dart'; -+ -+import '../models/connection_log.dart'; -+import '../models/host.dart'; -+import '../models/ssh_session.dart'; -+import '../services/os_detection.dart'; -+import '../theme/app_theme.dart'; -+ -+/// Full-area screen shown inside a session tab while an SSH session is -+/// connecting, has errored, or has disconnected. Mirrors mstsc-style connect -+/// UX: a host card, an animated node → line → terminal connection graphic, a -+/// "Show logs" panel over [SshSession.connectionLog], and Close/Retry actions. -+class SessionConnectingView extends StatefulWidget { -+ final SshSession session; -+ final VoidCallback onClose; -+ final VoidCallback onRetry; -+ -+ const SessionConnectingView({ -+ super.key, -+ required this.session, -+ required this.onClose, -+ required this.onRetry, -+ }); -+ -+ @override -+ State createState() => _SessionConnectingViewState(); -+} -+ -+class _SessionConnectingViewState extends State -+ with SingleTickerProviderStateMixin { -+ late final AnimationController _anim; -+ bool _showLogs = false; -+ -+ @override -+ void initState() { -+ super.initState(); -+ _anim = AnimationController( -+ vsync: this, -+ duration: const Duration(milliseconds: 1400), -+ ); -+ _syncAnimation(); -+ } -+ -+ @override -+ void didUpdateWidget(SessionConnectingView old) { -+ super.didUpdateWidget(old); -+ _syncAnimation(); -+ } -+ -+ /// Spin only while connecting; freeze for error/disconnected states. -+ void _syncAnimation() { -+ final connecting = widget.session.status == SessionStatus.connecting; -+ if (connecting && !_anim.isAnimating) { -+ _anim.repeat(); -+ } else if (!connecting && _anim.isAnimating) { -+ _anim.stop(); -+ } -+ } -+ -+ @override -+ void dispose() { -+ _anim.dispose(); -+ super.dispose(); -+ } -+ -+ Color get _nodeColor => switch (widget.session.status) { -+ SessionStatus.error => AppColors.red, -+ SessionStatus.disconnected => AppColors.textTertiary, -+ _ => AppColors.blue, -+ }; -+ -+ @override -+ Widget build(BuildContext context) { -+ final session = widget.session; -+ final host = session.host; -+ final status = session.status; -+ final isError = status == SessionStatus.error; -+ final isDisconnected = status == SessionStatus.disconnected; -+ final canRetry = isError || isDisconnected; -+ -+ final message = switch (status) { -+ SessionStatus.error => session.errorMessage ?? 'Connection error', -+ SessionStatus.disconnected => 'Disconnected', -+ _ => 'Connecting…', -+ }; -+ final messageColor = switch (status) { -+ SessionStatus.error => AppColors.red, -+ SessionStatus.disconnected => AppColors.textSecondary, -+ _ => AppColors.textSecondary, -+ }; -+ -+ return Container( -+ color: AppColors.bg, -+ alignment: Alignment.center, -+ padding: const EdgeInsets.all(24), -+ child: ConstrainedBox( -+ constraints: const BoxConstraints(maxWidth: 520), -+ child: Container( -+ decoration: BoxDecoration( -+ color: AppColors.card, -+ borderRadius: BorderRadius.circular(16), -+ border: Border.all(color: AppColors.border), -+ ), -+ padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), -+ child: Column( -+ mainAxisSize: MainAxisSize.min, -+ crossAxisAlignment: CrossAxisAlignment.start, -+ children: [ -+ _header(host), -+ const SizedBox(height: 22), -+ _ConnectionGraphic( -+ animation: _anim, -+ active: status == SessionStatus.connecting, -+ nodeColor: _nodeColor, -+ ), -+ const SizedBox(height: 14), -+ Text(message, style: TextStyle(color: messageColor, fontSize: 12.5)), -+ const SizedBox(height: 18), -+ Row( -+ children: [ -+ _PillButton(label: 'Close', onTap: widget.onClose), -+ if (canRetry) ...[ -+ const SizedBox(width: 10), -+ _PillButton( -+ label: 'Retry', -+ accent: true, -+ onTap: widget.onRetry, -+ ), -+ ], -+ ], -+ ), -+ if (_showLogs) ...[ -+ const SizedBox(height: 16), -+ _LogsPanel(lines: session.connectionLog), -+ ], -+ ], -+ ), -+ ), -+ ), -+ ); -+ } -+ -+ Widget _header(Host host) { -+ return Row( -+ crossAxisAlignment: CrossAxisAlignment.center, -+ children: [ -+ _OsAvatar(detectedOs: host.detectedOs), -+ const SizedBox(width: 14), -+ Expanded( -+ child: Column( -+ crossAxisAlignment: CrossAxisAlignment.start, -+ mainAxisSize: MainAxisSize.min, -+ children: [ -+ Text( -+ host.label, -+ maxLines: 1, -+ overflow: TextOverflow.ellipsis, -+ style: const TextStyle( -+ color: AppColors.textPrimary, -+ fontSize: 15, -+ fontWeight: FontWeight.w600, -+ ), -+ ), -+ const SizedBox(height: 2), -+ Text( -+ 'SSH ${host.host}:${host.port}', -+ maxLines: 1, -+ overflow: TextOverflow.ellipsis, -+ style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), -+ ), -+ ], -+ ), -+ ), -+ const SizedBox(width: 12), -+ _PillButton( -+ label: _showLogs ? 'Hide logs' : 'Show logs', -+ onTap: () => setState(() => _showLogs = !_showLogs), -+ ), -+ ], -+ ); -+ } -+} -+ -+class _OsAvatar extends StatelessWidget { -+ final String? detectedOs; -+ const _OsAvatar({required this.detectedOs}); -+ -+ @override -+ Widget build(BuildContext context) { -+ final asset = osIconAsset(detectedOs); -+ return Container( -+ width: 44, -+ height: 44, -+ decoration: BoxDecoration( -+ color: Colors.black, -+ borderRadius: BorderRadius.circular(12), -+ ), -+ alignment: Alignment.center, -+ child: asset != null -+ ? SvgPicture.asset( -+ asset, -+ width: 22, -+ height: 22, -+ colorFilter: -+ const ColorFilter.mode(AppColors.textPrimary, BlendMode.srcIn), -+ ) -+ : const Icon(Icons.dns_outlined, size: 22, color: AppColors.textPrimary), -+ ); -+ } -+} -+ -+/// node (link icon, spinning arc while active) ── line ── terminal node. -+class _ConnectionGraphic extends StatelessWidget { -+ final Animation animation; -+ final bool active; -+ final Color nodeColor; -+ -+ const _ConnectionGraphic({ -+ required this.animation, -+ required this.active, -+ required this.nodeColor, -+ }); -+ -+ @override -+ Widget build(BuildContext context) { -+ return SizedBox( -+ height: 48, -+ child: AnimatedBuilder( -+ animation: animation, -+ builder: (context, _) { -+ final t = animation.value; -+ return Row( -+ children: [ -+ _node( -+ child: _spinningRing(t), -+ ), -+ Expanded( -+ child: CustomPaint( -+ size: const Size(double.infinity, 48), -+ painter: _LinePainter( -+ progress: active ? t : null, -+ color: nodeColor, -+ ), -+ ), -+ ), -+ _node( -+ color: AppColors.cardHover, -+ child: const Icon(Icons.terminal, size: 20, color: AppColors.textPrimary), -+ ), -+ ], -+ ); -+ }, -+ ), -+ ); -+ } -+ -+ Widget _spinningRing(double t) { -+ final icon = const Icon(Icons.link, size: 18, color: Colors.white); -+ if (!active) return icon; -+ return CustomPaint( -+ painter: _ArcPainter(turn: t, color: Colors.white), -+ child: SizedBox(width: 38, height: 38, child: Center(child: icon)), -+ ); -+ } -+ -+ Widget _node({Color? color, required Widget child}) { -+ return Container( -+ width: 38, -+ height: 38, -+ decoration: BoxDecoration( -+ color: color ?? nodeColor, -+ shape: BoxShape.circle, -+ ), -+ alignment: Alignment.center, -+ child: child, -+ ); -+ } -+} -+ -+class _ArcPainter extends CustomPainter { -+ final double turn; -+ final Color color; -+ _ArcPainter({required this.turn, required this.color}); -+ -+ @override -+ void paint(Canvas canvas, Size size) { -+ final center = size.center(Offset.zero); -+ final radius = size.width / 2 - 1; -+ final paint = Paint() -+ ..color = color -+ ..style = PaintingStyle.stroke -+ ..strokeWidth = 2.5 -+ ..strokeCap = StrokeCap.round; -+ final start = turn * 2 * math.pi; -+ canvas.drawArc( -+ Rect.fromCircle(center: center, radius: radius), -+ start, -+ math.pi * 0.6, -+ false, -+ paint, -+ ); -+ } -+ -+ @override -+ bool shouldRepaint(_ArcPainter old) => old.turn != turn || old.color != color; -+} -+ -+class _LinePainter extends CustomPainter { -+ /// Animation phase in [0,1] while connecting; null when frozen. -+ final double? progress; -+ final Color color; -+ _LinePainter({required this.progress, required this.color}); -+ -+ @override -+ void paint(Canvas canvas, Size size) { -+ final y = size.height / 2; -+ final base = Paint() -+ ..color = AppColors.border -+ ..strokeWidth = 2 -+ ..strokeCap = StrokeCap.round; -+ canvas.drawLine(Offset(0, y), Offset(size.width, y), base); -+ -+ if (progress == null) return; -+ // A short glowing segment travels left → right along the line. -+ final dotX = progress! * size.width; -+ final glow = Paint() -+ ..color = color -+ ..strokeWidth = 3 -+ ..strokeCap = StrokeCap.round; -+ final half = size.width * 0.12; -+ canvas.drawLine( -+ Offset((dotX - half).clamp(0, size.width), y), -+ Offset((dotX + half).clamp(0, size.width), y), -+ glow, -+ ); -+ } -+ -+ @override -+ bool shouldRepaint(_LinePainter old) => -+ old.progress != progress || old.color != color; -+} -+ -+class _LogsPanel extends StatelessWidget { -+ final List lines; -+ const _LogsPanel({required this.lines}); -+ -+ @override -+ Widget build(BuildContext context) { -+ return Container( -+ constraints: const BoxConstraints(maxHeight: 180), -+ width: double.infinity, -+ decoration: BoxDecoration( -+ color: AppColors.bg, -+ borderRadius: BorderRadius.circular(8), -+ border: Border.all(color: AppColors.border), -+ ), -+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), -+ child: lines.isEmpty -+ ? const Text( -+ 'No logs yet', -+ style: TextStyle(color: AppColors.textTertiary, fontSize: 12, fontFamily: 'monospace'), -+ ) -+ : SingleChildScrollView( -+ reverse: true, -+ child: Column( -+ crossAxisAlignment: CrossAxisAlignment.start, -+ children: [for (final l in lines) _line(l)], -+ ), -+ ), -+ ); -+ } -+ -+ Widget _line(ConnectionLogLine l) { -+ final color = switch (l.level) { -+ ConnectionLogLevel.success => AppColors.accent, -+ ConnectionLogLevel.warn => AppColors.orange, -+ ConnectionLogLevel.error => AppColors.red, -+ ConnectionLogLevel.info => AppColors.textSecondary, -+ }; -+ const base = TextStyle(fontSize: 12, fontFamily: 'monospace', height: 1.35); -+ return Padding( -+ padding: const EdgeInsets.symmetric(vertical: 1), -+ child: Row( -+ crossAxisAlignment: CrossAxisAlignment.start, -+ children: [ -+ Text(_hms(l.time), style: base.copyWith(color: AppColors.textTertiary)), -+ const SizedBox(width: 10), -+ Expanded(child: Text(l.message, style: base.copyWith(color: color))), -+ ], -+ ), -+ ); -+ } -+ -+ String _hms(DateTime t) { -+ String two(int n) => n.toString().padLeft(2, '0'); -+ return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; -+ } -+} -+ -+class _PillButton extends StatefulWidget { -+ final String label; -+ final bool accent; -+ final VoidCallback onTap; -+ const _PillButton({required this.label, this.accent = false, required this.onTap}); -+ -+ @override -+ State<_PillButton> createState() => _PillButtonState(); -+} -+ -+class _PillButtonState extends State<_PillButton> { -+ bool _hovered = false; -+ -+ @override -+ Widget build(BuildContext context) { -+ final accent = widget.accent; -+ final bg = accent -+ ? (_hovered ? AppColors.accentDim : AppColors.accent) -+ : (_hovered ? AppColors.cardHover : AppColors.bg); -+ final fg = accent ? Colors.black : AppColors.textPrimary; -+ return MouseRegion( -+ cursor: SystemMouseCursors.click, -+ onEnter: (_) => setState(() => _hovered = true), -+ onExit: (_) => setState(() => _hovered = false), -+ child: GestureDetector( -+ onTap: widget.onTap, -+ child: Container( -+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), -+ decoration: BoxDecoration( -+ color: bg, -+ borderRadius: BorderRadius.circular(8), -+ border: Border.all(color: accent ? AppColors.accent : AppColors.border), -+ ), -+ child: Text( -+ widget.label, -+ style: TextStyle(color: fg, fontSize: 13, fontWeight: FontWeight.w500), -+ ), -+ ), -+ ), -+ ); -+ } -+} - -diff --git a/app/test/models/connection_log_test.dart b/app/test/models/connection_log_test.dart (NEW) -diff --git a/app/test/models/connection_log_test.dart b/app/test/models/connection_log_test.dart -new file mode 100644 -index 0000000..c6d5258 ---- /dev/null -+++ b/app/test/models/connection_log_test.dart -@@ -0,0 +1,53 @@ -+import 'package:flutter_test/flutter_test.dart'; -+import 'package:yourssh/models/connection_log.dart'; -+import 'package:yourssh/models/host.dart'; -+import 'package:yourssh/models/ssh_session.dart'; -+ -+SshSession _session() => SshSession( -+ host: Host(id: 'h1', label: 'Box', host: 'example.com', port: 22, username: 'root'), -+ ); -+ -+void main() { -+ group('SshSession.logConnection', () { -+ test('starts empty', () { -+ expect(_session().connectionLog, isEmpty); -+ }); -+ -+ test('appends a line with the given level and message', () { -+ final s = _session(); -+ s.logConnection(ConnectionLogLevel.info, 'Connecting…'); -+ s.logConnection(ConnectionLogLevel.success, 'Connected'); -+ -+ expect(s.connectionLog, hasLength(2)); -+ expect(s.connectionLog.first.level, ConnectionLogLevel.info); -+ expect(s.connectionLog.first.message, 'Connecting…'); -+ expect(s.connectionLog.last.level, ConnectionLogLevel.success); -+ }); -+ -+ test('uses the provided timestamp when given', () { -+ final s = _session(); -+ final t = DateTime(2026, 6, 23, 10, 30, 45); -+ s.logConnection(ConnectionLogLevel.info, 'x', at: t); -+ expect(s.connectionLog.single.time, t); -+ }); -+ -+ test('bounds the log to the most recent kMaxConnectionLogLines', () { -+ final s = _session(); -+ for (var i = 0; i < kMaxConnectionLogLines + 50; i++) { -+ s.logConnection(ConnectionLogLevel.info, 'line $i'); -+ } -+ expect(s.connectionLog, hasLength(kMaxConnectionLogLines)); -+ // Oldest lines dropped; the newest line is retained. -+ expect(s.connectionLog.last.message, 'line ${kMaxConnectionLogLines + 49}'); -+ expect(s.connectionLog.first.message, 'line 50'); -+ }); -+ -+ test('clearConnectionLog empties the buffer', () { -+ final s = _session() -+ ..logConnection(ConnectionLogLevel.info, 'a') -+ ..logConnection(ConnectionLogLevel.error, 'b'); -+ s.clearConnectionLog(); -+ expect(s.connectionLog, isEmpty); -+ }); -+ }); -+} - -diff --git a/app/test/providers/session_provider_connection_log_test.dart b/app/test/providers/session_provider_connection_log_test.dart (NEW) -diff --git a/app/test/providers/session_provider_connection_log_test.dart b/app/test/providers/session_provider_connection_log_test.dart -new file mode 100644 -index 0000000..b2478e6 ---- /dev/null -+++ b/app/test/providers/session_provider_connection_log_test.dart -@@ -0,0 +1,123 @@ -+import 'dart:async'; -+import 'dart:typed_data'; -+ -+import 'package:dartssh2/dartssh2.dart'; -+import 'package:flutter_test/flutter_test.dart'; -+import 'package:shared_preferences/shared_preferences.dart'; -+import 'package:yourssh/models/connection_log.dart'; -+import 'package:yourssh/models/host.dart'; -+import 'package:yourssh/models/ssh_key.dart'; -+import 'package:yourssh/models/ssh_session.dart'; -+import 'package:yourssh/providers/session_provider.dart'; -+import 'package:yourssh/services/ssh_service.dart'; -+import 'package:yourssh/services/storage_service.dart'; -+import 'package:yourssh/services/tab_metadata_service.dart'; -+ -+class _NullClient implements SSHClient { -+ @override -+ dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); -+} -+ -+class _FakeSsh extends SshService { -+ _FakeSsh() : super(StorageService()); -+ bool failConnect = false; -+ -+ /// Keeps connect() pending so the session stays in `connecting`. -+ Completer? connectGate; -+ -+ /// Keeps openShell pending so a connected session doesn't drop. -+ Completer? shellGate; -+ -+ @override -+ Future connect( -+ Host host, { -+ SshKeyEntry? keyEntry, -+ List jumpChain = const [], -+ Future Function(String keyType, Uint8List fingerprint)? verifyHostKey, -+ Future Function(Host hop, String keyType, Uint8List fp)? -+ verifyHopHostKey, -+ }) async { -+ final gate = connectGate; -+ if (gate != null) await gate.future; -+ if (failConnect) throw Exception('refused'); -+ return _NullClient(); -+ } -+ -+ @override -+ Future openShell(SshSession session, -+ {bool useTmux = false, String termType = 'xterm-256color'}) async { -+ final gate = shellGate; -+ if (gate != null) await gate.future; -+ } -+ -+ @override -+ void disconnectSession(String sessionId) {} -+ @override -+ void disconnect(String hostId) {} -+} -+ -+Host _host() => Host(label: 'prod', host: 'p.com', username: 'root'); -+ -+List _messages(SshSession s) => -+ s.connectionLog.map((l) => l.message).toList(); -+ -+void main() { -+ TestWidgetsFlutterBinding.ensureInitialized(); -+ setUp(() => SharedPreferences.setMockInitialValues({})); -+ -+ test('successful connect logs the connecting and established steps', () async { -+ final p = SessionProvider(_FakeSsh(), TabMetadataService()); -+ await p.connect(_host()); -+ final s = p.sshSessions.single; -+ -+ final msgs = _messages(s); -+ expect(msgs, contains('Connecting to p.com:22 as root')); -+ expect(msgs, contains('Connection established')); -+ p.dispose(); -+ }); -+ -+ test('failed connect logs an error line', () async { -+ final p = SessionProvider(_FakeSsh()..failConnect = true, TabMetadataService()); -+ await p.connect(_host()); -+ final s = p.sshSessions.single; -+ -+ expect(s.status, SessionStatus.error); -+ expect(_messages(s).any((m) => m.startsWith('Connection failed:')), isTrue); -+ expect(s.connectionLog.last.level, ConnectionLogLevel.error); -+ p.dispose(); -+ }); -+ -+ test('reconnectSession retries an errored session and clears the error', () async { -+ final ssh = _FakeSsh()..failConnect = true; -+ final p = SessionProvider(ssh, TabMetadataService()); -+ await p.connect(_host()); -+ final s = p.sshSessions.single; -+ expect(s.status, SessionStatus.error); -+ -+ // Next attempt succeeds and the shell stays open. -+ ssh.failConnect = false; -+ ssh.shellGate = Completer(); -+ p.reconnectSession(s.id); -+ await Future.delayed(Duration.zero); -+ -+ expect(s.status, SessionStatus.connected); -+ expect(s.errorMessage, isNull); -+ expect(_messages(s), contains('Retrying connection…')); -+ p.dispose(); -+ }); -+ -+ test('reconnectSession is a no-op while already connecting', () async { -+ final ssh = _FakeSsh()..connectGate = Completer(); -+ final p = SessionProvider(ssh, TabMetadataService()); -+ // Don't await: the gated connect() keeps the session in `connecting`. -+ unawaited(p.connect(_host())); -+ await Future.delayed(Duration.zero); -+ final s = p.sshSessions.single; -+ expect(s.status, SessionStatus.connecting); -+ -+ final before = s.connectionLog.length; -+ p.reconnectSession(s.id); -+ expect(s.connectionLog.length, before); // no "Retrying…" line appended -+ p.dispose(); -+ }); -+} - -diff --git a/app/test/widgets/session_connecting_view_test.dart b/app/test/widgets/session_connecting_view_test.dart (NEW) -diff --git a/app/test/widgets/session_connecting_view_test.dart b/app/test/widgets/session_connecting_view_test.dart -new file mode 100644 -index 0000000..9272b25 ---- /dev/null -+++ b/app/test/widgets/session_connecting_view_test.dart -@@ -0,0 +1,90 @@ -+import 'package:flutter/material.dart'; -+import 'package:flutter_test/flutter_test.dart'; -+import 'package:yourssh/models/connection_log.dart'; -+import 'package:yourssh/models/host.dart'; -+import 'package:yourssh/models/ssh_session.dart'; -+import 'package:yourssh/widgets/session_connecting_view.dart'; -+ -+SshSession _session({ -+ SessionStatus status = SessionStatus.connecting, -+ String? error, -+}) { -+ final s = SshSession( -+ host: Host(id: 'h1', label: 'Thang MacBook Pro', host: 'thngs-macbook-pro', port: 22, username: 'thang'), -+ status: status, -+ ); -+ s.errorMessage = error; -+ return s; -+} -+ -+Future _pump( -+ WidgetTester tester, -+ SshSession session, { -+ VoidCallback? onClose, -+ VoidCallback? onRetry, -+}) async { -+ await tester.pumpWidget(MaterialApp( -+ home: Scaffold( -+ body: SessionConnectingView( -+ session: session, -+ onClose: onClose ?? () {}, -+ onRetry: onRetry ?? () {}, -+ ), -+ ), -+ )); -+ await tester.pump(); // start the spinner animation; don't settle (it repeats). -+} -+ -+void main() { -+ testWidgets('shows the host label and SSH host:port subtitle', (tester) async { -+ await _pump(tester, _session()); -+ expect(find.text('Thang MacBook Pro'), findsOneWidget); -+ expect(find.text('SSH thngs-macbook-pro:22'), findsOneWidget); -+ }); -+ -+ testWidgets('connecting state shows Close but not Retry', (tester) async { -+ await _pump(tester, _session(status: SessionStatus.connecting)); -+ expect(find.text('Close'), findsOneWidget); -+ expect(find.text('Retry'), findsNothing); -+ }); -+ -+ testWidgets('error state shows the error message and a Retry button', (tester) async { -+ await _pump(tester, _session(status: SessionStatus.error, error: 'Auth failed')); -+ expect(find.text('Auth failed'), findsOneWidget); -+ expect(find.text('Retry'), findsOneWidget); -+ expect(find.text('Close'), findsOneWidget); -+ }); -+ -+ testWidgets('Show logs toggles the connection log panel', (tester) async { -+ final s = _session() -+ ..logConnection(ConnectionLogLevel.info, 'Resolving thngs-macbook-pro') -+ ..logConnection(ConnectionLogLevel.success, 'TCP established'); -+ await _pump(tester, s); -+ -+ // Hidden by default. -+ expect(find.text('Resolving thngs-macbook-pro'), findsNothing); -+ -+ await tester.tap(find.text('Show logs')); -+ await tester.pump(); -+ -+ expect(find.text('Resolving thngs-macbook-pro'), findsOneWidget); -+ expect(find.text('TCP established'), findsOneWidget); -+ }); -+ -+ testWidgets('Close and Retry fire their callbacks', (tester) async { -+ var closed = 0, retried = 0; -+ await _pump( -+ tester, -+ _session(status: SessionStatus.error, error: 'x'), -+ onClose: () => closed++, -+ onRetry: () => retried++, -+ ); -+ -+ await tester.tap(find.text('Retry')); -+ await tester.tap(find.text('Close')); -+ await tester.pump(); -+ -+ expect(retried, 1); -+ expect(closed, 1); -+ }); -+} diff --git a/app/integration_test/local_shell_probe_test.dart b/app/integration_test/local_shell_probe_test.dart deleted file mode 100644 index a76792db..00000000 --- a/app/integration_test/local_shell_probe_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -// Throwaway diagnostic: boot the real app and open a local shell through the -// real SessionProvider/LocalShellService path, then report the session status -// and terminal buffer. Delete after debugging. -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:provider/provider.dart'; -import 'package:yourssh/main.dart' as app; -import 'package:yourssh/models/local_session.dart'; -import 'package:yourssh/providers/session_provider.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('probe real local shell flow', (tester) async { - app.main(); - await tester.pump(const Duration(seconds: 2)); - - final context = tester.element(find.byType(Navigator).first); - final sessions = Provider.of(context, listen: false); - - await sessions.newLocalSession(); - // Let the PTY live (or die) for a few seconds of real time. - for (var i = 0; i < 25; i++) { - await tester.pump(const Duration(milliseconds: 200)); - await Future.delayed(const Duration(milliseconds: 200)); - } - - final local = sessions.sessions.whereType().toList(); - // ignore: avoid_print - print('PROBE: ${local.length} local session(s)'); - for (final s in local) { - // ignore: avoid_print - print('PROBE: status=${s.status} error=${s.errorMessage}'); - final buf = s.terminal.buffer; - final lines = []; - for (var y = 0; y < buf.height; y++) { - final line = buf.lines[y].toString().trimRight(); - if (line.isNotEmpty) lines.add(line); - } - // ignore: avoid_print - print('PROBE: terminal content:\n${lines.join('\n')}'); - } - }, timeout: const Timeout(Duration(minutes: 3))); -} diff --git a/app/integration_test/pty_probe_test.dart b/app/integration_test/pty_probe_test.dart deleted file mode 100644 index f199e18e..00000000 --- a/app/integration_test/pty_probe_test.dart +++ /dev/null @@ -1,44 +0,0 @@ -// Throwaway diagnostic: probe Pty.start on this machine to find out why the -// local shell exits immediately. Delete after debugging. -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_pty/flutter_pty.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - test('probe local shell spawn', () async { - final shell = Platform.environment['SHELL'] ?? '/bin/zsh'; - // ignore: avoid_print - print('PROBE: spawning "$shell" (HOME=${Platform.environment['HOME']})'); - - final pty = Pty.start( - shell, - arguments: const [], - columns: 80, - rows: 24, - environment: {...Platform.environment, 'TERM': 'xterm-256color'}, - ); - - final output = StringBuffer(); - final sub = pty.output - .cast>() - .transform(const Utf8Decoder(allowMalformed: true)) - .listen(output.write); - - final exit = await pty.exitCode - .timeout(const Duration(seconds: 5), onTimeout: () => -9999); - - // ignore: avoid_print - print('PROBE: exitCode=$exit (−9999 means still alive after 5s = OK)'); - // ignore: avoid_print - print('PROBE: output so far: ${jsonEncode(output.toString())}'); - - await sub.cancel(); - if (exit == -9999) pty.kill(); - }, timeout: const Timeout(Duration(minutes: 2))); -}