Improve theme uniformity across OEM devices

- Make semantic colors and the page indicator theme-aware so they keep
  adequate contrast in dark mode instead of using fixed values.
- Seed a full brand color scheme (#6C63FF) for devices without dynamic
  color (Android 11 and below) instead of falling back to Material's
  purple baseline; align the XML AppTheme accent accordingly.
- Stop manually forcing transparent system bar colors in the Compose
  theme; those setters are no-ops on Android 15+ and stripped the system
  contrast scrim, causing the half-gray navigation bar on One UI. Keep
  syncing the bar icon appearance with the active theme.
- Add a dynamic-color opt-out toggle (Android 12+), defaulting off on
  One UI where Samsung's palette extraction tends to look off.
This commit is contained in:
Rahul Patel
2026-06-03 00:12:43 +05:30
parent 98093c8f13
commit 2503a01182
10 changed files with 191 additions and 33 deletions

View File

@@ -68,6 +68,10 @@ val isHuawei: Boolean
Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") ||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("hi3")
val isOneUI: Boolean
get() = !getSystemProperty("ro.build.version.oneui").isNullOrBlank() ||
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
@get:SuppressLint("PrivateApi")
val isMiuiOptimizationDisabled: Boolean
get() {

View File

@@ -85,8 +85,13 @@ class AuroraApp : Application(), Configuration.Provider, SingletonImageLoader.Fa
val themeStyle = Preferences.getInteger(this, Preferences.PREFERENCE_THEME_STYLE)
setAppTheme(themeStyle)
// Apply dynamic colors to activities
DynamicColors.applyToActivitiesIfAvailable(this)
// Apply dynamic colors to activities, unless disabled (opt-out, off by default on One UI)
val dynamicColors = Preferences.getBoolean(
this,
Preferences.PREFERENCE_DYNAMIC_COLORS,
Preferences.dynamicColorsDefault
)
if (dynamicColors) DynamicColors.applyToActivitiesIfAvailable(this)
// Required for Shizuku installer
if (isPAndAbove) HiddenApiBypass.addHiddenApiExemptions("I", "L")

View File

@@ -15,12 +15,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
@@ -47,15 +47,13 @@ fun PageIndicator(modifier: Modifier = Modifier, totalPages: Int, currentPage: I
Alignment.CenterHorizontally
)
) {
val selectedColor = MaterialTheme.colorScheme.primary
val unselectedColor = MaterialTheme.colorScheme.outlineVariant
repeat(totalPages) { iteration ->
val page = stringResource(R.string.page, iteration)
val isSelected = currentPage == iteration
val color by animateColorAsState(
targetValue = if (isSelected) {
Color.DarkGray
} else {
Color.LightGray
},
targetValue = if (isSelected) selectedColor else unselectedColor,
animationSpec = tween()
)
val size by animateDpAsState(

View File

@@ -5,10 +5,101 @@
package com.aurora.store.compose.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
val warningColor = Color(0xFFFF7600) // Amber
val successColor = Color(0xFF1B8738) // Green
/**
* Whether the active [MaterialTheme] is dark.
*
* Derived from the resolved color scheme rather than [androidx.compose.foundation.isSystemInDarkTheme]
* so it stays correct when the user forces a light/dark theme that differs from the system setting.
*/
@Composable
@ReadOnlyComposable
private fun isAppInDarkTheme(): Boolean = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val colorGreen = Color(0xFF4CAF50)
val colorRed = Color(0xFFF44336)
/**
* Amber used to flag warnings/caveats. Lightened in dark theme for adequate contrast.
*/
val warningColor: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFFF7600)
/**
* Green used to flag positive/success states. Lightened in dark theme for adequate contrast.
*/
val successColor: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFF5BD27A) else Color(0xFF1B8738)
val colorGreen: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFF81C784) else Color(0xFF388E3C)
val colorRed: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFFE57373) else Color(0xFFD32F2F)
/**
* Brand color schemes seeded from Aurora's accent (#6C63FF), used on devices that don't support
* dynamic color (Android 11 and below) so the full palette stays on-brand instead of falling back
* to Material's default purple baseline.
*/
val BrandLightColorScheme = lightColorScheme(
primary = Color(0xFF6C63FF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFE4DFFF),
onPrimaryContainer = Color(0xFF1A0066),
secondary = Color(0xFF5C5D72),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE1E0F9),
onSecondaryContainer = Color(0xFF191A2C),
tertiary = Color(0xFF78536B),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFFD8EE),
onTertiaryContainer = Color(0xFF2E1126),
background = Color(0xFFFCF8FF),
onBackground = Color(0xFF1B1B21),
surface = Color(0xFFFCF8FF),
onSurface = Color(0xFF1B1B21),
surfaceVariant = Color(0xFFE4E1EC),
onSurfaceVariant = Color(0xFF47464F),
outline = Color(0xFF777680),
outlineVariant = Color(0xFFC8C5D0),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002)
)
val BrandDarkColorScheme = darkColorScheme(
primary = Color(0xFFC8BFFF),
onPrimary = Color(0xFF31149C),
primaryContainer = Color(0xFF534BD6),
onPrimaryContainer = Color(0xFFE4DFFF),
secondary = Color(0xFFC5C4DD),
onSecondary = Color(0xFF2E2F42),
secondaryContainer = Color(0xFF444559),
onSecondaryContainer = Color(0xFFE1E0F9),
tertiary = Color(0xFFE8B9D5),
onTertiary = Color(0xFF46263B),
tertiaryContainer = Color(0xFF5E3C52),
onTertiaryContainer = Color(0xFFFFD8EE),
background = Color(0xFF131318),
onBackground = Color(0xFFE4E1E9),
surface = Color(0xFF131318),
onSurface = Color(0xFFE4E1E9),
surfaceVariant = Color(0xFF47464F),
onSurfaceVariant = Color(0xFFC8C5D0),
outline = Color(0xFF918F9A),
outlineVariant = Color(0xFF47464F),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6)
)

