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