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; /// /// One destination in the bottom bar. /// /// /// /// 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 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 /// 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 { public static readonly StyledProperty LabelProperty = AvaloniaProperty.Register(nameof(Label), string.Empty); /// 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)); /// 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() { 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 }, }; } /// 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, }; 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); } /// public string Glyph { get => GetValue(GlyphProperty); set => SetValue(GlyphProperty, value); } /// Whether this is the destination currently showing. public bool IsCurrent { get => GetValue(IsCurrentProperty); set => SetValue(IsCurrentProperty, value); } /// public int Count { get => GetValue(CountProperty); set => SetValue(CountProperty, value); } /// 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); } } /// 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; } }