Skip to content

feat(mobile): Material 3 Expressive Categories, anchored row menu, and colourful tabs - #182

Merged
rghvgrv merged 7 commits into
mainfrom
feat/categories-material-expressive
Aug 11, 2026
Merged

rghvgrv merged 7 commits into
mainfrom
feat/categories-material-expressive

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Mobile presentation work on Categories, the row menu it opens, and the tab bar. No API, schema or migration change; IUserPrompt gains one property and everything else is markup, styles and assets.

Every change below was checked on a physical device, not just compiled — several of these were only found by looking at the screen.


1. Categories: swipe gone, Material 3 Expressive

Swipe-to-rename/delete is removed, along with the caption that explained it. The row's ⋮ — added in #172 because the gesture advertised itself to nobody — is the only route now. RenameCommand/DeleteCommand are untouched; ManageCommand still calls them.

HasManageableCategories existed only to gate that caption, so it went with it.

New keyed styles in Theme.xaml, additive and opted into by name — no other screen changed:

Before Now
List hairline rows in one card each row its own 24-radius container, 8dp apart
Heading 13px uppercase muted caption 22px bold sentence case + tonal count pill
Category icon 42 squircle 48 circle
Menu button ••• text Button 44 circular tonal container, real Material more_vert path
Text field / Add outlined r12 / default filled r28 / pill r28

Two constraints held deliberately:

  • No colour invented. The categorical palette is validated for contrast and CVD separation; a ninth hue picked by eye would not be.
  • The glyph is path geometry, not the string •••, so its fill is theme-bound like every other icon. A Button cannot host a Path, hence Border + TapGestureRecognizer — the pattern SubscriptionListPage already uses.

Also visible in testing: #173 working in the wild — system "Other" now draws its own tray glyph, where it and a user category used to be pixel-identical.

2. The row menu: platform action sheet → anchored dropdown

DisplayActionSheetAsync 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.

This took three passes, each correcting something only visible on screen:

  1. Bottom sheet. Full-width, top-rounded, drag handle. Too heavy for two choices.
  2. Compact card. 212 wide, all four corners, bare glyphs. Better — but it opened in the top-right corner regardless of which row was tapped, so it read as unrelated to the row it acted on. It also covered the app bar: the popup overlay spans the whole window, status bar included, so a 12pt top margin drew it over the header.
  3. Anchored. It now hangs off the button that opened it.

Anchoring needed real plumbing, because neither library offers it:

  • CommunityToolkit's Popup exposes only Margin and two alignment properties — no anchor.
  • MAUI has no cross-platform "where is this element on screen"; VisualElement.Bounds is parent-relative, useless inside a scrolled list.

So AnchorBounds asks the platform view (GetLocationOnScreen on Android, pixels → DIPs) and returns null elsewhere, where the fixed-corner fallback still applies.

The position travels view → dialog service, never through the view model. The page is the only layer that can see the button; handing a VisualElement to a view model would put layout inside the layer that is unit-tested without one. IUserPrompt gains a write-then-consume NextActionSheetAnchor, cleared on read so a stale position cannot leak into a later menu.

Positioning: right edges aligned (the button sits at the right end of its row, so a left-aligned menu would run off-screen), clamped to the screen on both axes, and flipped to open upwards when a row near the bottom leaves no room below.

One more thing only the device showed: a single Border cannot carry both the shadow and the rounded shape — the shadow is drawn against square bounds and flattens the corners. Split into an outer shadow Border and an inner shape Border.

Applied to Payment sources too, so the same control behaves the same way on both screens.

3. Payment: hint removed, tab bar coloured

The Payment hint is gone, matching Categories; HasPaymentSources went with it.

Each tab icon now carries a hue — Home blue, Subs teal, Categories purple, Payment amber, Settings grey.

Colouring the SVGs was only half of it. Android's BottomNavigationView applies an ItemIconTintList built from Shell's TabBarForegroundColor/TabBarUnselectedColor, and that tint replaces the icon's own colour — all five rendered as one hue whatever the SVGs said. ColorfulTabsShellRenderer clears the tint list after base.SetAppearance.

  • Icons only. Labels still take TabBarTitleColor, so the active tab is marked by its text turning purple. With every icon coloured, icon colour can no longer say which tab is selected, and nothing else would.
  • One value per icon, since an SVG cannot hold an AppThemeBinding — each hue is a mid-tone legible on both the light and dark tab bar.

Verified on device

Xiaomi M2101K6P, Android 13. Driven directly rather than by eye:

Check Result
Categories ⋮ → menu anchors under that row's button ✅
Payment ⋮ → same, anchored to its row ✅
Rename → prompt pre-filled with the name ✅
Delete → confirmation first ✅
Cancel → Canara, Gpay, Phonepay all still present ✅
Tap outside → dismisses, nothing renamed or deleted ✅
Five tab colours render together, selected tab still distinguishable ✅
Crash buffer across the whole session empty

The Delete path matters most: the menu returning null on dismissal must never fall through to an action. It doesn't.

Tests

SubVora.Mobile.Tests — 302 passed, 0 failed.

