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 @@
-
+
+
-
+
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
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..922c73c 100644
--- a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml
+++ b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml
@@ -17,7 +17,7 @@
-
+
+ CommandParameter="{Binding .}"
+ Clicked="OnManageClicked" />
@@ -115,14 +115,6 @@
-
-
-
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/CategoryGroupingTests.cs b/tests/SubVora.Mobile.Tests/CategoryGroupingTests.cs
index cd04a9e..2f9fa07 100644
--- a/tests/SubVora.Mobile.Tests/CategoryGroupingTests.cs
+++ b/tests/SubVora.Mobile.Tests/CategoryGroupingTests.cs
@@ -1,4 +1,4 @@
-using SubVora.Mobile.Api.Dtos;
+using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Tests.Fakes;
using SubVora.Mobile.ViewModels;
@@ -56,7 +56,6 @@ public async Task LoadAsync_WithNoUserCategories_ShowsOnlyTheSystemSection()
var group = Assert.Single(viewModel.Groups);
Assert.Equal(CategoryGroup.SystemTitle, group.Title);
- Assert.False(viewModel.HasManageableCategories);
}
[Fact]
@@ -87,7 +86,6 @@ public async Task AddAsync_PutsTheNewCategoryInTheUserSection_NotTheSystemOne()
Assert.Equal(2, viewModel.Groups.Count);
Assert.Equal(CategoryGroup.UserTitle, viewModel.Groups[0].Title);
Assert.Equal("Music", Assert.Single(viewModel.Groups[0]).Name);
- Assert.True(viewModel.HasManageableCategories);
}
[Fact]
@@ -115,7 +113,6 @@ public async Task DeleteAsync_DropsTheUserSectionOnceItsLastRowIsGone()
var group = Assert.Single(viewModel.Groups);
Assert.Equal(CategoryGroup.SystemTitle, group.Title);
- Assert.False(viewModel.HasManageableCategories);
}
[Fact]
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)
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);
}
}