diff --git a/README.md b/README.md
index b33a7c2..e84efe9 100644
--- a/README.md
+++ b/README.md
@@ -268,10 +268,21 @@ the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read t
host-key decisions and the counted delete confirmations are there too, and none of them were softened to
fit 360dp.
-What it does **not** have is file transfer — deliberately, since scoped storage means there is no local
-pane to put beside the remote one — and the four list screens the desktop grew last (pins, snippets, logs,
-teams), whose view models are already shared and which are additive rather than structural. Importing an
-`~/.ssh/config` has no meaning on a phone at all.
+Its interface is the **v2 design**: four destinations in a bottom bar — Hosts, Terminal, Vault and More —
+with snippets, SFTP, S3 buckets, logs and preferences one tap deeper behind the last. That is also where
+the product's palette now comes from, and both heads share it, so the desktop is blue too.
+
+File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
+either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
+put in one — moving a file *in* from the phone needs the system document picker and is the next piece of
+work rather than a thing the screen pretends to do. What is still absent is a host editor and a keychain
+item editor, so hosts and keys are created on the desktop and sync down; pins, teams and import have no
+phone screen either. Importing an `~/.ssh/config` has no meaning on a phone at all.
+
+**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
+leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
+every control on that screen would have had no effect. See
+[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
diff --git a/docs/android-port.md b/docs/android-port.md
index 62bdfa3..f72624b 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -439,6 +439,15 @@ go at 360dp:
`setInvalidatedByBiometricEnrollment` is on, which is what stops somebody who can add a fingerprint to an
unlocked phone from inheriting the vault — the cost being that re-enrolment permanently destroys the key,
which the load path treats as ordinary rather than exceptional.
+
+ **The prompt is raised on arrival at the lock screen** rather than waiting for the button, which is still
+ there and still says what it does. Two rules shape it, both in `PhoneShell.TryOfferDeviceUnlock`. It
+ happens **only at launch**: a lock the user asked for is not answered with a request to unlock, because
+ that makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And it
+ happens **once**: a declined gesture leaves the passphrase box exactly where it was, which is the whole
+ fallback, and a prompt that came back after being dismissed would be a modal you cannot get out of to
+ type into it. It watches three properties rather than one because startup sets the state to `Locked`
+ before it has asked the keystore whether there is a key to offer, and does both inside the busy wrapper.
5. ~~**Android sign-in.**~~ **Done**, and the seam it needed turned out to be worth more than the
implementation. `IAuthorizationCallback` now sits between `OidcClient` and the loopback listener, so the
two heads differ in *where the response arrives* and in nothing else — PKCE, the state check, discovery,
@@ -477,6 +486,14 @@ go at 360dp:
The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here:
import has no meaning under scoped storage, and the other four are list screens whose view models already
exist — they are additive rather than structural.
+
+ **Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took
+ the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the
+ view models that were already shared, behind a MORE hub that holds the five destinations the bottom bar
+ has no room for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved
+ together, because it is shared. What v2 asked for and did not get is in
+ [`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
+ named as absent rather than drawn, because nothing in the SSH layer forwards anything.
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
reflows a terminal while output is arriving.
@@ -487,9 +504,19 @@ go at 360dp:
drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt
believing they typed an `l`.
-All eight are done for the decided scope. What is left is not on this list: the four destinations the
-phone does not draw, file transfer (deliberately deferred, and see the decision above for the shape it
-takes), the App Link upgrade, and — the one that is not optional — running any of it on a device.
+All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7.
+
+What is left, in the order it matters:
+
+- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
+ ever been launched on hardware or an emulator. Everything below is reasoning from the code.
+- **The document picker.** The files screen browses a remote, downloads from it and runs the queue; putting
+ a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped
+ source, which is the decision recorded above and the obvious next piece of work.
+- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
+ desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
+- **Pins, teams and import**, which v2 does not draw either.
+- **The App Link upgrade**, unchanged from step 5.
---
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index 18739dd..20c9161 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -3,6 +3,41 @@
The Avalonia client's interface was rebuilt from a Claude Design project — a five-screen spec covering
the chrome, hosts and terminals, file transfer, the vault, teams, and preferences.
+> ## The second import — v2, phone
+>
+> A later design in the same project, **DodoSSH Android v2**, is what the phone head now draws: eight
+> screens, a bottom bar of four with the rest behind MORE, and a new palette. It has been imported, and
+> this section is the same accounting for it. The desktop's own v2 has **not** been imported — only its
+> palette, because the palette is shared and the two heads may not disagree about a colour.
+>
+> **The palette went from green to blue, and one colour changed meaning rather than value.** Green used to
+> be the accent, so `Ellipse.dot.live` filled with `Accent` and "the thing to press" and "a shell is open
+> here" were the same colour by construction. v2 makes the accent `#5B8CFF` and keeps a green, `#3DDC97`,
+> for status alone. That is now `Live`, and nothing merely interactive may use it. The accent is also two
+> colours — `Accent` fills, `AccentText` writes — which is what stops a row of chips reading as a row of
+> primary actions.
+>
+> **Five destinations moved under MORE and four of them were built**: snippets, SFTP, S3 and logs, each
+> over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`;
+> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
+>
+> | v2 element | What ships instead |
+> | --- | --- |
+> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
+> | `23 ms · fwd 5432` on the terminal's connection line | The account and endpoint actually dialled. There is no RTT measurement in SSH.NET and nothing forwards anything. |
+> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
+> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
+> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
+> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. |
+> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. |
+> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
+> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`, and `LocalPath` starts at `SpecialFolder.UserProfile`, which on Android is the application's own private directory: a download would report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. |
+> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
+> | A `+` floating button on **HOSTS** | Omitted. It would open a host editor, and this head has none — hosts are created on the desktop and sync down. |
+> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
+> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. |
+> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. |
+
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
project each piece would have to land in, and **what the shipped interface does instead**. That last
column is the important one. This codebase states its limits rather than implying them — the README does
diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs
index 6f5bc1a..f0f54fa 100644
--- a/src/DodoSSH.Client.Android/App.axaml.cs
+++ b/src/DodoSSH.Client.Android/App.axaml.cs
@@ -76,8 +76,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
- // Zero transfers for now: file transfer is out of this head's first scope by decision, and this is
- // the seam it arrives through rather than a dependency taken before there is anything behind it.
+ //
+ // Still zero transfers, and the reason moved rather than went away. v2 built the files screen, so
+ // this head can now browse a remote — but it cannot start a transfer, because both directions need
+ // the system document picker that scoped storage forces and that is not built (see FilesScreen).
+ // So the count is zero because the queue provably cannot have anything in it, not because nothing
+ // was wired. This is still the seam it arrives through: when the picker lands, this reads the
+ // queue and Refresh() gets called as transfers start and finish.
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
diff --git a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml
index b69c3f7..3978443 100644
--- a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml
+++ b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml
@@ -5,7 +5,7 @@
bitmap so there is one geometry to change and no set of five PNG densities to forget one
of.
- #3CE88F is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out
+ #5B8CFF is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out
because an Android resource cannot reference a XAML dictionary. The same duplication
colors.xml already carries for the window background, and the same rule applies: if the
palette moves, this moves with it.
@@ -31,14 +31,14 @@
@@ -47,7 +47,7 @@
diff --git a/src/DodoSSH.Client.Android/Resources/values/colors.xml b/src/DodoSSH.Client.Android/Resources/values/colors.xml
index 9c62f67..d6dc3c9 100644
--- a/src/DodoSSH.Client.Android/Resources/values/colors.xml
+++ b/src/DodoSSH.Client.Android/Resources/values/colors.xml
@@ -9,5 +9,5 @@
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
hidden.
-->
- #0A0C0B
+ #0E1220
diff --git a/src/DodoSSH.Client.Android/Resources/values/styles.xml b/src/DodoSSH.Client.Android/Resources/values/styles.xml
index f2b81b0..6832c2d 100644
--- a/src/DodoSSH.Client.Android/Resources/values/styles.xml
+++ b/src/DodoSSH.Client.Android/Resources/values/styles.xml
@@ -12,7 +12,7 @@
the sync dot, which a system action bar has nowhere to put.
windowLightStatusBar is false so the clock and the battery icon are drawn light. The design's status
- strip is #7E8A84 text on near-black, and leaving this at its default renders dark-on-dark — legible
+ strip is #8B93B0 text on near-black, and leaving this at its default renders dark-on-dark — legible
on the mock-up and invisible on a device.
-->
@@ -48,7 +54,7 @@
-
+
@@ -63,7 +69,7 @@
-
+
@@ -76,7 +82,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -177,11 +282,49 @@
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/FilesScreen.axaml b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml
new file mode 100644
index 0000000..210e87d
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/FilesScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml.cs
new file mode 100644
index 0000000..05963c5
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml.cs
@@ -0,0 +1,43 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+using DodoSSH.Client.Shell.ViewModels;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Designs 06 and 08 — one remote pane, over SFTP or over a bucket.
+internal sealed partial class FilesScreen : UserControl
+{
+ public FilesScreen() => AvaloniaXamlLoader.Load(this);
+
+ ///
+ /// Opens a directory on a single tap.
+ ///
+ ///
+ ///
+ /// The desktop pane opens on a double tap and on Enter. A phone has neither, so the gesture has to be
+ /// the one gesture there is — and that is why this is here rather than in the shared view model: making
+ /// selection navigate would give the desktop a single click that walks into a directory.
+ ///
+ ///
+ /// rather than a command on the row, because the row must stay a
+ /// plain : a button as the item template swallows the press before the list sees it
+ /// — it marks the pointer event handled, and the list's own handler is not registered for handled
+ /// events — leaving nothing selected and every command that reads the selection doing nothing. Tapped
+ /// fires after the list has moved its selection, which is what lets this read it.
+ ///
+ ///
+ /// A file is left selected rather than opened. There is nothing this head could do with it — see the
+ /// note about the document picker at the top of the screen — and the actions below act on the selection.
+ ///
+ ///
+ private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
+ {
+ if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
+ {
+ transfers.OpenRemoteCommand.Execute(null);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml b/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml
index 992a98e..f44dbf2 100644
--- a/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml
+++ b/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml
@@ -24,8 +24,12 @@
-
-
+
+
-
+
-
-
+
+ Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="12" />
-
+
@@ -46,11 +52,11 @@
banner the user has to dismiss rather than a screen they might never open.
-->
-
+ BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="12" Padding="13,11">
+
-
+
@@ -62,22 +68,33 @@
+ BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="12" Padding="13,11">
-
+
+
@@ -89,45 +106,52 @@
the filter, which is what a heading over a filtered list has to say.
-->
-
+
-
+
-
+
-
-
+
-
-
-
+
+
-
-
+
+
-
-
-
+
+
+
+
+
+
+
+ VerticalAlignment="Center" IsVisible="{Binding !IsConnected}" />
@@ -141,7 +165,7 @@
-->
+ BorderThickness="0,1,0,0" Padding="14,12">
@@ -153,7 +177,7 @@
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
-->
-
diff --git a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
index 96b29d8..280a3f8 100644
--- a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
@@ -14,9 +14,21 @@
And ◆ the delete confirmation replaces the row in place rather than stacking a dialogue over it — the
design is explicit about that, and on a phone it matters more than on the desktop: a modal over a list
hides which row it is talking about, which is exactly the information the confirmation exists to give.
+
+ ── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
+ Rows became cards with a glyph tile, which is the design's own shape. Three things drawn on those cards
+ are not here, and all three for the same reason: there is nothing behind them. An `ED25519` badge needs
+ an algorithm field, and `SshKeySecret` has none — the type stores the armour verbatim rather than
+ parsing it. A `SHA256:…` line needs a fingerprint, computed by the same parsing. And an `agent` chip
+ needs an agent, of which this application has none at all — the connect path decrypts the key and hands
+ the bytes to the SSH library. What the card carries instead is `Detail`, which is what is genuinely
+ known *about* an item and never the secret. See docs/design-import-gaps.md.
+
+ Mode=OneWay on the chips is load-bearing: a two-way selection binding moves the chip before the command
+ beside it has had the chance to refuse, which would leave the row lit for a section the list is not on.
-->
-
+
+
+
+
-
+ BorderThickness="1" CornerRadius="12" Padding="14,12">
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/LockedScreen.axaml b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml
index c52cacd..c4d6b5a 100644
--- a/src/DodoSSH.Client.Android/Views/LockedScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml
@@ -62,7 +62,7 @@
+
diff --git a/src/DodoSSH.Client.Android/Views/LogsScreen.axaml b/src/DodoSSH.Client.Android/Views/LogsScreen.axaml
new file mode 100644
index 0000000..1f6f807
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/LogsScreen.axaml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/LogsScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/LogsScreen.axaml.cs
new file mode 100644
index 0000000..8af6fa4
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/LogsScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Design 07 — the two logs: what has been connected to, and what has been changed.
+internal sealed partial class LogsScreen : UserControl
+{
+ public LogsScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/MoreScreen.axaml b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml
new file mode 100644
index 0000000..bc98b19
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/MoreScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml.cs
new file mode 100644
index 0000000..443b1fc
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Design v2 — MORE, the hub for the five destinations the bottom bar has no room for.
+internal sealed partial class MoreScreen : UserControl
+{
+ public MoreScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/NavButton.cs b/src/DodoSSH.Client.Android/Views/NavButton.cs
index d6a936d..9dda918 100644
--- a/src/DodoSSH.Client.Android/Views/NavButton.cs
+++ b/src/DodoSSH.Client.Android/Views/NavButton.cs
@@ -17,13 +17,21 @@ namespace DodoSSH.Client.Android.Views;
///
///
///
-/// Written in C# rather than as a XAML control because it is a button with a label and a rule about the
-/// accent bar, and a templated control for that would be more machinery than the thing it draws.
+/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about
+/// the accent bar, and a templated control for that would be more machinery than the thing it draws.
///
///
-/// The accent bar is on top, not underneath. The design puts it there in the nav rail and it is
-/// worth keeping on a phone for a reason the desktop does not have: the bottom of a phone screen is under
-/// the system's own gesture bar, and an indicator drawn there is one the user cannot see.
+/// The accent bar is on top, not underneath. The v2 design distinguishes the current destination with
+/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the
+/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar,
+/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is
+/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are
+/// three, which is the right number for the control that says where you are.
+///
+///
+/// Every colour here is resolved from Theme/Palette.axaml rather than written out. A nav bar that
+/// named its own blues is exactly how the fourth place a colour appears ends up one shade off — which is the
+/// argument the desktop head's Ellipse.dot style makes for being a style rather than a converter.
///
///
internal sealed class NavButton : Button
@@ -31,12 +39,26 @@ internal sealed class NavButton : Button
public static readonly StyledProperty LabelProperty =
AvaloniaProperty.Register(nameof(Label), string.Empty);
+ /// The glyph drawn above the label.
+ ///
+ /// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts
+ /// Android already has, so shipping four vectors to draw them would buy nothing but four more files to
+ /// keep in step with the palette.
+ ///
+ public static readonly StyledProperty GlyphProperty =
+ AvaloniaProperty.Register(nameof(Glyph), string.Empty);
+
public static readonly StyledProperty IsCurrentProperty =
AvaloniaProperty.Register(nameof(IsCurrent));
private readonly Border indicator;
+ private readonly TextBlock glyph;
private readonly TextBlock text;
+ // Resolved from the palette once the control is in the tree; see Repaint.
+ private IBrush? currentBrush;
+ private IBrush? idleBrush;
+
public NavButton()
{
Background = Brushes.Transparent;
@@ -55,16 +77,28 @@ internal sealed class NavButton : Button
Background = Brushes.Transparent,
};
+ glyph = new TextBlock
+ {
+ FontSize = 15,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
text = new TextBlock
{
FontSize = 9,
- FontWeight = FontWeight.SemiBold,
+ FontWeight = FontWeight.Medium,
HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- Foreground = new SolidColorBrush(Color.Parse("#566059")),
};
- Content = new Panel { Children = { indicator, text } };
+ var stack = new StackPanel
+ {
+ Spacing = 3,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children = { glyph, text },
+ };
+
+ Content = new Panel { Children = { indicator, stack } };
}
public string Label
@@ -73,6 +107,13 @@ internal sealed class NavButton : Button
set => SetValue(LabelProperty, value);
}
+ ///
+ public string Glyph
+ {
+ get => GetValue(GlyphProperty);
+ set => SetValue(GlyphProperty, value);
+ }
+
/// Whether this is the destination currently showing.
public bool IsCurrent
{
@@ -89,14 +130,13 @@ internal sealed class NavButton : Button
{
text.Text = Label;
}
+ else if (change.Property == GlyphProperty)
+ {
+ glyph.Text = Glyph;
+ }
else if (change.Property == IsCurrentProperty)
{
- // Both the label and the bar, not just the bar. Colour alone at 9pt on a dark background is a
- // weak signal, and it is the only one a user with a colour deficiency would have.
- text.Foreground = new SolidColorBrush(Color.Parse(IsCurrent ? "#3CE88F" : "#566059"));
- indicator.Background = IsCurrent
- ? new SolidColorBrush(Color.Parse("#3CE88F"))
- : Brushes.Transparent;
+ Repaint();
}
else if (change.Property == FontFamilyProperty)
{
@@ -115,5 +155,27 @@ internal sealed class NavButton : Button
{
text.FontFamily = mono;
}
+
+ currentBrush = Application.Current?.FindResource("AccentText") as IBrush;
+ idleBrush = Application.Current?.FindResource("TextFaint") as IBrush;
+
+ // IsCurrent is normally bound and so has usually already been set by the time the template applies,
+ // which is why this runs again here rather than only on the property change.
+ Repaint();
+ }
+
+ private void Repaint()
+ {
+ // Both the label and the bar, not just the bar — and the weight as well. See the remark on the type.
+ var paint = IsCurrent ? currentBrush : idleBrush;
+
+ if (paint is not null)
+ {
+ glyph.Foreground = paint;
+ text.Foreground = paint;
+ }
+
+ text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium;
+ indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent;
}
}
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
index 3e23d2e..87d09fd 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
@@ -13,10 +13,14 @@
It switches on ShellState, which is the same six states the desktop has because both heads share one
state machine. What differs is only what each one draws.
- The nav rail's eight destinations become four in a bottom bar. That is not a reflow: the plan says five
- characters was a desktop constraint and the phone can use words, and it also says a phone cannot carry
- eight. What is here is what the decided scope needs — the keychain and a terminal — and the destinations
- behind the other four are reachable from those two rather than being hidden.
+ ── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
+ The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper
+ behind MORE: snippets, SFTP, S3, logs and preferences. That is the v2 design's own arrangement, and it
+ replaces the first design's four, which had nothing behind them at all. Five characters was a desktop
+ constraint and the phone uses words.
+
+ The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
+ are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
-->
@@ -34,29 +38,47 @@
-
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
@@ -85,15 +107,60 @@
-->
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -104,64 +171,78 @@
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
host list, which is where you are when you open the next one.
+
+ v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
+ rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
+ saying what the row already looks like.
-->
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BorderThickness="0,1,0,0" Height="64">
-
-
-
-
-
+
+ CommandParameter="{x:Static vm:ShellScreen.Vault}" />
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
index 3c4a1b7..9eccfe7 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
@@ -1,6 +1,8 @@
using global::Android.Views;
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
@@ -13,6 +15,19 @@ internal sealed partial class PhoneShell : UserControl
{
private MainWindowViewModel? shell;
+ ///
+ /// Whether the lock screen currently showing is the one the application launched into.
+ ///
+ ///
+ /// Set once, when the shell arrives, because this control is built once for the process. Cleared the
+ /// moment the state leaves , and never set again — which is what stops a
+ /// deliberate lock from being answered with an immediate request to unlock. See
+ /// .
+ ///
+ private bool thisLockIsTheLaunch;
+
+ private bool offeredDeviceUnlock;
+
public PhoneShell()
{
AvaloniaXamlLoader.Load(this);
@@ -30,15 +45,167 @@ internal sealed partial class PhoneShell : UserControl
{
shell.PropertyChanged += OnShellChanged;
ApplyScreenshotPolicy(shell.State);
+
+ thisLockIsTheLaunch = true;
+ TryOfferDeviceUnlock();
}
};
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
- if (shell is not null && e.PropertyName is nameof(MainWindowViewModel.State))
+ if (shell is null)
+ {
+ return;
+ }
+
+ if (e.PropertyName is nameof(MainWindowViewModel.State))
{
ApplyScreenshotPolicy(shell.State);
+
+ if (shell.State is not ShellState.Locked)
+ {
+ thisLockIsTheLaunch = false;
+ offeredDeviceUnlock = false;
+ }
+ }
+
+ // Three properties rather than one, because the condition is assembled out of order. Startup sets
+ // State to Locked and only then awaits the keystore to decide CanUnlockWithDevice, and it does all
+ // of it inside the busy wrapper — which refuses a second command outright. So whichever of the
+ // three settles last is the one that has to ask again.
+ if (e.PropertyName is nameof(MainWindowViewModel.State)
+ or nameof(MainWindowViewModel.CanUnlockWithDevice)
+ or nameof(MainWindowViewModel.IsBusy))
+ {
+ TryOfferDeviceUnlock();
+ }
+ }
+
+ ///
+ /// Raises the fingerprint prompt on arriving at the lock screen, rather than waiting to be asked.
+ ///
+ ///
+ ///
+ /// The button is still there and still says what it does; this only spends the tap for you. On a phone
+ /// that is the difference between opening the application in one gesture and in two, and the second of
+ /// the two was a button whose entire content was "yes, do the thing you already know I want".
+ ///
+ ///
+ /// Only at launch. A lock the user asked for is not answered with a request to unlock — that
+ /// turns LOCK into a control that appears to do nothing, and worse, trains the reflex of authenticating
+ /// at a prompt that appeared without being asked for. So the offer belongs to the locked screen the
+ /// process started on and to no other.
+ ///
+ ///
+ /// Once. A declined gesture leaves the passphrase box exactly where it was, which is the whole
+ /// fallback — and a prompt that reappeared after being dismissed would be a modal the user cannot get
+ /// out of to type into it.
+ ///
+ ///
+ /// Nothing here needs a failure path. UnlockWithDeviceAsync turns every refusal into a status
+ /// line, and a phone with no enrolled fingerprint never gets here at all, because
+ /// CanUnlockWithDevice already asked the keystore.
+ ///
+ ///
+ private void TryOfferDeviceUnlock()
+ {
+ if (!thisLockIsTheLaunch || offeredDeviceUnlock)
+ {
+ return;
+ }
+
+ if (shell is not { State: ShellState.Locked, CanUnlockWithDevice: true, IsBusy: false } current)
+ {
+ return;
+ }
+
+ offeredDeviceUnlock = true;
+ current.UnlockWithDeviceCommand.Execute(null);
+ }
+
+ ///
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ if (TopLevel.GetTopLevel(this) is { } top)
+ {
+ top.BackRequested += OnBackRequested;
+ }
+ }
+
+ ///
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ if (TopLevel.GetTopLevel(this) is { } top)
+ {
+ top.BackRequested -= OnBackRequested;
+ }
+
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ ///
+ /// Takes the system back gesture up the hierarchy rather than out of the application.
+ ///
+ ///
+ ///
+ /// v2 is the first arrangement here with a second level: five destinations sit behind MORE, each with
+ /// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first,
+ /// and left unhandled it does not go up — it finishes the activity. Ending the application from a log
+ /// screen is not a plausible reading of "back".
+ ///
+ ///
+ /// Handled in the order the interface is stacked, not by screen alone: the terminal is a surface over a
+ /// page, so it is dismissed before the page under it is considered. Setting Handled is what stops
+ /// the event being re-dispatched to the activity's own default.
+ ///
+ ///
+ /// Two things it deliberately does not do. It does not answer a host-key prompt — those are
+ /// decisions with two named buttons, and a gesture that dismissed one would be the swipe-to-dismiss this
+ /// head refused when it made the changed-key refusal a full-screen panel rather than a sheet. And from
+ /// the host list it does nothing at all, so back still leaves the application from the screen the
+ /// application opens on, which is what every other Android app does.
+ ///
+ ///
+ private void OnBackRequested(object? sender, RoutedEventArgs e)
+ {
+ if (shell is not { State: ShellState.Unlocked } current)
+ {
+ return;
+ }
+
+ // A decision is on screen. Leave it alone — see the remark.
+ if (current.Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true }
+ || current.Transfers is { HasPendingHostKey: true } or { HasHostKeyMismatch: true })
+ {
+ return;
+ }
+
+ if (!current.IsShowingPages)
+ {
+ current.ShowScreenCommand.Execute(current.Screen);
+ e.Handled = true;
+ return;
+ }
+
+ switch (current.Screen)
+ {
+ case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
+ or ShellScreen.Buckets or ShellScreen.Preferences:
+ current.ShowScreenCommand.Execute(ShellScreen.More);
+ e.Handled = true;
+ break;
+
+ case ShellScreen.More or ShellScreen.Vault:
+ current.ShowScreenCommand.Execute(ShellScreen.Hosts);
+ e.Handled = true;
+ break;
+
+ default:
+ // Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
+ break;
}
}
diff --git a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
new file mode 100644
index 0000000..5f433fe
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml.cs
new file mode 100644
index 0000000..4d4f2a5
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Design 05 — the saved commands, and the two ways of sending one to a shell.
+internal sealed partial class SnippetsScreen : UserControl
+{
+ public SnippetsScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
index 5a6545e..dfe6f7e 100644
--- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
@@ -21,43 +21,61 @@
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
+
+
+
+
+
@@ -70,11 +88,16 @@
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head has
no keyboard to press it with, which is why the two heads differ here and nowhere else in this screen.
-->
-
+
-
@@ -149,11 +178,10 @@
then release, because holding a modifier while typing is not possible one-thumbed.
-->
-
+
-
+
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
index 40b562d..bbeeeec 100644
--- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
@@ -129,13 +129,13 @@ internal sealed partial class TerminalScreen : UserControl
// 44 wide as well as tall. The design draws them flexed across the width, which at 360dp
// with ten keys is 32 pixels each — under every thumb-target guideline there is.
MinWidth = 44,
- Height = 34,
+ Height = 38,
Padding = new Thickness(10, 0),
- CornerRadius = new CornerRadius(5),
- Background = new SolidColorBrush(Color.Parse("#161B19")),
- BorderBrush = new SolidColorBrush(Color.Parse("#232927")),
+ CornerRadius = new CornerRadius(9),
+ Background = Palette("Panel"),
+ BorderBrush = Palette("BorderMid"),
BorderThickness = new Thickness(1),
- Foreground = new SolidColorBrush(Color.Parse("#B7C0BB")),
+ Foreground = Palette("TextDim"),
HorizontalContentAlignment = HorizontalAlignment.Center,
};
@@ -164,13 +164,18 @@ internal sealed partial class TerminalScreen : UserControl
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
// ^L to a database prompt believing they typed an l.
- controlKey.Background = new SolidColorBrush(
- Color.Parse(controlLatched ? "#243A2F" : "#161B19"));
-
- controlKey.Foreground = new SolidColorBrush(
- Color.Parse(controlLatched ? "#3CE88F" : "#B7C0BB"));
+ controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
+ controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
}
+ /// One brush from Theme/Palette.axaml, by key.
+ ///
+ /// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
+ /// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
+ /// its own blues is how the palette ends up with a fifth surface nobody meant to add.
+ ///
+ private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
+
///
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml
index 985b678..fe071d5 100644
--- a/src/DodoSSH.Client.App/App.axaml
+++ b/src/DodoSSH.Client.App/App.axaml
@@ -428,8 +428,12 @@
decoration that reads as information.
A style rather than a value converter so that the two colours are the palette's, once. A converter
- would have had to name #3CE88F in C#, which is how a green ends up one shade off in the fourth place
+ would have had to name #3DDC97 in C#, which is how a green ends up one shade off in the fourth place
it appears.
+
+ Live rather than Accent, and that is a v2 change with meaning rather than a recolour. The accent is
+ blue now, and the green survives as the colour of a fact — see the remark on Live in Palette.axaml.
+ Filling this with Accent would say a connected host is a thing to press.
-->
-
+
+
diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
index 847ba66..47a068c 100644
--- a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
@@ -37,11 +37,21 @@
+
- #0A0C0B
+
+
+ #0E1220
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
- #3CE88F
+ #5B8CFF
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+