No new tests. This is presentation plus one platform tint override; the behaviour behind the button (ManageCommand → rename/delete, hidden on system rows, dismissal does nothing) is already pinned by ManageActionTests and CategoryGroupingTests, which pass unchanged. Two tests were removed alongside the two properties they covered. XAML is compile-checked by the Windows-TFM build via MauiXamlInflator=SourceGen, so a mistyped StaticResource fails the build.

Backend untouched.

Not done

  • Flip-upwards is untested. The code opens the menu above the button when a row sits too low; neither list was long enough to trigger it.
  • Payment sources keeps swipe and the older row styling — only Categories got the Expressive treatment. Worth deciding whether to match them.

rghvgrv and others added 2 commits August 11, 2026 17:24
…essive

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) <noreply@anthropic.com>
…tom sheet

Tapping the row menu opened DisplayActionSheetAsync - an unstyled list of
strings in a system alert. It takes none of the app's shape, colour or type
scale, so next to the screen that opened it, it read as a different
application.

Replaced with a bottom sheet built from the app's own tokens: rounded top
corners, a drag handle, and one tappable rounded container per action
carrying its icon. Delete is drawn in the danger colour with a tinted
container rather than sitting in a list looking identical to Rename.

Behind IUserPrompt.ActionSheetAsync, whose signature is unchanged. That is
the seam the view models are tested against, so ManageCommand, both callers
and every existing test are untouched - only ShellUserPrompt's
implementation changed.

Uses CommunityToolkit.Maui's Popup<T>, already a dependency. No new package.

The cancel label is now unused: the sheet is dismissed by tapping outside,
which is the Material pattern and is what the null return already meant. It
stays in the signature rather than churning every caller for no behavioural
gain, and a platform needing an explicit cancel row can still use it.

Rows are built in code because the caller passes plain strings, which also
means they cannot use AppThemeBinding markup and resolve their own light/dark
token pair. An action with no icon mapping gets a text-only row rather than a
guessed glyph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rghvgrv rghvgrv self-assigned this Aug 11, 2026
rghvgrv and others added 4 commits August 11, 2026 17:51
…sheet

A full-width sheet anchored to the bottom edge is a heavy shape for two
choices, and it was reading as a modal decision rather than a row-level
menu. Replaced with a small menu card - the shape a "..." button is expected
to open.

  - 232 wide, right-aligned near the top, rather than edge to edge.
  - All four corners rounded (18) with its own shadow, rather than top-only
    corners flush to the screen edge.
  - Bare 19px glyphs instead of 40px tinted circles, which at menu scale
    were most of the row's height.
  - Scrim down from 0.45 to 0.2: a menu should not hide the row it belongs
    to, which is the only thing tying the two together.
  - Drag handle gone; it belonged to the sheet.

Still not anchored to the button that opened it. CommunityToolkit's Popup
exposes only Margin and the two alignment properties - there is 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, which is what makes
it legible without the anchor.

IUserPrompt is unchanged, so ManageCommand, both callers and all 303 mobile
tests are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified on a device this time, which is how both of these were found.

The popup overlay spans the whole window - status bar and app bar included -
so a 12pt top margin drew the menu on top of the purple "Categories" bar.
Now 88: 24 for the status bar, 56 for the app bar, then a gap.

The card is also nested in a second, transparent Border that carries the
shadow. On one Border the shadow is drawn against the square bounds and the
rounded corners stop reading as rounded at all.

Tightened to sit closer to a menu than a dialog: 212 wide, radius 16, rows
at 10x9 padding with 17px glyphs and 14.5pt labels.

Confirmed end to end on the handset: the menu opens clear of the app bar,
Rename opens the rename prompt pre-filled with the category name, tapping
outside dismisses without renaming or deleting anything, and the crash
buffer stays empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu opened in the top-right corner regardless of which row was tapped,
so it read as unrelated to the row it acted on.

CommunityToolkit's Popup has no anchor, and MAUI has no cross-platform "where
is this element on screen" - VisualElement.Bounds is parent-relative, which is
useless inside a scrolled list. AnchorBounds asks the platform view instead
(GetLocationOnScreen on Android, converted from pixels to DIPs) and returns
null anywhere else, where the menu keeps the old fixed-corner fallback rather
than guessing.

The position travels view -> dialog service, not through the view model. The
page is the only layer that can see where the button is, and handing it a
VisualElement would put layout inside the layer that is unit-tested without
one. IUserPrompt gains a write-then-consume NextActionSheetAnchor, cleared as
soon as it is read so a later unanchored menu cannot inherit it.

ActionSheetPopup then hangs the card off the button: right edges aligned,
since the button sits at the right end of its row and a left-aligned menu
would run off-screen; clamped to the screen on both axes; and flipped to open
upwards when a row near the bottom leaves no room below.

Both Categories and Payment sources, so the same control behaves the same way
on both screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@rghvgrv rghvgrv changed the title feat(mobile): drop swipe on Categories and restyle it Material 3 Expressive feat(mobile): Material 3 Expressive Categories, anchored row menu, and colourful tabs Aug 11, 2026
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) <noreply@anthropic.com>
@rghvgrv
rghvgrv merged commit df67f88 into main Aug 11, 2026
4 checks passed
@rghvgrv
rghvgrv deleted the feat/categories-material-expressive branch August 11, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant