From 5593f337b66e53b47c1d3d62f3cae83c8253efce Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sun, 2 Aug 2026 18:23:53 +0200 Subject: [PATCH] Give the phone the second design, and both heads the palette it arrives with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts, Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the last. The first design's four had nothing behind them, which is what made a hub worth building. The palette moved from green-black to blue-black, and it moved in the shared project because that is where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which finally separates them: Live is that green and nothing merely interactive may use it. The accent is also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is a row of things that all look like the primary action. A palette is not one file, which is the part worth knowing before the next one. Nine hex literals lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state, two scrims, the window background Android paints before Avalonia has a frame, and the launcher vector. The two C# sites now resolve from the dictionary by name rather than restating it. The renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and terminal.js keep hand-copied values and say so at both sites. ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over one TransfersViewModel differing only in which picker they offer, and the kind is set by the button that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket already open. It refuses to change kind while a session is live, because there is one session behind both destinations and switching under it would title a screen S3 while it listed an SFTP host. What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd 5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are written once at close and read when the screen opens, and the severity filter, which has nothing to count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and lifecycle. And the + on HOSTS, which would open a host editor this head has not got. SFTP is browse, open and delete. Both transfer commands work, and what they work against is 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 have reported success and left the file where the person who asked for it cannot open it, which is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is not drawn either, since nothing here can put anything in it. Both return with the document picker. The foreground service still counts zero transfers, and the reason moved rather than went away. Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox ItemTemplate swallows the pointer press before the list sees it, so the files listing selected nothing and every command reading the selection did nothing — the row is a Border now and the phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no handler for the system back gesture, so back left the application from a log screen. And the tab's close cross had shrunk to a 30x32 target flush against the select target, which is the one control here that ends a shell with no confirmation and no undo. Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which is still there. Only at launch: a lock the user asked for is not answered with an immediate request to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a prompt that came back after being dismissed would be a modal you cannot get out of to type into it. Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with Accent, which was blue against green and is now two steps of one blue; an executable is Live now. And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification time — blank now, for the reason a directory's size is blank. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on a device — nothing in this head ever has been; see docs/android-port.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 --- README.md | 19 +- docs/android-port.md | 33 +- docs/design-import-gaps.md | 35 +++ src/DodoSSH.Client.Android/App.axaml.cs | 9 +- .../drawable/ic_launcher_foreground.xml | 8 +- .../Resources/values/colors.xml | 2 +- .../Resources/values/styles.xml | 2 +- src/DodoSSH.Client.Android/Theme/Phone.axaml | 182 ++++++++++- .../Views/FilesScreen.axaml | 283 ++++++++++++++++++ .../Views/FilesScreen.axaml.cs | 43 +++ .../Views/HostKeySheet.axaml | 8 +- .../Views/HostsScreen.axaml | 96 +++--- .../Views/KeychainScreen.axaml | 81 +++-- .../Views/LockedScreen.axaml | 6 +- .../Views/LogsScreen.axaml | 173 +++++++++++ .../Views/LogsScreen.axaml.cs | 10 + .../Views/MoreScreen.axaml | 160 ++++++++++ .../Views/MoreScreen.axaml.cs | 10 + src/DodoSSH.Client.Android/Views/NavButton.cs | 92 +++++- .../Views/PhoneShell.axaml | 207 +++++++++---- .../Views/PhoneShell.axaml.cs | 169 ++++++++++- .../Views/SnippetsScreen.axaml | 167 +++++++++++ .../Views/SnippetsScreen.axaml.cs | 10 + .../Views/TerminalScreen.axaml | 80 +++-- .../Views/TerminalScreen.axaml.cs | 25 +- src/DodoSSH.Client.App/App.axaml | 8 +- .../Views/QuickConnect.axaml | 4 +- .../Views/TransfersScreen.axaml | 12 +- src/DodoSSH.Client.Shell/Theme/Palette.axaml | 150 +++++++--- .../ViewModels/MainWindowViewModel.cs | 98 ++++++ .../ViewModels/TransfersViewModel.cs | 10 +- .../WebAssets/terminal.css | 19 +- .../WebAssets/terminal.js | 5 +- 33 files changed, 1959 insertions(+), 257 deletions(-) create mode 100644 src/DodoSSH.Client.Android/Views/FilesScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/FilesScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/LogsScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/LogsScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/MoreScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/MoreScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml.cs 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/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 @@ --> - + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + 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 @@ + + + + + + + + + - - - - - - + + + + + @@ -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 - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +