Files
DodoSSH/src/DodoSSH.Client.Android/Views/NavButton.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

257 lines
9.6 KiB
C#

using System.Globalization;
using Avalonia;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
// implicit usings, so the bare name is ambiguous — the same collision the namespace itself causes
// for Android.App. See the note at the top of MainActivity.
using Application = Avalonia.Application;
using Button = Avalonia.Controls.Button;
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// One destination in the bottom bar.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// ◆ <b>The indicator is a pill behind the icon, where it was a bar along the top edge.</b> 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.
/// </para>
/// <para>
/// ◆ <b>An icon from the embedded Material font rather than a Unicode glyph.</b> 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.
/// </para>
/// <para>
/// <b>A count, when there is one.</b> 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.
/// </para>
/// <para>
/// <b>It has a name.</b> 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.
/// </para>
/// <para>
/// Every colour here is resolved from <c>Theme/Palette.axaml</c> 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 <c>Ellipse.dot</c> style makes for being a style rather than a converter.
/// </para>
/// </remarks>
internal sealed class NavButton : Button
{
public static readonly StyledProperty<string> LabelProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty);
/// <summary>The icon drawn above the label: one codepoint of the embedded Material Icons font.</summary>
public static readonly StyledProperty<string> GlyphProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Glyph), string.Empty);
public static readonly StyledProperty<bool> IsCurrentProperty =
AvaloniaProperty.Register<NavButton, bool>(nameof(IsCurrent));
/// <summary>A count drawn over the icon, or zero for none.</summary>
public static readonly StyledProperty<int> CountProperty =
AvaloniaProperty.Register<NavButton, int>(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()
{
Background = Brushes.Transparent;
BorderThickness = new Thickness(0);
CornerRadius = new CornerRadius(0);
Padding = new Thickness(0);
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
HorizontalContentAlignment = HorizontalAlignment.Stretch;
VerticalContentAlignment = VerticalAlignment.Stretch;
glyph = new TextBlock
{
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 = 12,
FontWeight = FontWeight.Medium,
HorizontalAlignment = HorizontalAlignment.Center,
};
Content = new StackPanel
{
Spacing = 4,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Children = { new Panel { Children = { pill, badge } }, text },
};
}
/// <summary>The count's badge, over the pill's top-right corner.</summary>
/// <remarks>
/// Over the corner rather than beside the label, so it reads as belonging to the icon and costs the
/// label no width.
/// </remarks>
private static (Border Badge, TextBlock Text) MakeBadge()
{
var count = new TextBlock
{
FontSize = 10.5,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
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
{
get => GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
/// <inheritdoc cref="GlyphProperty" />
public string Glyph
{
get => GetValue(GlyphProperty);
set => SetValue(GlyphProperty, value);
}
/// <summary>Whether this is the destination currently showing.</summary>
public bool IsCurrent
{
get => GetValue(IsCurrentProperty);
set => SetValue(IsCurrentProperty, value);
}
/// <inheritdoc cref="CountProperty" />
public int Count
{
get => GetValue(CountProperty);
set => SetValue(CountProperty, value);
}
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == LabelProperty)
{
text.Text = Label;
AutomationProperties.SetName(this, Label);
}
else if (change.Property == GlyphProperty)
{
glyph.Text = Glyph;
}
else if (change.Property == IsCurrentProperty)
{
Repaint();
}
else if (change.Property == CountProperty)
{
badge.IsVisible = Count > 0;
badgeText.Text = Count.ToString(CultureInfo.InvariantCulture);
}
}
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
// 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("IconFont") is FontFamily icons)
{
glyph.FontFamily = icons;
}
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.
Repaint();
}
private void Repaint()
{
// 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)
{
text.Foreground = paint;
}
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;
}
}