Files
DodoSSH/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
T
jaap-jan 5b32c76c95
ci / build and test (push) Successful in 3m49s
ci / android head (push) Successful in 3m55s
ci / api image (push) Successful in 6s
ci / desktop nightly (push) Successful in 1m22s
Android: the redesign — a Vaults tab with hosts, keychain and snippets, card lists, a keychain that can add, and files that act on what is chosen
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.
2026-10-04 15:50:09 +02:00

174 lines
7.7 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 02 — the host list, and the two gestures that are the whole of this file.</summary>
/// <remarks>
/// <para>
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> The
/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
/// ask.
/// </para>
/// <para>
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
/// </para>
/// </remarks>
internal sealed partial class HostsScreen : UserControl
{
/// <summary>
/// Whether the press that is about to end was long enough to have meant something else.
/// </summary>
/// <remarks>
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
/// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
/// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
/// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
/// </remarks>
private bool held;
/// <remarks>
/// <para>
/// Both gestures are attached here rather than in the markup so that they sit beside the property that
/// makes one of them fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than
/// assumed: it is the whole of the long press, and a default that changed would take it away silently —
/// every tap would go on working and nothing would ever choose a host again.
/// </para>
/// <para>
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
/// which is the same rule <c>PhoneShell</c> and <c>TerminalScreen</c> already carry, and which this
/// file learned the hard way. That field is
/// assigned by the generated <c>InitializeComponent</c>, and no view on this head calls it — they load
/// their XAML directly, as above. So the field compiles, is null at run time, and the first thing
/// touching it throws. Here that was a <see cref="NullReferenceException"/> in this constructor, which
/// is a crash before the first frame: the phone shell builds this control on the way up, so the
/// application died on launch rather than on the hosts screen.
/// </para>
/// </remarks>
public HostsScreen()
{
AvaloniaXamlLoader.Load(this);
var rows = this.FindControl<ListBox>("Rows")!;
InputElement.SetIsHoldingEnabled(rows, true);
rows.Holding += OnRowHeld;
var sheet = this.FindControl<Panel>("PasswordSheet")!;
var box = this.FindControl<TextBox>("PasswordBox")!;
sheet.PropertyChanged += (_, e) =>
{
if (e.Property == IsVisibleProperty && sheet.IsVisible)
{
FocusThePasswordBox(box);
}
};
}
/// <summary>
/// Puts the focus, and so the keyboard, in the connect sheet's box as the sheet opens.
/// </summary>
/// <remarks>
/// The sheet exists for that one box, and it used to open with nothing focused — a second tap before
/// typing could start (audit M12). Posted at <c>Loaded</c> priority because the box is not yet laid out
/// on the turn the sheet's visibility changes, and focusing a control with no bounds raises no keyboard.
/// </remarks>
private static void FocusThePasswordBox(TextBox box) =>
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
/// <summary>
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The branch is the selection, not the row.</b> Once anything is chosen the screen is in selection
/// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
/// connecting. That is what every Android list does, and the alternative is worse than a rule to
/// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
/// selection somebody was halfway through building.
/// </para>
/// <para>
/// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
/// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
/// be an event handler returning a task nothing observes.
/// </para>
/// </remarks>
private void OnRowTapped(object? sender, TappedEventArgs e)
{
if (held)
{
held = false;
return;
}
if (DataContext is not VaultViewModel vault || RowUnder(e) is not { } row)
{
return;
}
if (vault.IsChoosingHosts)
{
vault.ToggleHostChoiceCommand.Execute(row);
return;
}
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
}
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
/// <remarks>
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
/// had been recognised, and the only feedback available on this list is the thing it does.
/// </remarks>
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
{
if (e.HoldingState != HoldingState.Started)
{
return;
}
held = true;
if (DataContext is VaultViewModel vault && RowUnder(e) is { } row)
{
vault.ChooseHostCommand.Execute(row);
}
}
/// <summary>
/// The host the gesture landed on, or null where it landed on something that is not one.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Read off the element under the finger rather than off the list's selection, which is what both
/// handlers used to do.</b> The selection was defensible while a tap only ever connected: a tap on a
/// group heading moves the selection and the view model bounces it straight back — see
/// <c>VaultViewModel.SelectedSidebarRow</c> — so reading it answered "a host, or nothing" for free.
/// It stops being defensible the moment a tap can tick one: a heading would then bounce the selection to
/// whichever host was last chosen and tick <em>that</em>, which is a machine the user was not pointing
/// at going into a set they are about to delete.
/// </para>
/// <para>
/// The ancestor rather than <c>e.Source</c> itself, because the source is whatever leaf the finger
/// landed on — a tag chip's <c>TextBlock</c> has a string for a data context, and the row's own
/// <c>Ellipse</c> has the row. The <see cref="ListBoxItem"/> is the one element in that chain whose data
/// context is always the list's item, whatever kind it is.
/// </para>
/// </remarks>
private static HostRowViewModel? RowUnder(RoutedEventArgs e) =>
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true)?.DataContext
as HostRowViewModel;
}