From 5b32c76c956b2388e8e34750f88bc80dc61b67bc Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sun, 4 Oct 2026 15:50:09 +0200 Subject: [PATCH] =?UTF-8?q?Android:=20the=20redesign=20=E2=80=94=20a=20Vau?= =?UTF-8?q?lts=20tab=20with=20hosts,=20keychain=20and=20snippets,=20card?= =?UTF-8?q?=20lists,=20a=20keychain=20that=20can=20add,=20and=20files=20th?= =?UTF-8?q?at=20act=20on=20what=20is=20chosen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom bar is Vaults · Connections · Settings, drawn as Material 3 pills with icons from the embedded Material font and a count of open sessions on Connections, which replaces the strip of shells above the bar. Vaults is the open vault: its header carries the name as a switch to the vaults screen, the sync light and a lock icon, and a Hosts · Keychain · Snippets segmented control sits under it. The keychain and snippets move there from Settings (IsVaultSurface lights the tab; IsMoreSurface no longer lists them), and back from either goes to the hosts. Settings becomes an account card and grouped sections: security, vaults and history, files, this app. Host rows are cards with a terminal tile and the live dot on its corner. The connect sheet names the host and its address, labels its box, focuses it as it opens, reveals the password on request, and puts CANCEL left and CONNECT right; every two-button row on the head now confirms on the right. While a hosts-screen sheet is up the header and bar are dimmed too and a tap on them lowers it, and the bar stands down while the software keyboard is up. The keychain gains a floating + and a sheet of the desktop's five adds (generate a key, paste a key, a password, a bucket, a tag) over the editors VaultViewModel already had, drawn as a full-screen page with labels above the boxes; a chosen item gets EDIT and COPY PUBLIC KEY beside MOVE and DELETE. Snippets get a floating + and cards that say RUNS or INSERTS. Files trade the seven-button block for a selection bar (save, and a ⋮ sheet with rename, move, permissions and a red delete), folder and file tiles, an upload button, and header actions for a terminal on the same host, refresh and close; a terminal's + sheet opens SFTP on that terminal's own host. The theme takes the audit's visual findings: Fluent's ClipToBounds is cleared on glowing buttons so the glow is no longer cut to a square (the desktop's Button.accent too), prose is Montserrat with mono kept for addresses and code, disabled secondary buttons are quieter than enabled ones, Fluent's dropdown, checkbox, switch, focus and selection brushes come from the palette, touch no longer leaves hover fills behind, and lists, nav entries and icon buttons carry names for TalkBack. Builds clean; checked on the emulator end to end: unlock, the three vault views, adding and deleting a password, connecting through the new sheet, files on the terminal's host with the selection bar and its sheet. App tests 727 of 733 pass with 5 skipped; the one failure, TheCountsAPhoneShows_ FollowEveryForwardsState, passes three runs of three alone and is untouched here. Layout tests pass. --- docs/android-port.md | 10 + docs/manual-checks.md | 29 +- src/DodoSSH.Client.Android/Theme/Phone.axaml | 302 +++++++++++- .../Views/FilesScreen.axaml | 278 ++++++++---- .../Views/FilesScreen.axaml.cs | 18 + .../Views/HostsScreen.axaml | 231 ++++++---- .../Views/HostsScreen.axaml.cs | 23 + .../Views/KeychainScreen.axaml | 428 +++++++++++++++--- .../Views/KeychainScreen.axaml.cs | 60 +++ .../Views/LogsScreen.axaml | 6 +- .../Views/MoreScreen.axaml | 414 ++++++++++------- src/DodoSSH.Client.Android/Views/NavButton.cs | 149 ++++-- .../Views/PhoneRail.axaml | 53 ++- .../Views/PhoneShell.axaml | 224 ++++----- .../Views/PhoneShell.axaml.cs | 151 +++++- .../Views/PreferencesScreen.axaml | 2 +- .../Views/QuickAccessKeyboard.axaml | 4 +- .../Views/SnippetsScreen.axaml | 99 ++-- .../Views/TerminalScreen.axaml | 55 ++- .../Views/VaultsScreen.axaml | 4 +- src/DodoSSH.Client.App/App.axaml | 10 + .../ViewModels/MainWindowViewModel.cs | 28 +- .../ViewModels/TransfersViewModel.cs | 55 +++ .../ViewModels/VaultViewModel.cs | 43 ++ 24 files changed, 2022 insertions(+), 654 deletions(-) diff --git a/docs/android-port.md b/docs/android-port.md index 8524354..362f19a 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -640,6 +640,16 @@ go at 360dp: the membership test, the tab that lights and the back gesture's first case all read that one property. The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange deliberately differently, after the hub itself. + + **And the 2026-10-04 redesign moved it back out.** The bar is VAULTS · CONNECTIONS · SETTINGS. The first + entry is the open vault, with a HOSTS · KEYCHAIN · SNIPPETS switch under the vault's header, so the + keychain and snippets sit beside the hosts they are used with rather than behind the hub (audit M8); + `IsVaultSurface` lights it, and `IsMoreSurface` lost `Keychain` and `Snippets`. SETTINGS keeps what is + about this phone and this account: an account card, security, vaults and the log, files, preferences. + The strip of open shells above the bar went too — Connections carries the count in a badge instead. The + keychain gained an add sheet and an editor page over the editors the shared view model already had, the + files screen moved its seven buttons into a selection bar and an actions sheet, and a terminal's `+` + sheet now opens SFTP on that terminal's own host. 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. diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 97cd152..d49fc09 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -2058,16 +2058,31 @@ not have. If the password came back, something is storing it — it never was. A at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through `AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press. -### 11.9 Settings holds the keychain, and back walks out of it +### 11.9 The keychain and snippets are the vault's, and back walks out of them -From HOSTS: Settings, then Keychain. +From HOSTS, tap KEYCHAIN in the switch under the vault's header, then SNIPPETS. -**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing. -The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back, -by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS. +**Pass:** the bottom bar reads Vaults · Connections · Settings, and Vaults stays lit on all three. The +vault's name, the sync light, the lock and the switch stay above each one; neither screen draws a header of +its own. Back from KEYCHAIN or SNIPPETS returns to HOSTS; a second back leaves the application. -**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first -case, and those two have to move together — the switch mirrors that property by construction. +**Failure means:** `IsVaultSurface` and the back gesture's first case have drifted apart — they list the same +three screens and have to move together. + +### 11.9b Every add is the floating button, and every confirm is on the right + +On HOSTS, KEYCHAIN and SNIPPETS, and on SFTP once connected, there is a round + (an upload arrow on SFTP) +at the bottom right, glowing without a square behind it. On the keychain it raises a sheet of five things +to add; choosing ADD A PASSWORD opens a full-screen page with labels above its boxes. Tap a host that asks +for a password. + +**Pass:** the password sheet opens with the keyboard already up, its box ringed in violet; CANCEL is on the +left and CONNECT on the right; the header and the bottom bar behind the sheet are dimmed, and tapping either +closes the sheet. On SFTP, tapping a file puts a bar across the top with its name, a save arrow and ⋮; the ⋮ +sheet has Rename, Move, Permissions and a red Delete. + +**Failure means:** a square behind a glowing button is `ClipToBounds` back on (`Phone.axaml`); a bright +header under a sheet is `PhoneShell.ShowsSheetScrim` missing that sheet. ### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard** diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml index fb3f0d7..c6b42b7 100644 --- a/src/DodoSSH.Client.Android/Theme/Phone.axaml +++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml @@ -36,6 +36,156 @@ this paragraph to learn it was deliberate. --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -468,9 +649,9 @@ - - - + + + + + + + + + + + + + + + + + - + - @@ -78,10 +81,59 @@ - + + + + + + + + + + + + + - @@ -241,7 +293,7 @@ @@ -265,7 +317,7 @@ answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as "this host has nothing pinned". --> - @@ -317,6 +369,8 @@ + + @@ -402,7 +415,7 @@ the filter, which is what a heading over a filtered list has to say. --> - + - - + - - + + + + + + + + + - + - @@ -464,10 +493,10 @@ - - - + + - + @@ -604,9 +632,9 @@ Text="A host inside this group uses these wherever it says nothing itself. They are read at connect time rather than copied in, so changing one here changes every host that never overrode it." /> - @@ -646,9 +677,10 @@ "Which kind of thing are you adding" has no wrong answer and no answer at all is one of them. And it lives here rather than in PhoneShell. HostKeySheet sits up there because the files screen raises - it too; nothing but this screen raises this. The cost is that the scrim covers the screen and not the - 64px nav bar underneath, so the four destinations stay lit and tappable — which for a menu is right: - navigating away is a perfectly good way to decide not to add anything. + it too; nothing but this screen raises this. The cost used to be that the scrim covered the screen and + not the header or the nav bar, which stayed lit and tappable round every sheet (audit M12). PhoneShell + now dims those two rows as well while any sheet here is up, and a tap on them lowers the sheet — see + PhoneShell.ShowsSheetScrim. --> @@ -670,7 +702,7 @@ @@ -782,7 +814,7 @@ + - - - - - + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + - + - - + + diff --git a/src/DodoSSH.Client.Android/Views/NavButton.cs b/src/DodoSSH.Client.Android/Views/NavButton.cs index 9dda918..85ca6c8 100644 --- a/src/DodoSSH.Client.Android/Views/NavButton.cs +++ b/src/DodoSSH.Client.Android/Views/NavButton.cs @@ -1,4 +1,7 @@ +using System.Globalization; + using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Layout; @@ -17,16 +20,31 @@ namespace DodoSSH.Client.Android.Views; /// /// /// -/// 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. +/// Written in C# rather than as a XAML control because it is a button with an icon, a label and a rule about +/// the indicator, and a templated control for that would be more machinery than the thing it draws. /// /// -/// 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. +/// ◆ The indicator is a pill behind the icon, where it was a bar along the top edge. The bar was put at +/// the top because the bottom of the screen is under the system's gesture bar, and an indicator drawn there is +/// one nobody sees; the pill answers the same worry from the other side, by sitting in the middle of the +/// control rather than at either edge. It is the shape Android's own navigation bar has drawn since Material 3 +/// and the one the 2026-10-04 redesign asks for. The rule the bar existed to keep is kept: the current entry is +/// still told apart three ways — the filled pill, the accent colour and the heavier label — because colour +/// alone at this size is the one signal a user with a colour deficiency would be left with. +/// +/// +/// ◆ An icon from the embedded Material font rather than a Unicode glyph. The glyphs (▤ ⌗ ⚙) came from +/// whichever symbol font the phone had, at whatever weight it drew them, and ⚙ meant both this tab and the +/// Preferences row under it (audit M7). The icon font is the one the desktop's rail uses. +/// +/// +/// A count, when there is one. Connections carries the number of open sessions in a green badge, which +/// was otherwise only said by the strip of shells above the bar. Zero draws nothing: a "0" is a number about +/// nothing, and the badge being absent already says it. +/// +/// +/// It has a name. Its content is a panel, and Avalonia's automation peer reported that panel's type name +/// to TalkBack — "Avalonia.Controls.Panel", three times across the bar (audit H4). The label is the name. /// /// /// Every colour here is resolved from Theme/Palette.axaml rather than written out. A nav bar that @@ -39,25 +57,28 @@ 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. - /// + /// The icon drawn above the label: one codepoint of the embedded Material Icons font. public static readonly StyledProperty GlyphProperty = AvaloniaProperty.Register(nameof(Glyph), string.Empty); public static readonly StyledProperty IsCurrentProperty = AvaloniaProperty.Register(nameof(IsCurrent)); - private readonly Border indicator; + /// A count drawn over the icon, or zero for none. + public static readonly StyledProperty CountProperty = + AvaloniaProperty.Register(nameof(Count)); + + private readonly Border pill; private readonly TextBlock glyph; private readonly TextBlock text; + private readonly Border badge; + private readonly TextBlock badgeText; // Resolved from the palette once the control is in the tree; see Repaint. private IBrush? currentBrush; + private IBrush? currentIconBrush; private IBrush? idleBrush; + private IBrush? pillBrush; public NavButton() { @@ -70,35 +91,71 @@ internal sealed class NavButton : Button HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; - indicator = new Border - { - Height = 2, - VerticalAlignment = VerticalAlignment.Top, - Background = Brushes.Transparent, - }; - glyph = new TextBlock { - FontSize = 15, + FontSize = 21, HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + AutomationProperties.SetAccessibilityView(glyph, AccessibilityView.Raw); + + (badge, badgeText) = MakeBadge(); + + pill = new Border + { + Width = 64, + Height = 32, + CornerRadius = new CornerRadius(16), + HorizontalAlignment = HorizontalAlignment.Center, + Background = Brushes.Transparent, + Child = glyph, }; text = new TextBlock { - FontSize = 9, + FontSize = 12, FontWeight = FontWeight.Medium, HorizontalAlignment = HorizontalAlignment.Center, }; - var stack = new StackPanel + Content = new StackPanel { - Spacing = 3, + Spacing = 4, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { new Panel { Children = { pill, badge } }, text }, + }; + } + + /// The count's badge, over the pill's top-right corner. + /// + /// Over the corner rather than beside the label, so it reads as belonging to the icon and costs the + /// label no width. + /// + private static (Border Badge, TextBlock Text) MakeBadge() + { + var count = new TextBlock + { + FontSize = 10.5, + FontWeight = FontWeight.Bold, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, - Children = { glyph, text }, }; - Content = new Panel { Children = { indicator, stack } }; + var holder = new Border + { + MinWidth = 18, + Height = 18, + CornerRadius = new CornerRadius(9), + Padding = new Thickness(5, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, -4, 8, 0), + IsVisible = false, + Child = count, + }; + + return (holder, count); } public string Label @@ -121,6 +178,13 @@ internal sealed class NavButton : Button set => SetValue(IsCurrentProperty, value); } + /// + public int Count + { + get => GetValue(CountProperty); + set => SetValue(CountProperty, value); + } + /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { @@ -129,6 +193,7 @@ internal sealed class NavButton : Button if (change.Property == LabelProperty) { text.Text = Label; + AutomationProperties.SetName(this, Label); } else if (change.Property == GlyphProperty) { @@ -138,9 +203,10 @@ internal sealed class NavButton : Button { Repaint(); } - else if (change.Property == FontFamilyProperty) + else if (change.Property == CountProperty) { - text.FontFamily = FontFamily; + badge.IsVisible = Count > 0; + badgeText.Text = Count.ToString(CultureInfo.InvariantCulture); } } @@ -151,13 +217,17 @@ internal sealed class NavButton : Button // Resolved here rather than in the constructor: application resources are not reachable while the // control is still being constructed, and a hard-coded stack would drift from Palette.axaml. - if (Application.Current?.FindResource("MonoFont") is FontFamily mono) + if (Application.Current?.FindResource("IconFont") is FontFamily icons) { - text.FontFamily = mono; + glyph.FontFamily = icons; } - currentBrush = Application.Current?.FindResource("AccentText") as IBrush; + currentBrush = Application.Current?.FindResource("Text") as IBrush; + currentIconBrush = Application.Current?.FindResource("AccentText") as IBrush; idleBrush = Application.Current?.FindResource("TextFaint") as IBrush; + pillBrush = Application.Current?.FindResource("AccentSoft") as IBrush; + badge.Background = Application.Current?.FindResource("Live") as IBrush; + badgeText.Foreground = Application.Current?.FindResource("Canvas") 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. @@ -166,16 +236,21 @@ internal sealed class NavButton : Button private void Repaint() { - // Both the label and the bar, not just the bar — and the weight as well. See the remark on the type. + // The pill, the colour and the weight together. See the remark on the type. var paint = IsCurrent ? currentBrush : idleBrush; + var iconPaint = IsCurrent ? currentIconBrush : 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; + if (iconPaint is not null) + { + glyph.Foreground = iconPaint; + } + + text.FontWeight = IsCurrent ? FontWeight.Bold : FontWeight.Medium; + pill.Background = IsCurrent && pillBrush is not null ? pillBrush : Brushes.Transparent; } } diff --git a/src/DodoSSH.Client.Android/Views/PhoneRail.axaml b/src/DodoSSH.Client.Android/Views/PhoneRail.axaml index fc1d06a..716fae9 100644 --- a/src/DodoSSH.Client.Android/Views/PhoneRail.axaml +++ b/src/DodoSSH.Client.Android/Views/PhoneRail.axaml @@ -73,17 +73,28 @@ + + @@ -120,11 +131,11 @@ - @@ -134,11 +145,11 @@ HorizontalScrollBarVisibility="Disabled"> - - - - - - - diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml index 76745cf..d9939d1 100644 --- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml +++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml @@ -24,10 +24,11 @@ are the pair a session moves between; on the desktop the terminal is not a rail entry at all. ── a terminal gets the screen ───────────────────────────────────────────────────────────────────────── - Three of the four rows below stand down while a shell is showing: the header, the shells strip and the - bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control - computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which - is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome. + The rows around the screen stand down while a shell is showing: the header and the bottom bar. Both go + through flags the control computes, and the bar's is the one that differs — it stays up on Connections + with nothing running, which is the terminal surface drawing a page rather than a shell. See + PhoneShell.RefreshChrome. (A third row, the strip of open shells, was removed by the 2026-10-04 redesign; + the count on Connections took its place — see the remark where it was.) The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at 360dp the shell was framed by about a third of the display, all of it about somewhere the user was not. @@ -78,7 +79,7 @@ - + + + BorderThickness="0,0,0,1" Padding="10,0,6,0" Height="60"> - - - + ◆ The vault's name is a button now, and it opens the vaults. - + The 2026-10-04 redesign draws the name with a chevron, as the switch it is on every phone app + that holds more than one of something. What it leads to is VAULTS, which is where the vaults + this phone shows are chosen — so the chevron is a promise the screen behind it keeps, rather + than a dropdown this head has not got. + + The mark stays filled, and takes the gradient Button.primary has: it is the one thing on this + header that is not a fact about the vault, and the launcher icon draws it the same way. + --> + - - + - + - + + + + + + + + + + + + @@ -277,67 +330,14 @@ - - - - - - - - - - - - - - - - - - + BorderThickness="0,1,0,0" Height="72"> - + @@ -386,9 +392,11 @@ shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the bar can now sit under and not point at is the entry looking broken instead. + + ◆ The count is the open tabs, and it is what the shells strip used to say. See the remark above. --> - - + + diff --git a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml index e4ae987..0f3758a 100644 --- a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml @@ -27,18 +27,14 @@ - - - + - @@ -301,9 +303,12 @@ - + + + @@ -787,7 +814,7 @@