From c51f927673f2d8fa0b87c1240e9a7a2dd061592a Mon Sep 17 00:00:00 2001 From: RghvGrv Date: Tue, 11 Aug 2026 17:24:20 +0530 Subject: [PATCH 1/7] feat(mobile): drop swipe on Categories and restyle it Material 3 Expressive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swipe-to-rename/delete is gone from the Categories list, along with the hint that explained it. The row's own menu button - added in #172 because the gesture advertised itself to nobody - is now the only route, and two ways into the same action sheet is one more than the screen needs. The button is still drawn only on the user's own categories; the server answers 404 for a system default either way. RenameCommand and DeleteCommand are untouched. ManageCommand still calls them, so behaviour is identical - only the gesture is removed. HasManageableCategories went with the hint it existed to gate, and its assertions with it, rather than being left behind as a property nothing reads. The screen is then rebuilt on a Material 3 Expressive treatment, added to Theme.xaml as keyed styles under their own heading. They are additive and opted into by name, so no other screen changes: Categories is the first on this treatment and the rest keep the Card/CardRow set. What actually makes it expressive rather than just rounder: - Shape carries the hierarchy. Every row is its own 24-radius container on the page background, replacing hairline-separated rows inside one card. - Headings anchor instead of whisper. SectionHeading is an uppercase 13px muted caption; the M3E title is 22px, bold, sentence case, with the count moved into a tonal pill so the header row has two shapes in it. - Full-round for small interactive things: 48 circular category avatars, a 44 circular tonal icon button, a pill text field and a pill Add button. No colour is invented. Everything reuses the existing tokens, because the categorical palette is validated for contrast and CVD separation and a ninth hue chosen by eye would not be. The manage button also moves from a "•••" text Button to a Border hosting the real Material more_vert path, so its Fill is theme-bound like every other icon in the app. Verified on a physical device: the two sections, count pills, circular avatars and the tonal menu button all render, the menu button appears on the user's category and not on system rows, and there are no crashes. Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/Styles/Theme.xaml | 129 ++++++++++++++ .../ViewModels/CategoriesViewModel.cs | 9 - src/SubVora.Mobile/Views/CategoriesPage.xaml | 161 ++++++++---------- .../CategoryGroupingTests.cs | 5 +- 4 files changed, 204 insertions(+), 100 deletions(-) diff --git a/src/SubVora.Mobile/Resources/Styles/Theme.xaml b/src/SubVora.Mobile/Resources/Styles/Theme.xaml index d30267d..740bfc9 100644 --- a/src/SubVora.Mobile/Resources/Styles/Theme.xaml +++ b/src/SubVora.Mobile/Resources/Styles/Theme.xaml @@ -34,6 +34,7 @@ + @@ -506,4 +507,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/Views/CategoriesPage.xaml b/src/SubVora.Mobile/Views/CategoriesPage.xaml index 103038e..a6c1d96 100644 --- a/src/SubVora.Mobile/Views/CategoriesPage.xaml +++ b/src/SubVora.Mobile/Views/CategoriesPage.xaml @@ -18,120 +18,107 @@ - + 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 16778b8..0931ba3 100644 --- a/src/SubVora.Mobile/Services/ShellUserPrompt.cs +++ b/src/SubVora.Mobile/Services/ShellUserPrompt.cs @@ -26,10 +26,18 @@ public Task AlertAsync(string title, string message, string dismiss = "OK") => /// 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), + new ActionSheetPopup(title, actions, anchor), new PopupOptions { CanBeDismissedByTappingOutsideOfPopup = true, diff --git a/src/SubVora.Mobile/Views/ActionSheetPopup.cs b/src/SubVora.Mobile/Views/ActionSheetPopup.cs index 3f30a3a..4cff1eb 100644 --- a/src/SubVora.Mobile/Views/ActionSheetPopup.cs +++ b/src/SubVora.Mobile/Views/ActionSheetPopup.cs @@ -3,35 +3,69 @@ namespace SubVora.Mobile.Views; /// -/// Wraps in the toolkit's popup so it can return the chosen action. -/// -/// Thin on purpose: the visuals live in XAML, and this exists only to satisfy the -/// Popup<T> contract - a plain View cannot hand a result back to the awaiting caller. -/// +/// 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 { - public ActionSheetPopup(string title, IReadOnlyList actions) + /// 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) { - // Sized to the menu card itself, right-aligned, near the top - where a row-level "..." - // button sits and where a dropdown from one is expected to appear. - // - // Not anchored to the button that opened it: CommunityToolkit's Popup exposes only Margin - // and the two alignment properties, with no anchor, so tying it to a specific row would - // mean plumbing that row's screen position through IUserPrompt - and that interface is the - // seam every view model is tested against. The menu carries the row's name instead. VerticalOptions = LayoutOptions.Start; - HorizontalOptions = LayoutOptions.End; + HorizontalOptions = LayoutOptions.Start; Padding = 0; - - // The popup overlay covers the whole window, status bar and navigation bar included, so a - // small top margin put the menu on top of the purple app bar. This clears both: 24 for the - // status bar plus 56 for the app bar, then a gap. - Margin = new Thickness(0, 88, 10, 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/CategoriesPage.xaml b/src/SubVora.Mobile/Views/CategoriesPage.xaml index a6c1d96..38ecab5 100644 --- a/src/SubVora.Mobile/Views/CategoriesPage.xaml +++ b/src/SubVora.Mobile/Views/CategoriesPage.xaml @@ -98,10 +98,11 @@ Style="{StaticResource M3TonalIconButton}" IsVisible="{Binding IsSystemDefault, Converter={StaticResource InvertedBoolConverter}}" SemanticProperties.Description="Rename or delete this category"> + - + diff --git a/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs b/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs index db7981d..d6195f3 100644 --- a/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs +++ b/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs @@ -1,3 +1,5 @@ +using SubVora.Mobile.Api.Dtos; +using SubVora.Mobile.Services; using SubVora.Mobile.ViewModels; namespace SubVora.Mobile.Views; @@ -5,11 +7,13 @@ namespace SubVora.Mobile.Views; public partial class CategoriesPage : ContentPage { private readonly CategoriesViewModel _viewModel; + private readonly IUserPrompt _userPrompt; - public CategoriesPage(CategoriesViewModel viewModel) + public CategoriesPage(CategoriesViewModel viewModel, IUserPrompt userPrompt) { InitializeComponent(); _viewModel = viewModel; + _userPrompt = userPrompt; BindingContext = _viewModel; } @@ -18,4 +22,26 @@ protected override void OnAppearing() base.OnAppearing(); _viewModel.LoadCommand.Execute(null); } + + /// + /// Opens the row menu, anchored to the button that was tapped. + /// + /// The command could be bound straight from XAML, and was. It is routed through here only to + /// capture where the button is on screen: that is something the page can see and the view model + /// deliberately cannot, and the alternative - handing a VisualElement to the view model - would + /// put layout inside the layer that is unit-tested without one. + /// + /// + private void OnManageTapped(object? sender, TappedEventArgs e) + { + if (sender is not VisualElement button || e.Parameter is not CategoryDto category) + { + return; + } + + // Null is fine - ShellUserPrompt falls back to a fixed corner rather than guessing. + _userPrompt.NextActionSheetAnchor = AnchorBounds.OnScreen(button); + + _viewModel.ManageCommand.Execute(category); + } } diff --git a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml index a15866d..22fecbe 100644 --- a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml +++ b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml @@ -97,8 +97,8 @@ MinimumHeightRequest="34" MinimumWidthRequest="44" SemanticProperties.Description="Rename or delete this payment source" - Command="{Binding Source={RelativeSource AncestorType={x:Type viewModels:PaymentSourcesViewModel}}, Path=ManageCommand}" - CommandParameter="{Binding .}" /> + CommandParameter="{Binding .}" + Clicked="OnManageClicked" /> diff --git a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs index 2d77338..8dae0fe 100644 --- a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs +++ b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs @@ -1,3 +1,5 @@ +using SubVora.Mobile.Api.Dtos; +using SubVora.Mobile.Services; using SubVora.Mobile.ViewModels; namespace SubVora.Mobile.Views; @@ -5,11 +7,13 @@ namespace SubVora.Mobile.Views; public partial class PaymentSourcesPage : ContentPage { private readonly PaymentSourcesViewModel _viewModel; + private readonly IUserPrompt _userPrompt; - public PaymentSourcesPage(PaymentSourcesViewModel viewModel) + public PaymentSourcesPage(PaymentSourcesViewModel viewModel, IUserPrompt userPrompt) { InitializeComponent(); _viewModel = viewModel; + _userPrompt = userPrompt; BindingContext = _viewModel; } @@ -18,4 +22,20 @@ protected override void OnAppearing() base.OnAppearing(); _viewModel.LoadCommand.Execute(null); } + + /// + /// Opens the row menu anchored to the button tapped - see CategoriesPage.OnManageTapped for why + /// this goes through the page rather than binding the command directly. + /// + private void OnManageClicked(object? sender, EventArgs e) + { + if (sender is not Button button || button.CommandParameter is not PaymentSourceDto paymentSource) + { + return; + } + + _userPrompt.NextActionSheetAnchor = AnchorBounds.OnScreen(button); + + _viewModel.ManageCommand.Execute(paymentSource); + } } diff --git a/tests/SubVora.Mobile.Tests/Fakes/FakeUserPrompt.cs b/tests/SubVora.Mobile.Tests/Fakes/FakeUserPrompt.cs index 4c1695b..41ae7aa 100644 --- a/tests/SubVora.Mobile.Tests/Fakes/FakeUserPrompt.cs +++ b/tests/SubVora.Mobile.Tests/Fakes/FakeUserPrompt.cs @@ -35,6 +35,9 @@ public Task AlertAsync(string title, string message, string dismiss = "OK") /// Which action the sheet returns. Null - the default - is a dismissal. public string? ActionSheetResult { get; set; } + /// Set by the page, never read by a view model. Present only to satisfy the interface. + public Rect? NextActionSheetAnchor { get; set; } + public List<(string Title, string[] Actions)> ActionSheetCalls { get; } = []; public Task ActionSheetAsync(string title, string cancel, params string[] actions) From 8c6f79dac9a43f8f8265a8b9c0ee875240674884 Mon Sep 17 00:00:00 2001 From: RghvGrv Date: Tue, 11 Aug 2026 19:19:29 +0530 Subject: [PATCH 6/7] feat(mobile): drop the Payment hint and give each tab its own colour The hint under the Payment list is gone, matching Categories. Its only consumer was HasPaymentSources, so that went with it rather than being left as a property nothing reads - and its test with it. The five tab icons now carry a hue each: Home blue, Subs teal, Categories purple, Payment amber, Settings grey. Colouring the SVGs is only half of it - Android's BottomNavigationView applies an ItemIconTintList built from Shell's TabBarForegroundColor and TabBarUnselectedColor, and that tint replaces the icon's own colour, so all five rendered as one hue whatever the SVG said. ColorfulTabsShellRenderer clears the tint list after base.SetAppearance, which is the only way a multi-coloured tab icon survives. Icons only. The labels still take TabBarTitleColor, so the selected tab is marked by its text turning brand purple - with every icon coloured, icon colour can no longer say which tab is active, and nothing else would. One value per icon, since an SVG cannot hold an AppThemeBinding: each hue is a mid-tone that stays legible on the light tab bar and the dark one. Verified on a device: all five colours render together, the selected tab is still distinguishable, and the crash buffer is empty. Co-Authored-By: Claude Opus 5 (1M context) --- src/SubVora.Mobile/MauiProgram.cs | 7 +++ .../Android/ColorfulTabsShellRenderer.cs | 52 +++++++++++++++++++ .../Resources/Images/tab_categories.svg | 2 +- .../Resources/Images/tab_dashboard.svg | 2 +- .../Resources/Images/tab_payments.svg | 2 +- .../Resources/Images/tab_settings.svg | 2 +- .../Resources/Images/tab_subscriptions.svg | 2 +- .../ViewModels/PaymentSourcesViewModel.cs | 12 ----- .../Views/PaymentSourcesPage.xaml | 10 +--- .../SubVora.Mobile.Tests/ManageActionTests.cs | 19 +------ 10 files changed, 66 insertions(+), 44 deletions(-) create mode 100644 src/SubVora.Mobile/Platforms/Android/ColorfulTabsShellRenderer.cs 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/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/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/PaymentSourcesPage.xaml b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml index 22fecbe..922c73c 100644 --- a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml +++ b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml @@ -17,7 +17,7 @@ - + diff --git a/tests/SubVora.Mobile.Tests/ManageActionTests.cs b/tests/SubVora.Mobile.Tests/ManageActionTests.cs index 7fead6f..c823fcc 100644 --- a/tests/SubVora.Mobile.Tests/ManageActionTests.cs +++ b/tests/SubVora.Mobile.Tests/ManageActionTests.cs @@ -1,4 +1,4 @@ -using SubVora.Mobile.Api.Dtos; +using SubVora.Mobile.Api.Dtos; using SubVora.Mobile.Tests.Fakes; using SubVora.Mobile.ViewModels; @@ -106,22 +106,5 @@ public async Task PaymentSource_Manage_ChoosingDelete_DeletesAfterConfirming() Assert.Equal("HDFC Card", Assert.Single(prompt.ActionSheetCalls).Title); Assert.Equal(source.Id, Assert.Single(deleted)); Assert.Empty(viewModel.PaymentSources); - Assert.False(viewModel.HasPaymentSources); - } - - [Fact] - public async Task PaymentSource_HasPaymentSources_TracksTheList() - { - // Gates the manage hint. Derived from the collection, so it has to be raised on change - - // nothing does that automatically. - var api = new FakePaymentSourcesApi(); - var viewModel = new PaymentSourcesViewModel(api, new FakeUserPrompt(), new FakeConnectivityService()); - - Assert.False(viewModel.HasPaymentSources); - - viewModel.NewLabel = "UPI"; - await viewModel.AddCommand.ExecuteAsync(null); - - Assert.True(viewModel.HasPaymentSources); } } From 5c97f5167b0e422b49731128e34c6121749b2382 Mon Sep 17 00:00:00 2001 From: RghvGrv Date: Tue, 11 Aug 2026 21:15:54 +0530 Subject: [PATCH 7/7] fix(mobile): four dark-mode defects on Categories and the row menu All four were invisible in light mode and only turned up on the device with the system theme flipped. **Purple status bar over a near-black app bar.** colorPrimaryDark paints the status bar and is an Android resource, not a MAUI one, so Shell's AppThemeBinding never reached it. Added values-night/colors.xml setting it to SurfaceDark, the colour Shell already paints the app bar in dark. **Rows did not separate from the page.** M3ListItem was SurfaceDark #1C1C21 on a #111114 page - barely a step, and the list read as vague blobs. Dark now uses SurfaceMutedDark. Light is unchanged; white on #F4F5F9 already lifts. **Category tiles came out muddy.** The soft wash is 20% of the hue, and over a near-black surface most of what showed through was the background rather than the colour - navy for blue, brown for orange. 32% in dark, 20% unchanged in light. Same fix in PaymentSourceColorConverter, which shares the treatment. **A pale halo framed the menu.** PopupOptions.Shape = null does not mean "no shape" - the toolkit falls back to its own default card, which draws a light rounded rectangle behind the content. Invisible on a light page; a frame around the menu in dark. Now an explicitly transparent RoundRectangle. The menu's own shadow Border also gained the matching StrokeShape, so the shadow is cast round rather than square. Also: the row's menu button took SurfaceMutedDark, which is now the row's own colour, leaving it with no edge - HairlineDark in dark instead. Checked both themes on the device afterwards: dark is fixed and light is unchanged, with no halo in either. Co-Authored-By: Claude Opus 5 (1M context) --- .../Converters/CategoryColorConverter.cs | 9 ++++++++- .../Converters/PaymentSourceIconConverter.cs | 5 ++++- .../Android/Resources/values-night/colors.xml | 14 ++++++++++++++ src/SubVora.Mobile/Resources/Styles/Theme.xaml | 9 +++++++-- src/SubVora.Mobile/Services/ShellUserPrompt.cs | 17 ++++++++++++----- src/SubVora.Mobile/Views/ActionSheetView.xaml | 8 ++++++-- 6 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 src/SubVora.Mobile/Platforms/Android/Resources/values-night/colors.xml 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/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/Styles/Theme.xaml b/src/SubVora.Mobile/Resources/Styles/Theme.xaml index 740bfc9..6d51bc2 100644 --- a/src/SubVora.Mobile/Resources/Styles/Theme.xaml +++ b/src/SubVora.Mobile/Resources/Styles/Theme.xaml @@ -526,7 +526,10 @@