Skip to content
9 changes: 8 additions & 1 deletion src/SubVora.Mobile/Converters/CategoryColorConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ public class CategoryColorConverter : IValueConverter
/// </summary>
private const float SoftAlpha = 0.20f;

/// <summary>
/// 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.
/// </summary>
private const float SoftAlphaDark = 0.32f;

public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var isDark = Application.Current?.RequestedTheme == AppTheme.Dark;
Expand All @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ public class PaymentSourceColorConverter : IValueConverter
/// <summary>Matches CategoryColorConverter's soft wash, so tiles look the same weight across screens.</summary>
private const float SoftAlpha = 0.20f;

/// <summary>Same reasoning as CategoryColorConverter: 0.20 over a near-black surface reads muddy.</summary>
private const float SoftAlphaDark = 0.32f;

public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var isDark = Application.Current?.RequestedTheme == AppTheme.Dark;
Expand All @@ -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) =>
Expand Down
7 changes: 7 additions & 0 deletions src/SubVora.Mobile/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Shell, Platforms.Android.ColorfulTabsShellRenderer>());
#endif

#if DEBUG
builder.Logging.AddDebug();
#endif
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Lets the tab icons keep their own colours.
/// <para>
/// Android's BottomNavigationView applies an <c>ItemIconTintList</c> 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.
/// </para>
/// <para>
/// 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".
/// </para>
/// </summary>
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Dark-theme overrides for the Android-level colours in values/colors.xml.

colorPrimaryDark paints the status bar, and it is an Android resource rather than a MAUI one, so
Shell's AppThemeBinding never touched it: in dark mode a bright purple status bar sat directly
above a near-black app bar. Matching it to SurfaceDark - the same colour Shell paints the app bar
in dark - makes the top of the screen one surface again.
-->
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#1C1C21</color>
<color name="colorAccent">#7C5CE0</color>
</resources>
2 changes: 1 addition & 1 deletion src/SubVora.Mobile/Resources/Images/tab_categories.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/SubVora.Mobile/Resources/Images/tab_dashboard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/SubVora.Mobile/Resources/Images/tab_payments.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/SubVora.Mobile/Resources/Images/tab_settings.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
134 changes: 134 additions & 0 deletions src/SubVora.Mobile/Resources/Styles/Theme.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<PathGeometry x:Key="IconSchedule" Figures="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
<PathGeometry x:Key="IconCheckCircle" Figures="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
<PathGeometry x:Key="IconNotifications" Figures="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
<PathGeometry x:Key="IconMoreVert" Figures="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />

<!-- Payment-source icons, one per PaymentSourceType. See PaymentSourceIconConverter. -->
<PathGeometry x:Key="IconCreditCard" Figures="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z" />
Expand Down Expand Up @@ -506,4 +507,137 @@
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>

<!-- ===== Material 3 Expressive =====
Additive and keyed, never implicit: every style below is opted into by name, so adding them
changes nothing on a screen that does not ask for them. Categories is the first screen on
this treatment; the rest still use the Card/CardRow set above.

What makes it expressive rather than just rounded:
- Shape carries the hierarchy. Containers sit at the M3 "extra large" end (24-28) and
every row is its own container instead of hairline-separated rows inside one card.
- Section headings are large and sentence case. The older SectionHeading is an 11-13px
uppercase muted caption, which whispers; M3E wants the heading to be the anchor.
- Full-round shapes for anything interactive and small - icon containers, icon buttons,
the text field, the submit button.

Colours are the existing tokens. No hue is invented here: the categorical palette above is
validated for contrast and CVD separation, and a ninth colour added by eye would not be. -->

<!-- One row of a list. Its own container, so rows read as separate objects rather than as
entries in a table - the M3E list treatment. -->
<Style x:Key="M3ListItem" TargetType="Border">
<!-- SurfaceMutedDark in dark, not SurfaceDark: at #1C1C21 on a #111114 page the rows barely
separated from the background and the list read as vague blobs. Light needs no
equivalent - white on #F4F5F9 already lifts. -->
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource SurfaceLight}, Dark={StaticResource SurfaceMutedDark}}" />
<Setter Property="Stroke" Value="Transparent" />
<Setter Property="StrokeThickness" Value="0" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="StrokeShape">
<Setter.Value>
<RoundRectangle CornerRadius="24" />
</Setter.Value>
</Setter>
</Style>

<Style x:Key="M3SectionTitle" TargetType="Label">
<Setter Property="FontSize" Value="22" />
<Setter Property="FontAttributes" Value="Bold" />
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextStrongLight}, Dark={StaticResource TextStrongDark}}" />
</Style>

