Migrate to Material 3 (full color roles + dynamic color) + fix FAB#56
Conversation
Move the theme stack from Theme.MaterialComponents.* to Theme.Material3.*: - base Theme.SagerNet (values + values-v26), Theme.SagerNet.Dialog, Theme.Start - widget parents: CardView -> Material3.CardView.Filled, Button, TextInputLayout OutlinedBox, PopupMenu(.Overflow), BottomSheet.Modal, BottomSheetDialog overlay, ShapeAppearance Small/Medium. Custom style *names* are unchanged (they're identifiers). - Material 1.12.0 accepts the existing M2 attrs (colorAccent/colorPrimaryDark/etc.), and the ~25 per-theme color variants + Dracula keep working. Fix a latent crash surfaced by M3: M3 wraps a View's Context in a ContextThemeWrapper, so / in StatsBar, ServiceButton and Dialogs threw ClassCastException on launch. Added Context.unwrap<T>() (walks the ContextWrapper.baseContext chain) and used it at those sites. Verified on-device: app launches; main list, Settings, profile Edit + input dialog, QR generation (MaterialAlertDialog), and the remove-confirm alert all render with no inflation or cast crashes; lintVitalOssRelease passes.
📝 WalkthroughWalkthroughAdds a ChangesContext.unwrap() helper and adoption
Material Components → Material 3 migration and dynamic color support
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
…fix invisible/untappable FAB M3 color, to the bone: - Apply Material 3 dynamic color in ThemedActivity. New DYNAMIC (Material You) theme option seeds the palette from the wallpaper on Android 12+; every other theme seeds a content-based M3 palette from its own colorPrimary, so all M3 roles (primaryContainer/ surfaceVariant/outline/...) are generated correctly on all API levels without hand- authoring 21 palettes. - Add DYNAMIC theme (id 24) to Theme.kt (activity + dialog mapping) and a picker swatch. FAB fix (regressed by M3 BottomAppBar dropping the FAB cradle): - The airplane FAB was declared BEFORE the StatsBar in the CoordinatorLayout, so the bar drew on top and intercepted its touches when connected (invisible + needed a swipe to reach). Reordered the FAB (and its progress) to draw AFTER the bar, and tint it with colorTertiaryContainer/onTertiaryContainer so it contrasts the colorPrimary bar. Verified on-device: launch/connect/disconnect/reconnect clean; the FAB is visible and disconnects on tap while connected without swiping; no crashes.
Finish the M3 transition started on this branch. The base theme was already Theme.Material3.DayNight; this fills in the full M3 color-role model and removes the remaining Material 2 widget styles, without breaking the hand-picked themes. Color roles: - Add colorPrimaryContainer/onPrimaryContainer, colorSecondary(+Container), colorTertiary(+Container), colorSurfaceVariant/onSurfaceVariant, colorOutline/outlineVariant to the base theme (values, values-v26, values-night). Each role derives from values the theme already owns so the standard themes keep their look and M3 widgets stop falling back to the library's generated grayscale tones. - Explicit role overrides for the special themes: Dracula (dark-canvas containers/surfaceVariant/outline), Black (near-black containers, no gray). Black-on-light accents (Yellow/Lime/Amber) keep correct contrast via the base defaults. - New colors: m3_outline (light + night), Dracula and Black container/ surface-variant/outline swatches. Widgets (Material 2 -> Material 3): - themes.xml: Chip.Choice -> Widget.Material3.Chip.Suggestion; MaterialAlertDialog overlay -> ThemeOverlay.Material3.MaterialAlertDialog; dialog TextButtons -> Widget.Material3.Button.TextButton.Dialog(.Flush); ActionBar.Solid -> Widget.Material3.ActionBar.Solid; add a DayNight-aware toolbar popup overlay. - 6 layouts: Chip.Filter, borderless buttons, and AppCompat/ MaterialComponents popup overlays -> their Material 3 equivalents. Verified on Android across the standard themes plus Dracula, Black, the black-on-light accents, and Dynamic, in light and dark, covering the profile list, nav drawer, settings, switches, cards, FAB, and the theme-picker dialog. No regressions beyond the intended slight M3 container/outline tones on selected cards.
|
Both Greptile "Comments Outside Diff" reference earlier commits in this PR and are resolved in the current tree (
Verified on-device across all themes (incl. Black day/night): FABs render with legible icons. CodeRabbit CLI on the latest commit reports no actionable findings. |
Update — explicit M3 color roles for the hand-picked themes
Follow-up commit completing the color-role layer so M3 widgets resolve real roles instead of the library's generated grayscale fallback, while keeping the hand-picked themes' look.
values,values-v26,values-night):colorPrimaryContainer/onPrimaryContainer,colorSecondary(+Container),colorTertiary(+Container),colorSurfaceVariant/onSurfaceVariant,colorOutline/outlineVariant. Each role derives from values the theme already owns, so the standard themes are visually stable.Chip.Choice→Widget.Material3.Chip.Suggestion, MaterialAlertDialog overlay →ThemeOverlay.Material3.MaterialAlertDialog, dialog TextButtons →Widget.Material3.Button.TextButton.Dialog(.Flush),ActionBar.Solid→ M3, plus theChip.Filter/borderless-button/popup overlays in 6 layouts. NoMaterialComponents/AppCompatwidget styles remain.m3_outline(light + night) and Dracula/Black container/surface-variant/outline swatches.Verification
Throwaway debug build, installed on Android. Cycled the standard themes plus Dracula, Black, Yellow/Lime/Amber, and Dynamic in light and dark, covering the profile list, nav drawer, settings, switches, cards, FAB, and the theme-picker dialog. No regressions beyond the intended slight M3 container/outline tones on selected cards. CodeRabbit CLI run: the 4 findings (1 critical, 3 major) were all addressed (added the missing
colorSurfaceVariantto all three base themes; confirmed the nightm3_outlinevalue is the correctcolorOutlinerole).Summary
Full Material 3 migration: theme stack + complete M3 color, plus a FAB visibility/touch fix.
Theme engine (M3)
M3 color, to the bone
Latent crash fixed (surfaced by M3)
M3 wraps a View's Context in a ContextThemeWrapper, breaking
context as MainActivity/as LifecycleOwnercasts (StatsBar, ServiceButton, Dialogs). AddedContext.unwrap<T>()(walks the ContextWrapper chain).FAB fix (invisible / untappable airplane when connected)
M3's BottomAppBar dropped the FAB cradle cutout, so the airplane (declared before the StatsBar) was drawn under + touch-intercepted by the bar when connected — you had to swipe the panel to reach it. Reordered the FAB to draw after the bar, and tinted it colorTertiaryContainer/onTertiaryContainer so it contrasts the colorPrimary bar.
Testing
Greptile Summary
This PR migrates the entire theme stack from Material Components (M2) to Material 3, adds a new "Dynamic (Material You)" theme option backed by
DynamicColors, and fixes a FAB visibility/touch bug caused by M3's removal of the BottomAppBar cradle cutout. It also patches latentClassCastExceptioncrashes inStatsBar,ServiceButton, andDialogsthat M3'sContextThemeWrapperwrapping surfaced.Theme.MaterialComponents.*→Theme.Material3.*), per-theme M3 color roles (colorPrimaryContainer,colorTertiaryContainer,colorSurfaceVariant,colorOutline, etc.) hand-authored for every palette to avoid the library's gray-toned fallbacks; Dracula and Black get explicit night/day overrides.ThemedActivitycallsDynamicColors.applyToActivityIfAvailablebeforesuper.onCreatewhen theme24is selected; FAB recolored with?attr/fabColorBackground/?attr/fabStoppedColorso each theme controls its own background/tint without layout hacks.StatsBarin the layout to win z-order;Widget.SagerNet.BottomAppBarrestores the M3 cradle cutout so the airplane icon stays visible and tappable while connected.Confidence Score: 5/5
The change is safe to merge; the M3 migration is thorough, the FAB z-order fix is correct, and the ClassCastException patches are sound.
All three previously-flagged concerns have been addressed in the current revision: the Black theme's FAB background was changed from the gray accent (#9E9E9E) to the near-black primary (#2B2B2B), eliminating the contrast issue;
applyStateTintusescolorOnPrimarywhich is the correct on-color for the?colorPrimaryFAB background used by all standard themes; and the doubleunwrap()call iniconConnectingwas replaced with a single capturedownervariable. The one remaining observation — that dialog-style activities are excluded from DynamicColors when the Dynamic theme is selected — is a UX inconsistency rather than a crash or data-loss path.No files require special attention.
ThemedActivity.ktis worth a second look if dialog-style activities are ever added, since they would silently show the static base theme when the Dynamic option is chosen.Important Files Changed
Context.unwrap<T>()extension that walks the ContextWrapper chain; clean implementation, correctly useserror()for impossible unwrap case.!isDialogguard means dialog-style activities won't receive Material You colors, creating a theme inconsistency for that option.context as LifecycleOwnercasts withcontext.unwrap<LifecycleOwner>();applyStateTintcorrectly usescolorOnPrimary(the on-color forfabColorBackground=?colorPrimary).context as MainActivitycasts withcontext.unwrap<MainActivity>(); straightforward crash fix.DYNAMIC = 24constant and maps it toTheme.SagerNet/Theme.SagerNet_Dialog; straightforward addition.fabAnchorMode=cradleand changesbackgroundTintto?attr/fabColorBackground.fabColorBackgroundchanged from gray accent to near-black primary (fixes FAB contrast). Extensive but logically consistent.Theme.Material3.DayNight.NoActionBar; M3 color roles added matching values/themes.xml.m3_outlinebase color.context as Activitycast withcontext.unwrap<Activity>()intryToShow().Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[ThemedActivity.onCreate] --> B{isDialog?} B -- No --> C[Theme.apply: setTheme activity style] B -- Yes --> D[Theme.applyDialog: setTheme dialog style] C --> E[Theme.applyNightTheme] D --> E E --> F{isDialog?} F -- No --> G{appTheme == DYNAMIC?} F -- Yes --> H[skip DynamicColors] G -- Yes --> I[DynamicColors.applyToActivityIfAvailable] G -- No --> J[keep hand-picked theme palette] I --> K[super.onCreate] J --> K H --> K K --> L[setContentView / inflate layout] L --> M[FAB background = fabColorBackground] M --> N{state change} N -- Connected --> O[applyStateTint: statusConnectedColor] N -- Stopped --> P[applyStateTint: fabStoppedColor] N -- Connecting/Stopping --> Q[applyStateTint: colorOnPrimary]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[ThemedActivity.onCreate] --> B{isDialog?} B -- No --> C[Theme.apply: setTheme activity style] B -- Yes --> D[Theme.applyDialog: setTheme dialog style] C --> E[Theme.applyNightTheme] D --> E E --> F{isDialog?} F -- No --> G{appTheme == DYNAMIC?} F -- Yes --> H[skip DynamicColors] G -- Yes --> I[DynamicColors.applyToActivityIfAvailable] G -- No --> J[keep hand-picked theme palette] I --> K[super.onCreate] J --> K H --> K K --> L[setContentView / inflate layout] L --> M[FAB background = fabColorBackground] M --> N{state change} N -- Connected --> O[applyStateTint: statusConnectedColor] N -- Stopped --> P[applyStateTint: fabStoppedColor] N -- Connecting/Stopping --> Q[applyStateTint: colorOnPrimary]Comments Outside Diff (2)
app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt, line 143-153 (link)The FAB background was changed from
?colorPrimaryto?attr/colorTertiaryContainerinlayout_main.xml, andapp:tint="?attr/colorOnTertiaryContainer"was added to the XML for the initial render. However,applyStateTint()overrides that tint on every state change: for non-Dracula themes, all states (stopped, connecting, stopping) resolve tocolorOnPrimary, which M3 only guarantees to contrast againstcolorPrimary— notcolorTertiaryContainer. In a content-based generated palette,colorTertiaryContaineris typically a light/pastel surface andcolorOnPrimaryis often white, giving near-zero contrast.colorOnTertiaryContaineris the M3-correct on-color for this background and should be used as the fallback here (and the corresponding theme attributesstatusConnectedColor/fabStoppedColor/speedTextColorshould default to?attr/colorOnTertiaryContainerfor non-Dracula themes).app/src/main/res/values/themes.xml, line 644 (link)Before this PR the FAB background was hardcoded to
?colorPrimaryin the layout, so the Black theme'sfabColorBackground = color_ng_black_accent(#9E9E9E, medium gray) was never applied. Now that the layout reads?attr/fabColorBackground, that override kicks in. However, the icon tint for every state resolves tocolorOnPrimary = white(inherited — Black theme doesn't override it), giving a white icon on #9E9E9E. That is roughly 2.6:1 contrast, below the 3:1 WCAG AA minimum for interactive UI icons.The transient states (
Connecting/Stopping) are the hardest to fix becauseapplyStateTintusescom.google.android.material.R.attr.colorOnPrimarydirectly and bypassesfabStoppedColor. The cleanest options are:fabStoppedColor = @color/blackandstatusConnectedColor = @color/blackto the Black theme, and change theelsebranch inapplyStateTintto also resolve viaR.attr.fabStoppedColorso all four states are theme-controllable.?colorPrimary(#2B2B2B) and relies on the cradle cutout for visual separation from the bar.Reviews (6): Last reviewed commit: "feat(theme): complete Material 3 color-r..." | Re-trigger Greptile