View File

@@ -11,27 +11,23 @@ import android.os.Build
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.colorResource
import androidx.core.view.WindowCompat
import com.aurora.store.R
import com.aurora.store.util.Preferences
/**
* App theme for Aurora Store based on [MaterialTheme]
* App theme for Aurora Store based on [MaterialExpressiveTheme]
*/
@Composable
fun AuroraTheme(content: @Composable () -> Unit) {
@@ -40,28 +36,42 @@ fun AuroraTheme(content: @Composable () -> Unit) {
var themeStyle by remember {
mutableIntStateOf(Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE))
}
var dynamicColor by remember {
mutableStateOf(
Preferences.getBoolean(
context,
Preferences.PREFERENCE_DYNAMIC_COLORS,
Preferences.dynamicColorsDefault
)
)
}
DisposableEffect(Unit) {
val prefs = Preferences.getPrefs(context)
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == Preferences.PREFERENCE_THEME_STYLE) {
themeStyle = Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE)
when (key) {
Preferences.PREFERENCE_THEME_STYLE ->
themeStyle = Preferences.getInteger(context, key)
Preferences.PREFERENCE_DYNAMIC_COLORS ->
dynamicColor =
Preferences.getBoolean(context, key, Preferences.dynamicColorsDefault)
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
}
val isDynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val useDynamicColor = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val lightScheme = if (isDynamicColorSupported) {
val lightScheme = if (useDynamicColor) {
dynamicLightColorScheme(context)
} else {
lightColorScheme(primary = colorResource(id = R.color.colorAccent))
BrandLightColorScheme
}
val darkScheme = if (isDynamicColorSupported) {
val darkScheme = if (useDynamicColor) {
dynamicDarkColorScheme(context)
} else {
darkColorScheme(primary = colorResource(id = R.color.colorAccent))
BrandDarkColorScheme
}
val colorScheme = when (themeStyle) {
@@ -77,21 +87,22 @@ fun AuroraTheme(content: @Composable () -> Unit) {
}
/**
* Side effect to update the system bars to be transparent and match the theme's light/dark mode.
* This is necessary on OEM devices that don't properly support dynamic theming and may have issues with light/dark status bar icons.
* Keep the status/navigation bar icon appearance in sync with the theme's light/dark mode.
*
* The bars themselves are kept transparent and edge-to-edge by [androidx.activity.enableEdgeToEdge]
* (called once in the host activity); we deliberately don't touch `window.statusBarColor` /
* `navigationBarColor` here since those setters are deprecated no-ops on Android 15+ and would
* otherwise strip the system contrast scrim. Re-applying the appearance is still required because
* the user can force a light/dark theme that differs from the system, and that isn't known when
* edge-to-edge is first configured.
*/
val view = LocalView.current
val activity = LocalActivity.current
if (!view.isInEditMode) {
SideEffect {
val currentActivity = activity ?: return@SideEffect
val window = currentActivity.window
window.statusBarColor = android.graphics.Color.TRANSPARENT
window.navigationBarColor = android.graphics.Color.TRANSPARENT
WindowCompat
.getInsetsController(window, view)
.getInsetsController(currentActivity.window, view)
.apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.core.net.toUri
import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.isTAndAbove
import com.aurora.extensions.setAppTheme
import com.aurora.store.R
@@ -38,6 +39,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_DEFAULT_SELECTED_TAB
import com.aurora.store.util.Preferences.PREFERENCE_DYNAMIC_COLORS
import com.aurora.store.util.Preferences.PREFERENCE_FOR_YOU
import com.aurora.store.util.Preferences.PREFERENCE_THEME_STYLE
import com.aurora.store.util.save
@@ -62,6 +64,15 @@ private fun ScreenContent() {
var forYou by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_FOR_YOU, true))
}
var dynamicColors by remember {
mutableStateOf(
Preferences.getBoolean(
context,
PREFERENCE_DYNAMIC_COLORS,
Preferences.dynamicColorsDefault
)
)
}
var showThemeDialog by remember { mutableStateOf(false) }
var showTabDialog by remember { mutableStateOf(false) }
@@ -132,6 +143,29 @@ private fun ScreenContent() {
supportingContent = { Text(themeEntries.getOrElse(themeStyle) { "" }) }
)
}
if (isSAndAbove) {
item {
ListItem(
modifier = Modifier.clickable {
dynamicColors = !dynamicColors
context.save(PREFERENCE_DYNAMIC_COLORS, dynamicColors)
},
headlineContent = { Text(stringResource(R.string.pref_ui_dynamic_color)) },
supportingContent = {
Text(stringResource(R.string.pref_ui_dynamic_color_desc))
},
trailingContent = {
Switch(
checked = dynamicColors,
onCheckedChange = { checked ->
dynamicColors = checked
context.save(PREFERENCE_DYNAMIC_COLORS, checked)
}
)
}
)
}
}
item { HorizontalDivider() }
item {
ListItem(headlineContent = { Text(stringResource(R.string.pref_ui_layout)) })

View File

@@ -24,15 +24,24 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.aurora.extensions.isOneUI
import com.aurora.store.BuildConfig
object Preferences {
const val PREFERENCE_DEFAULT = "PREFERENCE_DEFAULT"
/**
* Default for [PREFERENCE_DYNAMIC_COLORS]. Dynamic color is opt-out everywhere except One UI,
* where Samsung's palette extraction tends to look off, so it defaults off and users can opt in.
*/
val dynamicColorsDefault: Boolean
get() = !isOneUI
const val PREFERENCE_AUTH_DATA = "PREFERENCE_AUTH_DATA"
const val PREFERENCE_INSTALLER_ID = "PREFERENCE_INSTALLER_ID"
const val PREFERENCE_THEME_STYLE = "PREFERENCE_THEME_STYLE"
const val PREFERENCE_DYNAMIC_COLORS = "PREFERENCE_DYNAMIC_COLORS"
const val PREFERENCE_FOR_YOU = "PREFERENCE_FOR_YOU"
const val PREFERENCE_DEFAULT_SELECTED_TAB = "PREFERENCE_DEFAULT_SELECTED_TAB"
const val PREFERENCE_INTRO = "PREFERENCE_INTRO"

View File

@@ -4,5 +4,7 @@
<style name="AppTheme" parent="Theme.Material3.DynamicColors.Dark.NoActionBar">
<item name="chipStyle">@style/Chip.Filter</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Brand accent for devices without dynamic color (Android 11 and below) -->
<item name="colorPrimary">@color/colorAccent</item>
</style>
</resources>

View File

@@ -414,6 +414,8 @@
<string name="pref_theme_style_system">Follow system</string>
<string name="pref_theme_style_light">Light</string>
<string name="pref_theme_style_dark">Dark</string>
<string name="pref_ui_dynamic_color">Dynamic colors</string>
<string name="pref_ui_dynamic_color_desc">Use colors from your wallpaper. Turn off for Aurora\'s brand palette.</string>
<string name="pref_ui_layout">Layout</string>
<string name="pref_ui_layout_tab">Select default tab</string>
<string name="app_links_title">App links</string>

View File

@@ -23,6 +23,8 @@
<item name="chipStyle">@style/Chip.Filter</item>
<item name="bottomSheetStyle">@style/AppTheme.BottomSheetStyle</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Brand accent for devices without dynamic color (Android 11 and below) -->
<item name="colorPrimary">@color/colorAccent</item>
</style>
<style name="AppTheme.Translucent" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">