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/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/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..48a0036f --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,15 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ +/build/ + +# 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..4d94c381 --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +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) + // require compiling against API 36; the Flutter default is still 34. + compileSdk = 36 + 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 + } + + 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 { + signingConfig = if (keystorePropertiesFile.exists()) + signingConfigs.getByName("release") + else + 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..36c95101 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + 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-hdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ba11f5fb Binary files /dev/null and b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..9a53a929 Binary files /dev/null and b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ 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-xhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..5f5b528c Binary files /dev/null and b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ 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 00000000..fcb3755f Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ 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 00000000..fae3ca0b Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ 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-anydpi-v26/ic_launcher.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..c79c58a3 --- /dev/null +++ b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + 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 00000000..685bcb39 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ 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 00000000..ed2743b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..bde17647 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 00000000..aeb6088c Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ 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 00000000..f2046535 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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/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/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..de7fd1d7 --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,39 @@ +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) +} +// 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") +} + +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/assets/android_adaptive_fg.png b/app/assets/android_adaptive_fg.png new file mode 100644 index 00000000..b36a933b Binary files /dev/null and b/app/assets/android_adaptive_fg.png differ 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), diff --git a/app/lib/mobile/mobile_app.dart b/app/lib/mobile/mobile_app.dart new file mode 100644 index 00000000..73c44773 --- /dev/null +++ b/app/lib/mobile/mobile_app.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.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; 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}); + + @override + State createState() => _YourSSHMobileAppState(); +} + +class _YourSSHMobileAppState extends State { + final _bootstrap = MobileBootstrap(); + + @override + void dispose() { + _bootstrap.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: _bootstrap.providers, + child: MaterialApp( + title: 'YourSSH', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: buildMobileTheme(), + home: const AppLockGate(child: MobileHomeShell()), + ), + ); + } +} diff --git a/app/lib/mobile/mobile_bootstrap.dart b/app/lib/mobile/mobile_bootstrap.dart new file mode 100644 index 00000000..569eeaf4 --- /dev/null +++ b/app/lib/mobile/mobile_bootstrap.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +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'; +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/key_gen_service.dart'; +import '../services/port_forward_service.dart'; +import '../services/sftp_transfer_service.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 +/// 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; + late final SyncProvider sync; + late final SyncService syncService; + late final SnippetProvider snippets; + late final SftpTransferService transfer; + late final PortForwardProvider portForwardProvider; + late final PortForwardService portForwardService; + late final KeyGenService keyGen; + + MobileBootstrap() { + storage = StorageService(); + ssh = SshService(storage); + hostProvider = HostProvider(storage); + keyProvider = KeyProvider()..savePassphrase = storage.savePassphrase; + settings = SettingsProvider(); + knownHosts = KnownHostsProvider(storage)..load(); + sessions = SessionProvider(ssh, TabMetadataService()); + sync = SyncProvider(storage: storage); + 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), + ); + keyGen = KeyGenService(); + hostProvider.onHostDeleted = + (id) => unawaited(portForwardService.stopForHost(id)); + unawaited(portForwardProvider.ready.then( + (_) => portForwardService.autoStartAll(portForwardProvider.forwards))); + _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), + ChangeNotifierProvider.value(value: sync), + Provider.value(value: syncService), + ChangeNotifierProvider.value(value: snippets), + 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 + /// 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_add_host_screen.dart b/app/lib/mobile/screens/mobile_add_host_screen.dart new file mode 100644 index 00000000..eb10bcdf --- /dev/null +++ b/app/lib/mobile/screens/mobile_add_host_screen.dart @@ -0,0 +1,691 @@ +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 '../../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'; + +/// 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; + + const MobileAddHostScreen({super.key, this.existing}); + + @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(); + 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 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; + _address.text = h.host; + _port.text = '${h.port}'; + _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, + _group, + _snippet, + ]) { + c.dispose(); + } + super.dispose(); + } + + // ── 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]; + } + + /// 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(); + final port = int.tryParse(_port.text.trim()) ?? 22; + final snippetVal = _snippet.text.trim(); + final provider = context.read(); + final existing = widget.existing; + + if (existing != null) { + final newTags = _buildTags(existing.tags); + if (_isCertOrAgent) { + // 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 { + 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( + updated, + password: _useKey ? null : _password.text, + ); + } + return existing.id; + } 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, + port: port, + username: username, + authType: authType, + keyId: keyId, + tags: groupVal.isEmpty ? [] : [groupVal], + startupSnippet: snippetVal.isEmpty ? null : snippetVal, + ); + await provider.addHost(host, password: _useKey ? null : _password.text); + return host.id; + } + } + + Future _save() async { + if (!_canSave) return; + await _doSave(); + if (mounted) Navigator.of(context).pop(); + } + + Future _saveAndConnect() async { + if (!_canSave) return; + final savedId = await _doSave(); + if (!mounted) return; + // Use the id returned by _doSave — deterministic, no .last assumption. + final host = context.read().byId(savedId); + if (host != null && mounted) { + context.read().connectAny(host); + } + if (mounted) Navigator.of(context).pop(); + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final keys = context.watch().keys; + + return Scaffold( + backgroundColor: MobileColors.bg, + appBar: _buildAppBar(), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space4, + MobileTokens.space5 * 2, + ), + children: [ + _generalGroup(), + const SizedBox(height: MobileTokens.space4), + _authGroup(keys), + const SizedBox(height: MobileTokens.space4), + _advancedGroup(), + const SizedBox(height: MobileTokens.space5), + _saveConnectButton(), + ], + ), + ), + ); + } + + 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? 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.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 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 _CertAgentNoteRow({required this.authType}); + + @override + Widget build(BuildContext context) { + final kind = + authType == AuthType.certificate ? 'Certificate' : 'SSH agent'; + 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/lib/mobile/screens/mobile_home_shell.dart b/app/lib/mobile/screens/mobile_home_shell.dart new file mode 100644 index 00000000..5eed0ae7 --- /dev/null +++ b/app/lib/mobile/screens/mobile_home_shell.dart @@ -0,0 +1,48 @@ +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'; +import 'mobile_keys_screen.dart'; +import 'mobile_settings_screen.dart'; +import 'mobile_snippets_screen.dart'; + +/// Bottom-navigation shell for the Android app. +/// Four tabs: Hosts · Snippets · Keys · Settings. +/// Tab bodies are temporary placeholders until tasks T8/T9 land. +class MobileHomeShell extends StatefulWidget { + const MobileHomeShell({super.key}); + + @override + State createState() => _MobileHomeShellState(); +} + +class _MobileHomeShellState extends State { + MobileTab _current = MobileTab.hosts; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => HostReachabilityProbe(), + child: Scaffold( + body: TofuWatcher( + child: IndexedStack( + index: MobileTab.values.indexOf(_current), + children: const [ + MobileHostsScreen(), + MobileSnippetsScreen(), + MobileKeysScreen(), + MobileSettingsScreen(), + ], + ), + ), + 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 new file mode 100644 index 00000000..fc0ba603 --- /dev/null +++ b/app/lib/mobile/screens/mobile_hosts_screen.dart @@ -0,0 +1,556 @@ +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 '../../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'; +import 'mobile_port_forward_screen.dart'; +import 'mobile_sftp_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. +class MobileHostsScreen extends StatefulWidget { + 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); + 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; + } + + List _filtered(List all) { + 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 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)), + ); + } + + /// 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; + 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); + final rows = _buildRows(grouped); + + // 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: MobileColors.bg, + body: SafeArea( + child: Stack( + children: [ + 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, + ), + ), + ], + ), + ], + ), + ), + const _DecorativeCircle(icon: Icons.more_horiz), + const SizedBox(width: MobileTokens.space2), + const _DecorativeCircle(icon: Icons.person_outline), + ], + ), + ), + + // ── 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: MobileColors.fieldFill, + contentPadding: const EdgeInsets.symmetric( + 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), + ), + ), + ), + ), + const SizedBox(height: MobileTokens.space3), + + // ── Folder chips ───────────────────────────────────────────── + if (tags.isNotEmpty) + SizedBox( + height: 38, + 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', + large: true, + selected: _tagFilter == null, + onTap: () => setState(() => _tagFilter = null), + ), + ), + for (final tag in tags) + Padding( + padding: const EdgeInsets.only( + right: MobileTokens.space2), + child: TagChip( + label: tag, + large: true, + 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: rows.length, + itemBuilder: (_, i) => + _buildRowWidget(rows[i], sessions, probe), + ), + ), + ), + ], + ), + + // ── FAB ────────────────────────────────────────────────────────── + Positioned( + right: MobileTokens.space4, + bottom: MobileTokens.space4, + child: MobileFab(onTap: _pushAddHost), + ), + ], + ), + ), + ); + } + + // ── Grouped list helpers ───────────────────────────────────────────────── + + /// O(1) item builder — indexes into the pre-built flat [rows] list. + Widget _buildRowWidget( + _ListRow row, + List sessions, + HostReachabilityProbe probe, + ) { + if (row is _HeaderRow) { + return Padding( + padding: const EdgeInsets.fromLTRB( + MobileTokens.space4, + MobileTokens.space3, + MobileTokens.space4, + 0, + ), + child: SectionHeader(row.title), + ); + } + 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: () => _openSession(host), + onLongPress: () => _showActions(host), + ); + } + + // ── Actions ────────────────────────────────────────────────────────────── + + 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.last.id; + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => MobileTerminalScreen( + focusSessionId: sessionId, + onOpenFiles: (h) => _pushSftp(ctx, h), + onOpenPortForward: (h) => _pushPortForward(ctx, h), + ), + ), + ); + 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: (ctx) => MobileTerminalScreen( + focusSessionId: sessionId, + onOpenFiles: (h) => _pushSftp(ctx, h), + onOpenPortForward: (h) => _pushPortForward(ctx, h), + ), + ), + ); + } + + void _pushSftp(BuildContext ctx, Host host) { + Navigator.of(ctx).push( + MaterialPageRoute( + builder: (_) => MobileSftpScreen(host: host), + ), + ); + } + + 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()), + ); + } + + Future _showActions(Host host) 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: [ + // 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: MobileColors.textPrimary), + title: Text('Edit', + style: mobileBody(size: 16, weight: FontWeight.w500)), + onTap: () { + Navigator.pop(ctx); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MobileAddHostScreen(existing: host), + )); + }, + ), + ListTile( + 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), + ], + ), + ), + ); + } + + Future _confirmDelete(Host host) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MobileColors.surface, + title: Text('Delete ${host.label}?', + 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: Text('Cancel', style: mobileBody(size: 15)), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text('Delete', + style: mobileBody(size: 15, color: MobileColors.red)), + ), + ], + ), + ); + if (ok == true && mounted) { + await context.read().deleteHost(host.id); + } + } +} + +// ── 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 ──────────────────────────────────────────────────────────── + +/// Non-interactive decorative circle icon — purely visual, no tap target. +class _DecorativeCircle extends StatelessWidget { + const _DecorativeCircle({required this.icon}); + final IconData icon; + + @override + Widget build(BuildContext context) { + 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), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.onAdd, required this.hasQuery}); + final VoidCallback onAdd; + final bool hasQuery; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasQuery ? Icons.search_off : Icons.dns_outlined, + size: 48, + color: MobileColors.textFaint, + ), + const SizedBox(height: MobileTokens.space3), + Text( + hasQuery ? 'No results' : 'No hosts yet', + style: mobileHeading(size: 17), + ), + const SizedBox(height: MobileTokens.space1), + 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/lib/mobile/screens/mobile_keys_screen.dart b/app/lib/mobile/screens/mobile_keys_screen.dart new file mode 100644 index 00000000..64af55a8 --- /dev/null +++ b/app/lib/mobile/screens/mobile_keys_screen.dart @@ -0,0 +1,828 @@ +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; + + // 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) { + 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; + 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()); + } 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: MobileColors.fieldFill, + 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), + ), + ); + +// ── 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/screens/mobile_port_forward_screen.dart b/app/lib/mobile/screens/mobile_port_forward_screen.dart new file mode 100644 index 00000000..136b2e62 --- /dev/null +++ b/app/lib/mobile/screens/mobile_port_forward_screen.dart @@ -0,0 +1,368 @@ +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: const [SizedBox(width: 8)], + 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(); + + String? _validationError; + + @override + void dispose() { + _labelCtrl.dispose(); + _localPortCtrl.dispose(); + _remoteHostCtrl.dispose(); + _remotePortCtrl.dispose(); + 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 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(); + + 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; + _validationError = null; + }), + 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), + 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( + 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_qr_scan_screen.dart b/app/lib/mobile/screens/mobile_qr_scan_screen.dart new file mode 100644 index 00000000..cea47c9e --- /dev/null +++ b/app/lib/mobile/screens/mobile_qr_scan_screen.dart @@ -0,0 +1,79 @@ +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/mobile_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: 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 new file mode 100644 index 00000000..85f9c7d5 --- /dev/null +++ b/app/lib/mobile/screens/mobile_settings_screen.dart @@ -0,0 +1,236 @@ +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/sync_provider.dart'; +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_sync_screen.dart'; + +/// Settings tab — grouped preferences and security. +/// Groups: TERMINAL · SECURITY · KEYBOARD & SYNC +/// Both "Supabase sync" and "Pair new device" push [MobileSyncScreen]. +class MobileSettingsScreen extends StatefulWidget { + const MobileSettingsScreen({super.key}); + + @override + State createState() => _MobileSettingsScreenState(); +} + +class _MobileSettingsScreenState extends State { + // ── Security ────────────────────────────────────────────────────────────── + bool _appLock = true; + + // ── Keyboard ────────────────────────────────────────────────────────────── + bool _accessoryBar = true; + + // ── Version ─────────────────────────────────────────────────────────────── + String _version = ''; + + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((p) { + if (mounted) { + setState(() { + _appLock = p.getBool(kAppLockPrefKey) ?? true; + _accessoryBar = p.getBool(kAccessoryBarPrefKey) ?? true; + }); + } + }); + PackageInfo.fromPlatform().then((info) { + if (mounted) setState(() => _version = info.version); + }); + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + + return Scaffold( + backgroundColor: MobileColors.bg, + appBar: AppBar( + backgroundColor: MobileColors.bg, + surfaceTintColor: Colors.transparent, + title: Text('Settings', style: mobileHeading()), + actions: const [], + ), + 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, + ), + ), + const SizedBox(height: 2), + Text( + 'Supabase · end-to-end encrypted', + style: mobileBody(size: 12, color: MobileColors.textMuted), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: active + ? MobileColors.accent.withAlpha(38) + : MobileColors.border, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + ), + child: Text( + active ? 'On' : 'Off', + style: mobileBody( + size: 12, + weight: FontWeight.w600, + color: active ? MobileColors.accent : MobileColors.textFaint, + ), + ), + ), + ], + ), + ); + } + + // ── 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: const Icon(Icons.lock_clock_outlined, color: MobileColors.textMuted, size: 20), + title: 'Auto-lock', + value: 'After 1 min', + ), + ], + ); + } + + // ── 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: _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), + title: 'Supabase sync', + value: sync.isSupabaseConfigured ? 'Configured' : null, + onTap: _pushSyncScreen, + ), + SettingsRow( + leading: Icon(Icons.qr_code_scanner, color: MobileColors.textMuted, size: 20), + title: 'Pair new device', + onTap: _pushSyncScreen, + ), + ], + ); + } + + // ── Footer ──────────────────────────────────────────────────────────────── + + Widget _footer() { + return Center( + child: Text( + 'YourSSH · Version $_version', + style: mobileBody(size: 12, color: MobileColors.textFaint), + ), + ); + } + + // ── Navigation ──────────────────────────────────────────────────────────── + + Future _pushSyncScreen() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MobileSyncScreen()), + ); + } +} 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..d3338f90 --- /dev/null +++ b/app/lib/mobile/screens/mobile_sftp_screen.dart @@ -0,0 +1,638 @@ +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 '../../services/sftp_transfer_service.dart'; +import '../../services/ssh_service.dart'; +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.dart'; + +/// 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 { + 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; + + // 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; + // 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() async { + if (_sftp != null) return _sftp!; + _sftp = await context.read().openSftp(widget.host); + return _sftp!; + } + + 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 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 = entries; + _loading = false; + }); + } catch (e) { + if (mounted && token == _loadToken) { + setState(() { + _loading = false; + _error = '$e'; + }); + } + } + } + + 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; + }); + } + + void _applySort() { + final sorted = List.from(_entries); + _sortEntries(sorted); + setState(() => _entries = sorted); + } + + // ── Breadcrumb helpers ──────────────────────────────────────────────────── + + 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'; + } + 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) { + 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; + } + + 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; + } + + // ── Transfer ops ────────────────────────────────────────────────────────── + + 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(); + final file = await sftp.open(_join(e.filename)); + await for (final chunk in file.read()) { + sink.add(chunk); + } + await file.close(); + _snack('Downloaded ${e.filename}'); + } catch (err) { + _snack('Download failed: $err'); + } finally { + await sink.close(); + } + } + + Future _upload() async { + final transfer = context.read(); + final picked = await FilePicker.platform.pickFiles(); + final localPath = + picked == null || picked.files.isEmpty ? null : picked.files.first.path; + if (localPath == null) return; + final name = p.basename(localPath); + try { + await transfer.uploadFile(widget.host, localPath, _join(name)); + _snack('Uploaded $name'); + _load(_path); + } catch (err) { + _snack('Upload failed: $err'); + } + } + + void _snack(String msg) { + if (mounted) { + 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_snippets_screen.dart b/app/lib/mobile/screens/mobile_snippets_screen.dart new file mode 100644 index 00000000..0fc7fd99 --- /dev/null +++ b/app/lib/mobile/screens/mobile_snippets_screen.dart @@ -0,0 +1,165 @@ +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: MobileTokens.space1), + Text( + subtitle, + style: mobileBody(size: 13, color: MobileColors.textMuted), + ), + ], + ), + ), + + // ── Category filter chips ──────────────────────────────────────── + if (tags.isNotEmpty) + SizedBox( + height: 38, + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: MobileTokens.space4), + scrollDirection: Axis.horizontal, + children: [ + TagChip( + label: 'All', + large: true, + selected: _selectedTag == null, + onTap: () => setState(() => _selectedTag = null), + ), + for (final tag in tags) ...[ + const SizedBox(width: MobileTokens.space2), + TagChip( + label: tag, + large: true, + 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/screens/mobile_sync_screen.dart b/app/lib/mobile/screens/mobile_sync_screen.dart new file mode 100644 index 00000000..e2da4fe8 --- /dev/null +++ b/app/lib/mobile/screens/mobile_sync_screen.dart @@ -0,0 +1,603 @@ +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; + + // ── Supabase config form state ──────────────────────────────────────────── + final _url = TextEditingController(); + final _anon = TextEditingController(); + final _code = TextEditingController(); + bool _editingConfig = false; + bool _pulling = false; + String? _configError; + + @override + void initState() { + super.initState(); + _startServer(); + } + + @override + 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 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, anonKey); + 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 { + 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), + _buildConfigSection(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: const [SizedBox(width: 8)], + ); + + // ── 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), + ], + ], + ), + ), + ], + ); + + // ── 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 (_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!, + 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( + 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/lib/mobile/screens/mobile_terminal_screen.dart b/app/lib/mobile/screens/mobile_terminal_screen.dart new file mode 100644 index 00000000..a8ee134c --- /dev/null +++ b/app/lib/mobile/screens/mobile_terminal_screen.dart @@ -0,0 +1,627 @@ +import 'package:flutter/material.dart'; +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'; +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. +class MobileTerminalScreen extends StatefulWidget { + final String? focusSessionId; + + /// 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; + + /// 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, + 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; + + /// 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(); + _loadAccessoryPref(); + if (widget.focusSessionId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().setActive(widget.focusSessionId!); + } + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final ssh = context.watch().sshSessions; + if (ssh.isEmpty && !_popScheduled) { + _popScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && context.read().sshSessions.isEmpty) { + Navigator.maybePop(context); + } + _popScheduled = false; + }); + } + } + + 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 && active != null) { + widget.onOpenFiles!(active.host); + } + }, + ), + 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 && active != null) { + widget.onOpenPortForward!(active.host); + } + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + /// 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 { + if (!mounted) return; + await showModalBottomSheet( + context: ctx, + backgroundColor: MobileColors.surfaceAlt, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + 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), + ), + ), + 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); + sheetCtx.read().connectAny(host); + }, + ); + }, + ), + const SizedBox(height: MobileTokens.space2), + ], + ), + ); + }, + ); + } + + @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: () => _showHostPicker(context), + ), + 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: BoxDecoration( + color: session.status == SessionStatus.connected + ? MobileColors.green + : session.status == SessionStatus.connecting + ? MobileColors.accent + : MobileColors.red, + 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.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/lib/mobile/security/app_lock_gate.dart b/app/lib/mobile/security/app_lock_gate.dart new file mode 100644 index 00000000..c312186d --- /dev/null +++ b/app/lib/mobile/security/app_lock_gate.dart @@ -0,0 +1,136 @@ +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 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; + _locked = enabled; + }); + 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; + // 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; + } + } + + 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)(); + 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 + 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/lib/mobile/security/tofu_watcher.dart b/app/lib/mobile/security/tofu_watcher.dart new file mode 100644 index 00000000..79b85022 --- /dev/null +++ b/app/lib/mobile/security/tofu_watcher.dart @@ -0,0 +1,79 @@ +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((_) { + if (mounted) { + _show(challenge); + } else { + challenge.reject(); // never leave the connect future hanging + } + }); + } + 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/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/lib/mobile/sync/transfer_code.dart b/app/lib/mobile/sync/transfer_code.dart new file mode 100644 index 00000000..fea25628 --- /dev/null +++ b/app/lib/mobile/sync/transfer_code.dart @@ -0,0 +1,31 @@ +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'); + } + // 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_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/lib/mobile/terminal/accessory_key_bar.dart b/app/lib/mobile/terminal/accessory_key_bar.dart new file mode 100644 index 00000000..10df57cc --- /dev/null +++ b/app/lib/mobile/terminal/accessory_key_bar.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:xterm/xterm.dart'; + +import '../theme/mobile_theme.dart'; +import '../theme/mobile_tokens.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; + + /// 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. + 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); + } + + /// 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( + animation: controller, + builder: (context, _) { + return Container( + height: MobileTokens.accessoryBarHeight, + color: MobileColors.surfaceAlt, + 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)), + ], + ), + ); + }, + ); + } + + 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 ? MobileColors.accent : MobileColors.bg, + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, + style: TextStyle( + color: armed ? Colors.black : MobileColors.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: MobileColors.bg, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: MobileColors.textPrimary, size: 20), + ), + ), + ), + ); + } +} 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/lib/mobile/terminal/terminal_side_panel.dart b/app/lib/mobile/terminal/terminal_side_panel.dart new file mode 100644 index 00000000..77991544 --- /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[i]; // entries is already newest-first (addFirst) + 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/lib/mobile/theme/mobile_theme.dart b/app/lib/mobile/theme/mobile_theme.dart new file mode 100644 index 00000000..62878434 --- /dev/null +++ b/app/lib/mobile/theme/mobile_theme.dart @@ -0,0 +1,78 @@ +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); + /// 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. +/// 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 new file mode 100644 index 00000000..59ff8d22 --- /dev/null +++ b/app/lib/mobile/theme/mobile_tokens.dart @@ -0,0 +1,41 @@ +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; + + // ── Radii ───────────────────────────────────────────────────────────────── + static const double radiusCard = 14; + static const double radiusPill = 22; + static const double radiusAvatar = 12; + static const double radiusField = 12; + + // ── Sizes ───────────────────────────────────────────────────────────────── + static const double avatar = 44; + static const double statusDot = 8; + static const double accessoryBarHeight = 48; + 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/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/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/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/lib/mobile/widgets/host_avatar.dart b/app/lib/mobile/widgets/host_avatar.dart new file mode 100644 index 00000000..3f703720 --- /dev/null +++ b/app/lib/mobile/widgets/host_avatar.dart @@ -0,0 +1,86 @@ +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 +/// 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 (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); + + Widget tile = Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(MobileTokens.radiusAvatar), + ), + 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 new file mode 100644 index 00000000..9b30b700 --- /dev/null +++ b/app/lib/mobile/widgets/host_card.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +import '../../models/host.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 (with status dot overlay) + label +/// + user@ip (mono, faint) + latency badge + chevron. +class HostCard extends StatelessWidget { + final Host host; + final bool online; + final bool connecting; + final int? latencyMs; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const HostCard({ + super.key, + required this.host, + 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( + 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, + statusState: _connState, + ), + const SizedBox(width: MobileTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + host.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: mobileMono( + size: 12.5, + color: MobileColors.textFaint, + ), + ), + 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 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/lib/mobile/widgets/key_card.dart b/app/lib/mobile/widgets/key_card.dart new file mode 100644 index 00000000..ca191164 --- /dev/null +++ b/app/lib/mobile/widgets/key_card.dart @@ -0,0 +1,122 @@ +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: +/// 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 ''; + return sha256Fingerprint(pk) ?? 'SHA256:(pending)'; + } + + @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/lib/mobile/widgets/latency_badge.dart b/app/lib/mobile/widgets/latency_badge.dart new file mode 100644 index 00000000..7f41fb80 --- /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 != null ? '${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 new file mode 100644 index 00000000..5fa865a0 --- /dev/null +++ b/app/lib/mobile/widgets/mobile_card.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../theme/mobile_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, + }); + + // Card radius per spec: 15 + static const double _radius = 15; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: MobileColors.surface, + borderRadius: BorderRadius.circular(_radius), + border: Border.all(color: MobileColors.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/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..be7ced9a --- /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 = MobileColors.tabInactive; + + @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/lib/mobile/widgets/section_header.dart b/app/lib/mobile/widgets/section_header.dart new file mode 100644 index 00000000..4e8b9c93 --- /dev/null +++ b/app/lib/mobile/widgets/section_header.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.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: 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..8169bd95 --- /dev/null +++ b/app/lib/mobile/widgets/settings_row.dart @@ -0,0 +1,94 @@ +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), + ), + ], + if (trailing != null || toggle != null || onTap != null) ...[ + const SizedBox(width: MobileTokens.space2), + _buildTrailing(), + ], + ], + ), + ); + + if (onTap != null) { + return InkWell(onTap: onTap, child: content); + } + return content; + } +} 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/lib/mobile/widgets/status_dot.dart b/app/lib/mobile/widgets/status_dot.dart new file mode 100644 index 00000000..9ac7de3a --- /dev/null +++ b/app/lib/mobile/widgets/status_dot.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '../theme/mobile_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 => MobileColors.green, + HostConnState.connecting => MobileColors.accent, + HostConnState.offline => MobileColors.textFaint, + }; + +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/lib/mobile/widgets/tag_chip.dart b/app/lib/mobile/widgets/tag_chip.dart new file mode 100644 index 00000000..0fb2222a --- /dev/null +++ b/app/lib/mobile/widgets/tag_chip.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../theme/mobile_theme.dart'; +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.large = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final fg = selected ? Colors.black : MobileColors.textMuted; + final bg = selected + ? 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: 14, vertical: 8) + : const EdgeInsets.symmetric( + horizontal: MobileTokens.space2, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + border: large ? null : Border.all(color: MobileColors.border), + ), + 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( + borderRadius: BorderRadius.circular(MobileTokens.radiusPill), + onTap: onTap, + child: chip, + ); + } +} 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/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/pubspec.lock b/app/pubspec.lock index 5d971df6..3ff62d06 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: @@ -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: @@ -93,10 +117,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 +180,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: "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: "0.7.12" + version: "7.0.3" equatable: dependency: transitive description: @@ -282,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: @@ -302,10 +350,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 +452,26 @@ packages: dependency: transitive description: name: functions_client - sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + sha256: f3304ca860169f2e8acc5eceb0cd5d788cdac9551497b8326313a8130c14f24e + 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: "2.5.0" + version: "6.3.3" 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 +484,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: @@ -488,11 +544,27 @@ 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 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: @@ -565,6 +637,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: @@ -621,14 +733,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" multicast_dns: 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 +813,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 +889,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 +921,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: @@ -797,14 +973,22 @@ 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: 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 +1041,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 +1089,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 +1182,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 +1206,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 +1250,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 +1286,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 +1366,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 +1430,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 +1454,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 +1466,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 +1517,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: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5748b3eb..0bbd53be 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -39,6 +39,8 @@ 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 @@ -118,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: @@ -141,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 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); + }); +} 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..72ae97b2 --- /dev/null +++ b/app/test/mobile/accessory_key_bar_test.dart @@ -0,0 +1,97 @@ +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); + }); + + 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); + }); +} 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); + }); +} diff --git a/app/test/mobile/home_shell_test.dart b/app/test/mobile/home_shell_test.dart new file mode 100644 index 00000000..f5da4fdd --- /dev/null +++ b/app/test/mobile/home_shell_test.dart @@ -0,0 +1,104 @@ +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) { + 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({})); + + 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/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'); + }); + }); +} diff --git a/app/test/mobile/host_card_test.dart b/app/test/mobile/host_card_test.dart new file mode 100644 index 00000000..eb0be782 --- /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'; + +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, + online: true, + onTap: () => tapped = true, + ), + ), + )); + expect(find.text('Production Web'), 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')); + expect(tapped, isTrue); + }); +} 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..fb9eff92 --- /dev/null +++ b/app/test/mobile/mobile_add_host_screen_test.dart @@ -0,0 +1,107 @@ +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'; +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(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(); + + expect( + hosts.allHosts + .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.pump(); + 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']); + }); + + 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.pump(); + 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'); + }); +} diff --git a/app/test/mobile/mobile_bootstrap_test.dart b/app/test/mobile/mobile_bootstrap_test.dart new file mode 100644 index 00000000..9df49b6b --- /dev/null +++ b/app/test/mobile/mobile_bootstrap_test.dart @@ -0,0 +1,71 @@ +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/services/key_gen_service.dart'; +import 'package:yourssh_snippets/yourssh_snippets.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); + }); + + test('exposes sync provider + service', () { + final b = MobileBootstrap(); + expect(b.sync, isNotNull); + expect(b.syncService, isNotNull); + }); + + test('exposes snippets + transfer service', () { + final b = MobileBootstrap(); + 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(); + expect(b.providers.length, greaterThanOrEqualTo(14)); + }); + + 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); + // 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/mobile_home_shell_test.dart b/app/test/mobile/mobile_home_shell_test.dart new file mode 100644 index 00000000..d27e9b7d --- /dev/null +++ b/app/test/mobile/mobile_home_shell_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_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) { + 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({})); + + testWidgets('shows MobileTabBar with four destinations', (tester) async { + await tester.pumpWidget(_wrap(const MobileHomeShell())); + await tester.pumpAndSettle(); + + 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('Settings tab is reachable', (tester) async { + await tester.pumpWidget(_wrap(const MobileHomeShell())); + await tester.pumpAndSettle(); + + final bar = find.byType(MobileTabBar); + await tester.tap( + find.descendant(of: bar, matching: find.text('Settings')), + ); + await tester.pumpAndSettle(); + + // 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)); + }); +} 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..c3bffa38 --- /dev/null +++ b/app/test/mobile/mobile_hosts_screen_test.dart @@ -0,0 +1,61 @@ +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/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'; +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: (h, p, to) async {}); + await tester.pumpWidget(MaterialApp( + 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('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')); + 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); + }); +} 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..2f7cf791 --- /dev/null +++ b/app/test/mobile/mobile_sessions_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_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'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('empty state with no sessions', (tester) async { + final sp = + SessionProvider(SshService(StorageService()), TabMetadataService()); + await tester.pumpWidget(MaterialApp( + 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/mobile_settings_screen_test.dart b/app/test/mobile/mobile_settings_screen_test.dart new file mode 100644 index 00000000..020969a6 --- /dev/null +++ b/app/test/mobile/mobile_settings_screen_test.dart @@ -0,0 +1,72 @@ +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/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'; +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 sync = SyncProvider(); + final storage = StorageService(); + + // Providers wrap MaterialApp so pushed routes (MobileSyncScreen) also find + // them in the tree. + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider.value( + value: HostProvider(storage), + ), + ChangeNotifierProvider( + create: (_) => SettingsProvider(), + ), + Provider(create: (_) => SyncService(sync)), + ], + 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({}); + PackageInfo.setMockInitialValues( + appName: 'YourSSH', + packageName: 'com.yourssh', + version: '1.2.3', + buildNumber: '42', + buildSignature: '', + installerStore: null, + ); + }); + + testWidgets('"Supabase sync" row navigates to MobileSyncScreen', (tester) async { + await _pump(tester); + + // Tap the row — now pushes MobileSyncScreen instead of opening a sheet. + await tester.tap(find.text('Supabase sync')); + // Use pump(Duration) to avoid pumpAndSettle timing out on the P2P Timer. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + // MobileSyncScreen heading must be visible. + expect(find.text('Sync with Supabase'), findsOneWidget); + expect(find.byType(MobileSyncScreen), 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..4a0abe27 --- /dev/null +++ b/app/test/mobile/mobile_sftp_screen_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/mobile/screens/mobile_sftp_screen.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'; + +// 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 empty folder message with empty listing', (tester) async { + final storage = StorageService(); + final ssh = SshService(storage); + 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: [ + Provider.value(value: ssh), + Provider.value(value: transfer), + ], + child: MobileSftpScreen( + host: host, + lister: (_) async => [], + ), + ), + )); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.textContaining('Empty'), findsOneWidget); + }); +} 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); + }); +} 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..42ec89a4 --- /dev/null +++ b/app/test/mobile/screens/add_host_screen_test.dart @@ -0,0 +1,371 @@ +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/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'; +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, + ), + ); + +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'); + }); + + // ── 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'); + + // 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'); + }); +} + +// --------------------------------------------------------------------------- +// Fake providers +// --------------------------------------------------------------------------- + +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 = []; + + /// 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; + + @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); + lastAdded = 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(); + } +} + +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; + } +} 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..c8928987 --- /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: (h, p, to) 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); + }); +} 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..7dcdadca --- /dev/null +++ b/app/test/mobile/screens/keys_screen_test.dart @@ -0,0 +1,261 @@ +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'; + +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; +} + +/// 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 { + @override + 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( + WidgetTester tester, { + List? keys, + List? hosts, + KeyGenService? keyGen, +}) 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: + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPM4vCvRPxj8W9BoYRLsNJNMW5JCZw0DhS4OxGmwsUAZ user@host', + privateKeyPath: '/home/user/.ssh/id_ed25519', + ), + SshKeyEntry( + label: 'id_rsa', + algorithm: KeyAlgorithm.rsa, + publicKey: + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2 user@host', + 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() { + 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 { + 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: + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPM4vCvRPxj8W9BoYRLsNJNMW5JCZw0DhS4OxGmwsUAZ prod@host', + 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 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)); + }); +} 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..b4c873ba --- /dev/null +++ b/app/test/mobile/screens/port_forward_screen_test.dart @@ -0,0 +1,295 @@ +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); + + final List added = []; + + @override + Future add(PortForward fwd) async => added.add(fwd); +} + +class _FakePortForwardService extends PortForwardService { + _FakePortForwardService() + : super( + acquireTransport: (id) async => throw UnimplementedError(), + resolveHost: (id) => null, + onStatus: (fwdId, status, {error}) {}, + onConnections: (fwdId, count) {}, + ); + + 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; +} + +/// 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 +// --------------------------------------------------------------------------- + +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)); + }); + + // ------------------------------------------------------------------------- + // 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); + }); +} 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..fbb18f54 --- /dev/null +++ b/app/test/mobile/screens/settings_screen_test.dart @@ -0,0 +1,122 @@ +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/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 storage = StorageService(); + final hosts = HostProvider(storage); + final syncSvc = SyncService(sync); + + await tester.pumpWidget( + MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: settings), + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider.value(value: hosts), + Provider.value(value: syncSvc), + ], + 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); + }); + + 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); + }); +} 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..3271a1d5 --- /dev/null +++ b/app/test/mobile/screens/sftp_screen_test.dart @@ -0,0 +1,129 @@ +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)); + }); + + 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 new file mode 100644 index 00000000..7fb720dc --- /dev/null +++ b/app/test/mobile/screens/snippets_screen_test.dart @@ -0,0 +1,229 @@ +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 = _NullSessionProvider(); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: fakeSnippets), + ChangeNotifierProvider.value( + value: noSession, + ), + 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; +} 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..a46d6576 --- /dev/null +++ b/app/test/mobile/screens/sync_screen_test.dart @@ -0,0 +1,255 @@ +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'; +import 'package:yourssh/services/sync_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()); + final syncSvc = SyncService(sync); + + await tester.pumpWidget( + MaterialApp( + theme: buildMobileTheme(), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider.value(value: hosts), + Provider.value(value: syncSvc), + ], + 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)); + + return sync; +} + +// ── 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); + }); + + // ── 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); + }); + + // ── 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); + }); +} 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); + }); +} 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..a3dfd40f --- /dev/null +++ b/app/test/mobile/services/host_reachability_probe_test.dart @@ -0,0 +1,53 @@ +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); + DateTime 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); + }); + + 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); + }); +} 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); + }); +} diff --git a/app/test/mobile/status_dot_test.dart b/app/test/mobile/status_dot_test.dart new file mode 100644 index 00000000..74a9f9b8 --- /dev/null +++ b/app/test/mobile/status_dot_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.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), MobileColors.green); + expect(statusColor(HostConnState.connecting), MobileColors.accent); + expect(statusColor(HostConnState.offline), MobileColors.textFaint); + }); +} 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); + }); +} diff --git a/app/test/mobile/terminal_nav_test.dart b/app/test/mobile/terminal_nav_test.dart new file mode 100644 index 00000000..9f99f4f8 --- /dev/null +++ b/app/test/mobile/terminal_nav_test.dart @@ -0,0 +1,243 @@ +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 _sessions; + int connectAnyCalls = 0; + + _FakeSessionProvider(this._sessions) + : super(SshService(StorageService()), TabMetadataService()); + + @override + 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 + 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); + }); + + // --------------------------------------------------------------------------- + // 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); + }); +} 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); + }); +} 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); + }); +} diff --git a/app/test/mobile/transfer_code_test.dart b/app/test/mobile/transfer_code_test.dart new file mode 100644 index 00000000..bc4e41e5 --- /dev/null +++ b/app/test/mobile/transfer_code_test.dart @@ -0,0 +1,26 @@ +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); + }); + + 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); + }); +} 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)); + }); +} 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); + }); +} diff --git a/app/test/mobile/widgets/primitives_test.dart b/app/test/mobile/widgets/primitives_test.dart new file mode 100644 index 00000000..18baebe3 --- /dev/null +++ b/app/test/mobile/widgets/primitives_test.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/mobile/theme/mobile_theme.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); + }); + + 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 ─────────────────────────────────────────────────────────────── + 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); + }); + + 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); + 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: 14, vertical: 8)); + expect(container.constraints, const BoxConstraints(minWidth: 64)); + }); + }); + + // ── 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); + }); + }); +} 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); + }); +} 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 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. --- 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. 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. 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. 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. 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. 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. 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). 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. 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. 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). 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/