<!-- The count beside a section title. A pill rather than plain text, so the heading row has two
distinct shapes in it - shape variety is most of what reads as "expressive". -->
<Style x:Key="M3CountPill" TargetType="Border">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource BrandPurpleSoft}, Dark={StaticResource SurfaceMutedDark}}" />
<Setter Property="Stroke" Value="Transparent" />
<Setter Property="StrokeThickness" Value="0" />
<Setter Property="Padding" Value="12,5" />
<Setter Property="VerticalOptions" Value="Center" />
<Setter Property="StrokeShape">
<Setter.Value>
<RoundRectangle CornerRadius="16" />
</Setter.Value>
</Setter>
</Style>

<Style x:Key="M3CountPillText" TargetType="Label">
<Setter Property="FontSize" Value="12" />
<Setter Property="FontAttributes" Value="Bold" />
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource BrandPurple}, Dark={StaticResource BrandPurpleLight}}" />
</Style>

<!-- Fully round, unlike the squircle IconTile. At 48 a circle reads as an avatar for the thing
it labels, which is what a category tile is. -->
<Style x:Key="M3IconCircle" TargetType="Border">
<Setter Property="WidthRequest" Value="48" />
<Setter Property="HeightRequest" Value="48" />
<Setter Property="Padding" Value="0" />
<Setter Property="Stroke" Value="Transparent" />
<Setter Property="StrokeThickness" Value="0" />
<Setter Property="VerticalOptions" Value="Center" />
<Setter Property="StrokeShape">
<Setter.Value>
<RoundRectangle CornerRadius="24" />
</Setter.Value>
</Setter>
</Style>

<Style x:Key="M3IconCircleGlyph" TargetType="Path">
<Setter Property="Aspect" Value="Uniform" />
<Setter Property="HeightRequest" Value="23" />
<Setter Property="WidthRequest" Value="23" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="VerticalOptions" Value="Center" />
</Style>

<!-- Filled tonal icon button. A Border rather than a Button because a Button cannot host a Path,
and the icon set here is path geometry so it can take a theme-bound Fill. 44 square is the
accessible minimum tap target. -->
<Style x:Key="M3TonalIconButton" TargetType="Border">
<Setter Property="WidthRequest" Value="44" />
<Setter Property="HeightRequest" Value="44" />
<Setter Property="Padding" Value="0" />
<Setter Property="Stroke" Value="Transparent" />
<Setter Property="StrokeThickness" Value="0" />
<Setter Property="VerticalOptions" Value="Center" />
<!-- HairlineDark in dark, not SurfaceMutedDark: the row itself is now SurfaceMutedDark, so
the button had no edge against the row it sits on. -->
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource BrandPurpleSoft}, Dark={StaticResource HairlineDark}}" />
<Setter Property="StrokeShape">
<Setter.Value>
<RoundRectangle CornerRadius="22" />
</Setter.Value>
</Setter>
</Style>

<Style x:Key="M3TonalIconButtonGlyph" TargetType="Path">
<Setter Property="Aspect" Value="Uniform" />
<Setter Property="HeightRequest" Value="20" />
<Setter Property="WidthRequest" Value="20" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="VerticalOptions" Value="Center" />
<Setter Property="Fill" Value="{AppThemeBinding Light={StaticResource BrandPurple}, Dark={StaticResource BrandPurpleLight}}" />
</Style>

<!-- Filled text field: tonal fill, no outline, fully round. The outlined FieldBorder above is
the older treatment. -->
<Style x:Key="M3FilledField" TargetType="Border">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource SurfaceLight}, Dark={StaticResource SurfaceMutedDark}}" />
<Setter Property="Stroke" Value="Transparent" />
<Setter Property="StrokeThickness" Value="0" />
<Setter Property="Padding" Value="18,2" />
<Setter Property="StrokeShape">
<Setter.Value>
<RoundRectangle CornerRadius="28" />
</Setter.Value>
</Setter>
</Style>

<Style x:Key="M3PillButton" TargetType="Button" BasedOn="{StaticResource BaseButton}">
<Setter Property="CornerRadius" Value="28" />
<Setter Property="Padding" Value="26,14" />
<Setter Property="MinimumHeightRequest" Value="52" />
</Style>

</ResourceDictionary>
51 changes: 51 additions & 0 deletions src/SubVora.Mobile/Services/AnchorBounds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace SubVora.Mobile.Services;

/// <summary>
/// Screen position of a control, for anchoring a popup to it.
/// <para>
/// MAUI has no cross-platform "where is this element on screen" API - <c>VisualElement.Bounds</c> is
/// relative to the parent, which is useless inside a scrolled list. The platform view knows, so this
/// asks it.
/// </para>
/// </summary>
public static class AnchorBounds
{
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
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
}
}
13 changes: 13 additions & 0 deletions src/SubVora.Mobile/Services/IUserPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,17 @@ public interface IUserPrompt
/// </para>
/// </summary>
Task<string?> ActionSheetAsync(string title, string cancel, params string[] actions);

/// <summary>
/// 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.
/// <para>
/// 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
/// <see cref="ActionSheetAsync"/> 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.
/// </para>
/// </summary>
Rect? NextActionSheetAnchor { get; set; }
}
Loading
Loading