feat(mobile): Material 3 Expressive Categories, anchored row menu, and colourful tabs - #182
Merged
Merged
Conversation
…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>
…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>
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>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mobile presentation work on Categories, the row menu it opens, and the tab bar. No API, schema or migration change;
IUserPromptgains 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/DeleteCommandare untouched;ManageCommandstill calls them.HasManageableCategoriesexisted 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:•••textButtonmore_vertpathTwo constraints held deliberately:
•••, so its fill is theme-bound like every other icon. AButtoncannot host aPath, henceBorder+TapGestureRecognizer— the patternSubscriptionListPagealready 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
DisplayActionSheetAsyncis 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:
Anchoring needed real plumbing, because neither library offers it:
Popupexposes onlyMarginand two alignment properties — no anchor.VisualElement.Boundsis parent-relative, useless inside a scrolled list.So
AnchorBoundsasks the platform view (GetLocationOnScreenon 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
VisualElementto a view model would put layout inside the layer that is unit-tested without one.IUserPromptgains a write-then-consumeNextActionSheetAnchor, 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
Bordercannot carry both the shadow and the rounded shape — the shadow is drawn against square bounds and flattens the corners. Split into an outer shadowBorderand an inner shapeBorder.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;
HasPaymentSourceswent 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
BottomNavigationViewapplies anItemIconTintListbuilt from Shell'sTabBarForegroundColor/TabBarUnselectedColor, and that tint replaces the icon's own colour — all five rendered as one hue whatever the SVGs said.ColorfulTabsShellRendererclears the tint list afterbase.SetAppearance.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.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:
⋮→ menu anchors under that row's button⋮→ same, anchored to its rowThe Delete path matters most: the menu returning
nullon 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 byManageActionTestsandCategoryGroupingTests, which pass unchanged. Two tests were removed alongside the two properties they covered. XAML is compile-checked by the Windows-TFM build viaMauiXamlInflator=SourceGen, so a mistypedStaticResourcefails the build.Backend untouched.
Not done