diff --git a/src/SubVora.Mobile/Converters/CategoryColorConverter.cs b/src/SubVora.Mobile/Converters/CategoryColorConverter.cs index 9e00bdf..0170daf 100644 --- a/src/SubVora.Mobile/Converters/CategoryColorConverter.cs +++ b/src/SubVora.Mobile/Converters/CategoryColorConverter.cs @@ -45,6 +45,13 @@ public class CategoryColorConverter : IValueConverter /// private const float SoftAlpha = 0.20f; + /// + /// The same wash needs more of the hue on a dark surface. At 0.20 over #1C1C21 the tinted tiles + /// came out muddy - navy for blue, brown for orange - because most of what showed through was + /// the near-black behind them rather than the colour. + /// + private const float SoftAlphaDark = 0.32f; + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { var isDark = Application.Current?.RequestedTheme == AppTheme.Dark; @@ -53,7 +60,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture ? Color.FromArgb(isDark ? pair.Dark : pair.Light) : Color.FromArgb(isDark ? NeutralDark : NeutralLight); - return parameter as string == "soft" ? color.WithAlpha(SoftAlpha) : color; + return parameter as string == "soft" ? color.WithAlpha(isDark ? SoftAlphaDark : SoftAlpha) : color; } public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => diff --git a/src/SubVora.Mobile/Converters/PaymentSourceIconConverter.cs b/src/SubVora.Mobile/Converters/PaymentSourceIconConverter.cs index 306cb9b..b29083c 100644 --- a/src/SubVora.Mobile/Converters/PaymentSourceIconConverter.cs +++ b/src/SubVora.Mobile/Converters/PaymentSourceIconConverter.cs @@ -51,6 +51,9 @@ public class PaymentSourceColorConverter : IValueConverter /// Matches CategoryColorConverter's soft wash, so tiles look the same weight across screens. private const float SoftAlpha = 0.20f; + /// Same reasoning as CategoryColorConverter: 0.20 over a near-black surface reads muddy. + private const float SoftAlphaDark = 0.32f; + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { var isDark = Application.Current?.RequestedTheme == AppTheme.Dark; @@ -61,7 +64,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture var color = Color.FromArgb(isDark ? pair.Dark : pair.Light); - return parameter as string == "soft" ? color.WithAlpha(SoftAlpha) : color; + return parameter as string == "soft" ? color.WithAlpha(isDark ? SoftAlphaDark : SoftAlpha) : color; } public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => diff --git a/src/SubVora.Mobile/MauiProgram.cs b/src/SubVora.Mobile/MauiProgram.cs index 5f0a604..99de784 100644 --- a/src/SubVora.Mobile/MauiProgram.cs +++ b/src/SubVora.Mobile/MauiProgram.cs @@ -28,6 +28,13 @@ public static MauiApp CreateMauiApp() fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); }); +#if ANDROID + // Android tints every tab icon from Shell's TabBar colours, which flattens the five + // coloured tab SVGs into one hue. See ColorfulTabsShellRenderer. + builder.ConfigureMauiHandlers(handlers => + handlers.AddHandler()); +#endif + #if DEBUG builder.Logging.AddDebug(); #endif diff --git a/src/SubVora.Mobile/Platforms/Android/ColorfulTabsShellRenderer.cs b/src/SubVora.Mobile/Platforms/Android/ColorfulTabsShellRenderer.cs new file mode 100644 index 0000000..68be1ab --- /dev/null +++ b/src/SubVora.Mobile/Platforms/Android/ColorfulTabsShellRenderer.cs @@ -0,0 +1,52 @@ +using Android.Content; +using Google.Android.Material.BottomNavigation; +using Microsoft.Maui.Controls.Handlers.Compatibility; +using Microsoft.Maui.Controls.Platform.Compatibility; + +namespace SubVora.Mobile.Platforms.Android; + +/// +/// Lets the tab icons keep their own colours. +/// +/// Android's BottomNavigationView applies an ItemIconTintList to every tab icon, built from +/// Shell's TabBarForegroundColor and TabBarUnselectedColor. That tint replaces whatever colour the +/// icon actually has, so the five coloured tab SVGs all came out as one hue - purple when selected, +/// grey otherwise. Clearing the tint list is the only way to let a multi-coloured icon through. +/// +/// +/// Icons only. The labels still take TabBarTitleColor, so the selected tab is still marked by its +/// text turning brand purple - colour alone never carries "which tab am I on". +/// +/// +public class ColorfulTabsShellRenderer : ShellRenderer +{ + public ColorfulTabsShellRenderer(Context context) + : base(context) + { + } + + protected override IShellBottomNavViewAppearanceTracker CreateBottomNavViewAppearanceTracker(ShellItem shellItem) => + new UntintedBottomNavAppearanceTracker(this, shellItem); + + private sealed class UntintedBottomNavAppearanceTracker : ShellBottomNavViewAppearanceTracker + { + public UntintedBottomNavAppearanceTracker(IShellContext shellContext, ShellItem shellItem) + : base(shellContext, shellItem) + { + } + + public override void SetAppearance(BottomNavigationView bottomView, IShellAppearanceElement appearance) + { + base.SetAppearance(bottomView, appearance); + + // After base, which is what sets the tint in the first place. + bottomView.ItemIconTintList = null; + } + + public override void ResetAppearance(BottomNavigationView bottomView) + { + base.ResetAppearance(bottomView); + bottomView.ItemIconTintList = null; + } + } +} diff --git a/src/SubVora.Mobile/Platforms/Android/Resources/values-night/colors.xml b/src/SubVora.Mobile/Platforms/Android/Resources/values-night/colors.xml new file mode 100644 index 0000000..5adb078 --- /dev/null +++ b/src/SubVora.Mobile/Platforms/Android/Resources/values-night/colors.xml @@ -0,0 +1,14 @@ + + + + #512BD4 + #1C1C21 + #7C5CE0 + diff --git a/src/SubVora.Mobile/Resources/Images/tab_categories.svg b/src/SubVora.Mobile/Resources/Images/tab_categories.svg index 4ce3177..6ecc954 100644 --- a/src/SubVora.Mobile/Resources/Images/tab_categories.svg +++ b/src/SubVora.Mobile/Resources/Images/tab_categories.svg @@ -1,3 +1,3 @@ - + diff --git a/src/SubVora.Mobile/Resources/Images/tab_dashboard.svg b/src/SubVora.Mobile/Resources/Images/tab_dashboard.svg index 6819fc0..01d8ee8 100644 --- a/src/SubVora.Mobile/Resources/Images/tab_dashboard.svg +++ b/src/SubVora.Mobile/Resources/Images/tab_dashboard.svg @@ -1,3 +1,3 @@ - + diff --git a/src/SubVora.Mobile/Resources/Images/tab_payments.svg b/src/SubVora.Mobile/Resources/Images/tab_payments.svg index b2571f1..4dd6043 100644 --- a/src/SubVora.Mobile/Resources/Images/tab_payments.svg +++ b/src/SubVora.Mobile/Resources/Images/tab_payments.svg @@ -1,3 +1,3 @@ - + diff --git a/src/SubVora.Mobile/Resources/Images/tab_settings.svg b/src/SubVora.Mobile/Resources/Images/tab_settings.svg index b45694e..f0c0b09 100644 --- a/src/SubVora.Mobile/Resources/Images/tab_settings.svg +++ b/src/SubVora.Mobile/Resources/Images/tab_settings.svg @@ -1,3 +1,3 @@ - + diff --git a/src/SubVora.Mobile/Resources/Images/tab_subscriptions.svg b/src/SubVora.Mobile/Resources/Images/tab_subscriptions.svg index 90a9ff9..163f282 100644 --- a/src/SubVora.Mobile/Resources/Images/tab_subscriptions.svg +++ b/src/SubVora.Mobile/Resources/Images/tab_subscriptions.svg @@ -1,3 +1,3 @@ - + diff --git a/src/SubVora.Mobile/Resources/Styles/Theme.xaml b/src/SubVora.Mobile/Resources/Styles/Theme.xaml index d30267d..6d51bc2 100644 --- a/src/SubVora.Mobile/Resources/Styles/Theme.xaml +++ b/src/SubVora.Mobile/Resources/Styles/Theme.xaml @@ -34,6 +34,7 @@ + @@ -506,4 +507,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SubVora.Mobile/Services/AnchorBounds.cs b/src/SubVora.Mobile/Services/AnchorBounds.cs new file mode 100644 index 0000000..1ee78d0 --- /dev/null +++ b/src/SubVora.Mobile/Services/AnchorBounds.cs @@ -0,0 +1,51 @@ +namespace SubVora.Mobile.Services; + +/// +/// Screen position of a control, for anchoring a popup to it. +/// +/// MAUI has no cross-platform "where is this element on screen" API - VisualElement.Bounds is +/// relative to the parent, which is useless inside a scrolled list. The platform view knows, so this +/// asks it. +/// +/// +public static class AnchorBounds +{ + /// + /// The element's bounds in device-independent units, measured from the top-left of the screen + /// including the status bar - the same origin the popup overlay uses. + /// + /// Null when the platform is not handled or the element is not realised yet. Callers treat null + /// as "no anchor" and fall back to a fixed corner rather than guessing a position. + /// + /// + public static Rect? OnScreen(VisualElement element) + { +#if ANDROID + if (element.Handler?.PlatformView is not Android.Views.View native) + { + return null; + } + + var location = new int[2]; + native.GetLocationOnScreen(location); + + // GetLocationOnScreen answers in physical pixels; everything in MAUI layout is in DIPs. + var density = native.Context?.Resources?.DisplayMetrics?.Density ?? 1f; + if (density <= 0) + { + return null; + } + + return new Rect( + location[0] / density, + location[1] / density, + native.Width / density, + native.Height / density); +#else + // iOS/Windows would each need their own platform call. Neither is distributed today, and a + // wrong position is worse than the unanchored fallback. + _ = element; + return null; +#endif + } +} diff --git a/src/SubVora.Mobile/Services/IUserPrompt.cs b/src/SubVora.Mobile/Services/IUserPrompt.cs index 0581e1e..4c1c84f 100644 --- a/src/SubVora.Mobile/Services/IUserPrompt.cs +++ b/src/SubVora.Mobile/Services/IUserPrompt.cs @@ -28,4 +28,17 @@ public interface IUserPrompt /// /// Task ActionSheetAsync(string title, string cancel, params string[] actions); + + /// + /// Screen bounds of the control the next action sheet belongs to, so the menu can open against + /// it instead of in a corner. Null anchors nothing and the menu falls back to the top right. + /// + /// Set by the view immediately before invoking the command, and cleared as soon as it is used. + /// It lives here rather than as a parameter because the view models call + /// and must not know about views - the position is a fact only + /// the page has, and threading a VisualElement through a view model to reach this is worse than + /// one write-then-consume property on the dialog service. + /// + /// + Rect? NextActionSheetAnchor { get; set; } } diff --git a/src/SubVora.Mobile/Services/ShellUserPrompt.cs b/src/SubVora.Mobile/Services/ShellUserPrompt.cs index 7c98973..0dd7b31 100644 --- a/src/SubVora.Mobile/Services/ShellUserPrompt.cs +++ b/src/SubVora.Mobile/Services/ShellUserPrompt.cs @@ -1,3 +1,8 @@ +using CommunityToolkit.Maui; +using Microsoft.Maui.Controls.Shapes; +using CommunityToolkit.Maui.Extensions; +using SubVora.Mobile.Views; + namespace SubVora.Mobile.Services; public class ShellUserPrompt : IUserPrompt @@ -11,6 +16,53 @@ public Task AlertAsync(string title, string message, string dismiss = "OK") => public Task PromptAsync(string title, string message, string initialValue = "") => Shell.Current.DisplayPromptAsync(title, message, initialValue: initialValue); - public Task ActionSheetAsync(string title, string cancel, params string[] actions) => - Shell.Current.DisplayActionSheetAsync(title, cancel, destruction: null, actions); + /// + /// A styled bottom sheet rather than DisplayActionSheetAsync. The platform sheet is an + /// unstyled list of strings in a system alert - it takes none of the app's shape, colour or type + /// scale, and read as a different application next to the screen that opened it. + /// + /// The label is unused: the sheet is dismissed by tapping outside it, + /// which is the Material pattern and is what a null return means. It stays in the signature + /// because is the seam the view models are tested against, and + /// changing it would churn every caller for no behavioural gain. + /// + /// + /// + public Rect? NextActionSheetAnchor { get; set; } + + public async Task ActionSheetAsync(string title, string cancel, params string[] actions) + { + // Consumed once. Left set, a later menu opened from somewhere with no anchor would position + // itself against whatever was tapped last. + var anchor = NextActionSheetAnchor; + NextActionSheetAnchor = null; + + var result = await Shell.Current.ShowPopupAsync( + new ActionSheetPopup(title, actions, anchor), + new PopupOptions + { + CanBeDismissedByTappingOutsideOfPopup = true, + + // An explicitly transparent shape, not null: null falls back to the toolkit's own + // default card, which draws a pale rounded rectangle behind the content. Invisible + // against a light page, it showed up in dark mode as a halo framing the menu. + Shape = new RoundRectangle + { + CornerRadius = 16, + Fill = Colors.Transparent, + Stroke = Colors.Transparent, + StrokeThickness = 0, + }, + Shadow = null, + + // Light scrim only. A menu is not modal in the way a sheet is - it should dim the + // page enough to read as "on top of" without hiding the row it belongs to, which is + // the only thing tying the two together. + PageOverlayColor = Colors.Black.WithAlpha(0.2f), + }); + + // Dismissed without choosing. Null is the caller's cue to do nothing at all - never to fall + // through to a default action. + return result.WasDismissedByTappingOutsideOfPopup ? null : result.Result; + } } diff --git a/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs b/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs index 9d6bdf3..7dd7ee6 100644 --- a/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs +++ b/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs @@ -37,13 +37,6 @@ public partial class CategoriesViewModel : ObservableObject /// public ObservableCollection Groups { get; } = []; - /// - /// Whether any row offers rename/delete. Gates the hint under the list - the hint used to live - /// in the CollectionView's EmptyView, which renders only when there is nothing to act on, so - /// it was invisible exactly when it applied. - /// - public bool HasManageableCategories => Categories.Any(category => !category.IsSystemDefault); - public CategoriesViewModel(ICategoriesApi categoriesApi, IConnectivityService connectivity, IUserPrompt userPrompt) { _categoriesApi = categoriesApi; @@ -155,8 +148,6 @@ private void RebuildGroups() { Groups.Add(new CategoryGroup(CategoryGroup.SystemTitle, isSystem: true, system)); } - - OnPropertyChanged(nameof(HasManageableCategories)); } /// diff --git a/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs b/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs index 77df8b4..133a5fd 100644 --- a/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs +++ b/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs @@ -30,24 +30,12 @@ public partial class PaymentSourcesViewModel : ObservableObject public ObservableCollection PaymentSources { get; } = []; - /// - /// Whether there is any row to act on. Gates the manage hint under the list - it used to live - /// in the CollectionView's EmptyView, which renders only when the list is empty, so it was on - /// screen exactly when it did not apply. - /// - public bool HasPaymentSources => PaymentSources.Count > 0; - public PaymentSourcesViewModel(IPaymentSourcesApi paymentSourcesApi, IUserPrompt userPrompt, IConnectivityService connectivity) { _connectivity = connectivity; IsOffline = !connectivity.IsConnected; _paymentSourcesApi = paymentSourcesApi; _userPrompt = userPrompt; - - // HasPaymentSources is derived from the collection, and a collection change raises nothing - // for it on its own. Hooked once here rather than remembered at each of the four call sites - // that mutate the list. - PaymentSources.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasPaymentSources)); } /// /// Whether the device has no network. Refreshed when the screen loads and after a failed write diff --git a/src/SubVora.Mobile/Views/ActionSheetPopup.cs b/src/SubVora.Mobile/Views/ActionSheetPopup.cs new file mode 100644 index 0000000..4cff1eb --- /dev/null +++ b/src/SubVora.Mobile/Views/ActionSheetPopup.cs @@ -0,0 +1,71 @@ +using CommunityToolkit.Maui.Views; + +namespace SubVora.Mobile.Views; + +/// +/// Wraps in the toolkit's popup so it can return the chosen action, +/// and positions it against the control that opened it. +/// +public class ActionSheetPopup : Popup +{ + /// Must match the card's WidthRequest in ActionSheetView.xaml. + private const double MenuWidth = 212; + + /// Title row plus one row per action, near enough to decide whether the menu fits below the anchor. + private const double EstimatedRowHeight = 40; + private const double EstimatedChromeHeight = 40; + + private const double EdgeGap = 8; + private const double AnchorGap = 6; + + /// + /// Screen bounds of the control that opened the menu, in device-independent units, or null when + /// the caller could not work them out - in which case the menu falls back to the top right, + /// below the app bar. + /// + public ActionSheetPopup(string title, IReadOnlyList actions, Rect? anchor = null) + { + VerticalOptions = LayoutOptions.Start; + HorizontalOptions = LayoutOptions.Start; + Padding = 0; + Margin = PositionFor(anchor, actions.Count); + + var view = new ActionSheetView(title, actions); + view.ActionChosen += async (_, action) => await CloseAsync(action, CancellationToken.None); + + Content = view; + } + + /// + /// Where to put the menu. The popup overlay covers the whole window - status bar included - so + /// these are plain screen coordinates and a margin is the only positioning the toolkit offers: + /// its Popup has no anchor of its own. + /// + private static Thickness PositionFor(Rect? anchor, int actionCount) + { + var display = DeviceDisplay.Current.MainDisplayInfo; + var screenWidth = display.Width / display.Density; + var screenHeight = display.Height / display.Density; + + if (anchor is not { } bounds) + { + // No anchor: top right, clear of the 24pt status bar and 56pt app bar. + return new Thickness(Math.Max(EdgeGap, screenWidth - MenuWidth - 10), 88, 0, 0); + } + + // Right edges aligned, so the menu hangs from the button rather than starting at it - the + // button sits at the right end of its row, and a left-aligned menu would run off-screen. + var left = Math.Clamp(bounds.Right - MenuWidth, EdgeGap, Math.Max(EdgeGap, screenWidth - MenuWidth - EdgeGap)); + + var menuHeight = EstimatedChromeHeight + (actionCount * EstimatedRowHeight); + var below = bounds.Bottom + AnchorGap; + + // Flip above the button when there is not room beneath it, which is what happens for a row + // near the bottom of a long list. + var top = below + menuHeight + EdgeGap <= screenHeight + ? below + : Math.Max(EdgeGap, bounds.Top - menuHeight - AnchorGap); + + return new Thickness(left, top, 0, 0); + } +} diff --git a/src/SubVora.Mobile/Views/ActionSheetView.xaml b/src/SubVora.Mobile/Views/ActionSheetView.xaml new file mode 100644 index 0000000..9a36e2a --- /dev/null +++ b/src/SubVora.Mobile/Views/ActionSheetView.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/SubVora.Mobile/Views/ActionSheetView.xaml.cs b/src/SubVora.Mobile/Views/ActionSheetView.xaml.cs new file mode 100644 index 0000000..2566581 --- /dev/null +++ b/src/SubVora.Mobile/Views/ActionSheetView.xaml.cs @@ -0,0 +1,119 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace SubVora.Mobile.Views; + +/// +/// The contents of the dropdown menu. Rows are built in code rather than bound to a collection +/// because the caller passes plain strings - IUserPrompt.ActionSheetAsync keeps the platform +/// action sheet's signature so view models and their tests are unaffected by this being a custom +/// menu rather than the system one. +/// +public partial class ActionSheetView : ContentView +{ + /// + /// Icons for the actions this app actually offers. An action without an entry gets a text-only + /// row - better than picking a wrong icon for a word we did not expect. + /// + private static readonly Dictionary IconKeyByAction = new(StringComparer.OrdinalIgnoreCase) + { + ["Rename"] = "IconSliders", + ["Delete"] = "IconTrash", + }; + + /// Actions that destroy something, drawn in the danger colour rather than the normal text colour. + private static readonly HashSet DestructiveActions = new(StringComparer.OrdinalIgnoreCase) { "Delete" }; + + /// Raised with the chosen action. Never raised on dismissal - the popup handles that. + public event EventHandler? ActionChosen; + + public ActionSheetView(string title, IEnumerable actions) + { + InitializeComponent(); + + TitleLabel.Text = title; + + foreach (var action in actions) + { + ActionsLayout.Add(BuildRow(action)); + } + } + + private View BuildRow(string action) + { + var isDestructive = DestructiveActions.Contains(action); + + var foreground = isDestructive + ? ThemeColor("DangerLight", "DangerDark") + : ThemeColor("TextStrongLight", "TextStrongDark"); + + var row = new Grid + { + ColumnDefinitions = [new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star)], + ColumnSpacing = 12, + Padding = new Thickness(10, 9), + }; + + if (IconKeyByAction.TryGetValue(action, out var iconKey) + && Application.Current?.Resources.TryGetValue(iconKey, out var geometry) == true + && geometry is Geometry icon) + { + // Bare glyph, no tinted circle behind it. The circles belonged to the bottom sheet this + // replaced; at menu scale they were most of the row's height. + var glyph = new Microsoft.Maui.Controls.Shapes.Path + { + Data = icon, + Aspect = Stretch.Uniform, + WidthRequest = 17, + HeightRequest = 17, + Fill = foreground, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + }; + + row.Add(glyph); + Grid.SetColumn(glyph, 0); + } + + var label = new Label + { + Text = action, + FontSize = 14.5, + VerticalOptions = LayoutOptions.Center, + TextColor = foreground, + }; + row.Add(label); + Grid.SetColumn(label, 1); + + var container = new Border + { + Stroke = Colors.Transparent, + StrokeThickness = 0, + Padding = 0, + BackgroundColor = Colors.Transparent, + StrokeShape = new RoundRectangle { CornerRadius = 10 }, + Content = row, + }; + + // The whole row is the target, not just the label - an icon beside text people aim at is a + // tap that does nothing if only the text is wired up. + container.GestureRecognizers.Add(new TapGestureRecognizer + { + Command = new Command(() => ActionChosen?.Invoke(this, action)), + }); + + return container; + } + + /// + /// Resolves a light/dark token pair for the current theme. The rows are built in code, so they + /// cannot use AppThemeBinding markup and have to pick the side themselves. + /// + private static Color ThemeColor(string lightKey, string darkKey) + { + var key = Application.Current?.RequestedTheme == AppTheme.Dark ? darkKey : lightKey; + + return Application.Current?.Resources.TryGetValue(key, out var value) == true && value is Color color + ? color + : Colors.Grey; + } +} diff --git a/src/SubVora.Mobile/Views/CategoriesPage.xaml b/src/SubVora.Mobile/Views/CategoriesPage.xaml index 103038e..38ecab5 100644 --- a/src/SubVora.Mobile/Views/CategoriesPage.xaml +++ b/src/SubVora.Mobile/Views/CategoriesPage.xaml @@ -18,120 +18,108 @@ - +