feat: theme colour overrides

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-31 23:52:16 +01:00
parent 9bbc8f513c
commit 35c8976e30
10 changed files with 453 additions and 41 deletions

View File

@ -168,6 +168,7 @@ sentry {
dependencies { dependencies {
// Android/Kotlin Core // Android/Kotlin Core
implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.core:core-ktx:1.10.1'
implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.10"
// Kotlinx - various first-party extensions for Kotlin // Kotlinx - various first-party extensions for Kotlin
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
@ -254,6 +255,9 @@ dependencies {
implementation "androidx.media3:media3-datasource-okhttp:$media3_version" implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version" implementation "androidx.media3:media3-ui:$media3_version"
// Colour picker
implementation "com.github.skydoves:colorpicker-compose:1.0.5"
// Debug-only dependencies // Debug-only dependencies
// LeakCanary - memory leak detection // LeakCanary - memory leak detection

View File

@ -56,6 +56,7 @@ import chat.revolt.api.schemas.Invite
import chat.revolt.api.schemas.InviteJoined import chat.revolt.api.schemas.InviteJoined
import chat.revolt.api.schemas.RsResult import chat.revolt.api.schemas.RsResult
import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.generic.IconPlaceholder import chat.revolt.components.generic.IconPlaceholder
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.ui.theme.RevoltTheme import chat.revolt.ui.theme.RevoltTheme
@ -143,7 +144,10 @@ fun InviteScreen(
val inviteValid = if (viewModel.loadingFinished) (viewModel.inviteResult?.ok ?: false) else null val inviteValid = if (viewModel.loadingFinished) (viewModel.inviteResult?.ok ?: false) else null
val invite = viewModel.inviteResult?.value val invite = viewModel.inviteResult?.value
RevoltTheme(requestedTheme = GlobalState.theme) { RevoltTheme(
requestedTheme = GlobalState.theme,
colourOverrides = SyncedSettings.android.colourOverrides
) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)

View File

@ -26,6 +26,7 @@ import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import chat.revolt.BuildConfig import chat.revolt.BuildConfig
import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.ndk.NativeLibraries import chat.revolt.ndk.NativeLibraries
import chat.revolt.screens.SplashScreen import chat.revolt.screens.SplashScreen
import chat.revolt.screens.about.AboutScreen import chat.revolt.screens.about.AboutScreen
@ -86,7 +87,8 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) {
val navController = rememberNavController() val navController = rememberNavController()
RevoltTheme( RevoltTheme(
requestedTheme = GlobalState.theme requestedTheme = GlobalState.theme,
colourOverrides = SyncedSettings.android.colourOverrides
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View File

@ -45,6 +45,7 @@ import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltHttp
import chat.revolt.api.schemas.AutumnResource import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.generic.PageHeader import chat.revolt.components.generic.PageHeader
import chat.revolt.provider.getAttachmentContentUri import chat.revolt.provider.getAttachmentContentUri
import chat.revolt.ui.theme.RevoltTheme import chat.revolt.ui.theme.RevoltTheme
@ -177,7 +178,10 @@ fun ImageViewScreen(resource: AutumnResource, onClose: () -> Unit = {}) {
} }
} }
RevoltTheme(requestedTheme = GlobalState.theme) { RevoltTheme(
requestedTheme = GlobalState.theme,
colourOverrides = SyncedSettings.android.colourOverrides
) {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) } snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { pv -> ) { pv ->

View File

@ -49,6 +49,7 @@ import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltHttp
import chat.revolt.api.schemas.AutumnResource import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.generic.PageHeader import chat.revolt.components.generic.PageHeader
import chat.revolt.provider.getAttachmentContentUri import chat.revolt.provider.getAttachmentContentUri
import chat.revolt.ui.theme.RevoltTheme import chat.revolt.ui.theme.RevoltTheme
@ -193,7 +194,10 @@ fun VideoViewScreen(resource: AutumnResource, onClose: () -> Unit = {}) {
} }
} }
RevoltTheme(requestedTheme = GlobalState.theme) { RevoltTheme(
requestedTheme = GlobalState.theme,
colourOverrides = SyncedSettings.android.colourOverrides
) {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) } snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { pv -> ) { pv ->

View File

@ -13,5 +13,10 @@ data class AndroidSpecificSettings(
* The theme to use for the app. * The theme to use for the app.
* Can be one of `{ None, Revolt, Light, M3Dynamic, Amoled }` * Can be one of `{ None, Revolt, Light, M3Dynamic, Amoled }`
*/ */
var theme: String? = null var theme: String? = null,
/**
* Colour overrides.
* Map of `primary, onPrimary, primaryContainer, onPrimaryContainer, inversePrimary, secondary, onSecondary, secondaryContainer, onSecondaryContainer, tertiary, onTertiary, tertiaryContainer, onTertiaryContainer, background, onBackground, surface, onSurface, surfaceVariant, onSurfaceVariant, surfaceTint, inverseSurface, inverseOnSurface, error, onError, errorContainer, onErrorContainer, outline, outlineVariant, scrim` to int colours.
*/
var colourOverrides: Map<String, Int>? = null,
) )

View File

@ -2,10 +2,16 @@ package chat.revolt.components.screens.settings.appearance
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -13,14 +19,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
fun ThemeChip( fun ColourChip(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: Color, color: Color,
text: String, text: String,
selected: Boolean = false, selected: Boolean = false,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Column( Row(
Modifier Modifier
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.clickable(onClick = onClick) .clickable(onClick = onClick)
@ -33,19 +39,21 @@ fun ThemeChip(
Modifier Modifier
} }
) )
.padding(4.dp) .padding(8.dp),
.padding(8.dp) verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(color) .background(color)
.height(60.dp) .height(48.dp)
.fillMaxWidth(1f) .width(48.dp)
) )
Spacer(Modifier.width(16.dp))
Text( Text(
text = text, text = text,
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge
) )
} }
@ -54,7 +62,7 @@ fun ThemeChip(
@Preview @Preview
@Composable @Composable
fun SelectedThemeChipPreview() { fun SelectedThemeChipPreview() {
ThemeChip( ColourChip(
color = Color.Red, color = Color.Red,
text = "Red", text = "Red",
selected = true, selected = true,

View File

@ -1,25 +1,58 @@
package chat.revolt.screens.settings package chat.revolt.screens.settings
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material3.Button
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -29,32 +62,107 @@ import chat.revolt.R
import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.generic.PageHeader import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.settings.appearance.ThemeChip import chat.revolt.components.screens.settings.appearance.ColourChip
import chat.revolt.ui.theme.ClearRippleTheme
import chat.revolt.ui.theme.Theme import chat.revolt.ui.theme.Theme
import chat.revolt.ui.theme.systemSupportsDynamicColors import chat.revolt.ui.theme.systemSupportsDynamicColors
import com.github.skydoves.colorpicker.compose.AlphaSlider
import com.github.skydoves.colorpicker.compose.BrightnessSlider
import com.github.skydoves.colorpicker.compose.ColorEnvelope
import com.github.skydoves.colorpicker.compose.HsvColorPicker
import com.github.skydoves.colorpicker.compose.rememberColorPickerController
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.reflect.KVisibility
import kotlin.reflect.full.memberProperties
class AppearanceSettingsScreenViewModel : ViewModel() { class AppearanceSettingsScreenViewModel : ViewModel() {
var showColourOverrides by mutableStateOf(false)
var selectedOverrideName by mutableStateOf<String?>(null)
var selectedOverrideInitialValue by mutableStateOf<Int?>(null)
var overridePickerSheetVisible by mutableStateOf(false)
fun saveNewTheme(theme: Theme) { fun saveNewTheme(theme: Theme) {
GlobalState.theme = theme
viewModelScope.launch { viewModelScope.launch {
val android = SyncedSettings.android SyncedSettings.updateAndroid(SyncedSettings.android.copy(theme = theme.name))
android.theme = theme.toString() }
SyncedSettings.updateAndroid(android) }
fun updateColourOverrides(fieldName: String, value: Int?) {
viewModelScope.launch {
val overrides = SyncedSettings.android.copy().colourOverrides
if (overrides != null) {
val mutOverrides = overrides.toMutableMap()
if (value == null) {
mutOverrides.remove(fieldName)
} else {
mutOverrides[fieldName] = value
}
SyncedSettings.updateAndroid(
SyncedSettings.android.copy(
colourOverrides = mutOverrides
)
)
} else if (value != null) {
SyncedSettings.updateAndroid(
SyncedSettings.android.copy(
colourOverrides = mapOf(
fieldName to value
)
)
)
}
} }
} }
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppearanceSettingsScreen( fun AppearanceSettingsScreen(
navController: NavController, navController: NavController,
viewModel: AppearanceSettingsScreenViewModel = viewModel() viewModel: AppearanceSettingsScreenViewModel = viewModel()
) { ) {
val context = LocalContext.current val colourOverridesOpenerArrowRotation by animateFloatAsState(
if (viewModel.showColourOverrides) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) 90f else -90f
} else 0f,
label = "colourOverridesOpenerArrowRotation"
)
fun setNewTheme(theme: Theme) { val context = LocalContext.current
GlobalState.theme = theme val scope = rememberCoroutineScope()
viewModel.saveNewTheme(theme)
if (viewModel.overridePickerSheetVisible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
viewModel.overridePickerSheetVisible = false
}
) {
ColourSelectorSheet(
initialValue = Color(viewModel.selectedOverrideInitialValue ?: 0),
onConfirm = { color ->
viewModel.updateColourOverrides(
viewModel.selectedOverrideName ?: return@ColourSelectorSheet,
color?.toArgb()
)
scope.launch {
sheetState.hide()
viewModel.overridePickerSheetVisible = false
}
},
onDismiss = {
scope.launch {
sheetState.hide()
viewModel.overridePickerSheetVisible = false
}
}
)
}
} }
Column( Column(
@ -74,19 +182,21 @@ fun AppearanceSettingsScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(20.dp)
) { ) {
Text( Text(
text = stringResource(id = R.string.settings_appearance_theme), text = stringResource(id = R.string.settings_appearance_theme),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(bottom = 10.dp) modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 10.dp)
) )
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp)
) { ) {
ThemeChip( ColourChip(
color = Color(0xff1c243c), color = Color(0xff1c243c),
text = stringResource(id = R.string.settings_appearance_theme_revolt), text = stringResource(id = R.string.settings_appearance_theme_revolt),
selected = GlobalState.theme == Theme.Revolt, selected = GlobalState.theme == Theme.Revolt,
@ -94,10 +204,10 @@ fun AppearanceSettingsScreen(
.weight(1f) .weight(1f)
.testTag("set_theme_revolt") .testTag("set_theme_revolt")
) { ) {
setNewTheme(Theme.Revolt) viewModel.saveNewTheme(Theme.Revolt)
} }
ThemeChip( ColourChip(
color = Color(0xfff7f7f7), color = Color(0xfff7f7f7),
text = stringResource(id = R.string.settings_appearance_theme_light), text = stringResource(id = R.string.settings_appearance_theme_light),
selected = GlobalState.theme == Theme.Light, selected = GlobalState.theme == Theme.Light,
@ -105,10 +215,10 @@ fun AppearanceSettingsScreen(
.weight(1f) .weight(1f)
.testTag("set_theme_light") .testTag("set_theme_light")
) { ) {
setNewTheme(Theme.Light) viewModel.saveNewTheme(Theme.Light)
} }
ThemeChip( ColourChip(
color = Color(0xff000000), color = Color(0xff000000),
text = stringResource(id = R.string.settings_appearance_theme_amoled), text = stringResource(id = R.string.settings_appearance_theme_amoled),
selected = GlobalState.theme == Theme.Amoled, selected = GlobalState.theme == Theme.Amoled,
@ -116,10 +226,10 @@ fun AppearanceSettingsScreen(
.weight(1f) .weight(1f)
.testTag("set_theme_amoled") .testTag("set_theme_amoled")
) { ) {
setNewTheme(Theme.Amoled) viewModel.saveNewTheme(Theme.Amoled)
} }
ThemeChip( ColourChip(
color = if (isSystemInDarkTheme()) Color(0xff1c243c) else Color(0xfff7f7f7), color = if (isSystemInDarkTheme()) Color(0xff1c243c) else Color(0xfff7f7f7),
text = stringResource(id = R.string.settings_appearance_theme_none), text = stringResource(id = R.string.settings_appearance_theme_none),
selected = GlobalState.theme == Theme.None, selected = GlobalState.theme == Theme.None,
@ -127,11 +237,11 @@ fun AppearanceSettingsScreen(
.weight(1f) .weight(1f)
.testTag("set_theme_none") .testTag("set_theme_none")
) { ) {
setNewTheme(Theme.None) viewModel.saveNewTheme(Theme.None)
} }
if (systemSupportsDynamicColors()) { if (systemSupportsDynamicColors()) {
ThemeChip( ColourChip(
color = dynamicDarkColorScheme(LocalContext.current).primary, color = dynamicDarkColorScheme(LocalContext.current).primary,
text = stringResource(id = R.string.settings_appearance_theme_m3dynamic), text = stringResource(id = R.string.settings_appearance_theme_m3dynamic),
selected = GlobalState.theme == Theme.M3Dynamic, selected = GlobalState.theme == Theme.M3Dynamic,
@ -139,10 +249,10 @@ fun AppearanceSettingsScreen(
.weight(1f) .weight(1f)
.testTag("set_theme_m3dynamic") .testTag("set_theme_m3dynamic")
) { ) {
setNewTheme(Theme.M3Dynamic) viewModel.saveNewTheme(Theme.M3Dynamic)
} }
} else { } else {
ThemeChip( ColourChip(
color = Color(0xffa0a0a0), color = Color(0xffa0a0a0),
text = stringResource( text = stringResource(
id = R.string.settings_appearance_theme_m3dynamic_unsupported id = R.string.settings_appearance_theme_m3dynamic_unsupported
@ -162,6 +272,197 @@ fun AppearanceSettingsScreen(
} }
} }
} }
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier
.clickable {
viewModel.showColourOverrides = !viewModel.showColourOverrides
}
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier
.padding(start = 20.dp, end = 4.dp)
.rotate(colourOverridesOpenerArrowRotation)
)
}
Text(
text = stringResource(id = R.string.settings_appearance_colour_overrides),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.weight(1f)
)
if (LocalLayoutDirection.current == LayoutDirection.Rtl) {
Icon(
imageVector = Icons.Default.KeyboardArrowLeft,
contentDescription = null,
modifier = Modifier
.padding(start = 4.dp, end = 20.dp)
.rotate(colourOverridesOpenerArrowRotation)
)
}
}
AnimatedVisibility(viewModel.showColourOverrides) {
Column {
ColorScheme::class.memberProperties.forEach { member ->
if (member.visibility != KVisibility.PUBLIC) return@forEach
val name = member.name
val value = member.getter.call(MaterialTheme.colorScheme) as Color
ColourChip(
color = value,
text = try {
R.string::class.java.getField("settings_appearance_colour_overrides_${name.toSnakeCase()}")
.getInt(null)
.let { context.getString(it) }
} catch (e: Exception) {
name
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp)
.testTag("set_colour_override_$name")
) {
viewModel.selectedOverrideName = name
viewModel.selectedOverrideInitialValue = value.toArgb()
viewModel.overridePickerSheetVisible = true
}
}
}
}
} }
} }
} }
@Composable
fun ColourSelectorSheet(
initialValue: Color,
onConfirm: (Color?) -> Unit,
onDismiss: () -> Unit
) {
val controller = rememberColorPickerController()
val colour = remember { mutableStateOf(initialValue) }
Column(
modifier = Modifier
.padding(20.dp)
.verticalScroll(rememberScrollState())
) {
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.height(450.dp)
.padding(10.dp),
controller = controller,
onColorChanged = { colorEnvelope: ColorEnvelope ->
colour.value = colorEnvelope.color
},
)
AlphaSlider(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
controller = controller,
)
BrightnessSlider(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
controller = controller,
)
CompositionLocalProvider(
LocalRippleTheme provides ClearRippleTheme
) {
ColourChip(
color = colour.value,
text = "#${
(0xFFFFFF and colour.value.toArgb()).toString(16).padStart(6, '0').uppercase()
}",
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
) {}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextButton(
onClick = {
onConfirm(null)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.settings_appearance_colour_overrides_reset)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
onClick = {
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.cancel)
)
}
Button(
onClick = {
onConfirm(colour.value)
},
enabled = colour.value != initialValue,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.settings_appearance_colour_overrides_apply)
)
}
}
}
}
}
fun String.toSnakeCase(): String {
return this.replace(Regex("([a-z])([A-Z]+)"), "$1_$2").lowercase()
}

View File

@ -3,8 +3,16 @@ package chat.revolt.ui.theme
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.* import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.RippleTheme
import androidx.compose.material3.ColorScheme
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.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -12,6 +20,8 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import kotlin.reflect.KMutableProperty
import kotlin.reflect.full.memberProperties
val RevoltColorScheme = darkColorScheme( val RevoltColorScheme = darkColorScheme(
primary = Color(0xffda4e5b), primary = Color(0xffda4e5b),
@ -58,9 +68,8 @@ enum class Theme {
Amoled Amoled
} }
@SuppressLint("NewApi")
@Composable @Composable
fun RevoltTheme(requestedTheme: Theme, content: @Composable () -> Unit) { fun getColorScheme(requestedTheme: Theme, colourOverrides: Map<String, Int>? = null): ColorScheme {
val context = LocalContext.current val context = LocalContext.current
val systemInDarkTheme = isSystemInDarkTheme() val systemInDarkTheme = isSystemInDarkTheme()
@ -81,7 +90,7 @@ fun RevoltTheme(requestedTheme: Theme, content: @Composable () -> Unit) {
requestedTheme == Theme.None && systemInDarkTheme -> RevoltColorScheme requestedTheme == Theme.None && systemInDarkTheme -> RevoltColorScheme
requestedTheme == Theme.None && !systemInDarkTheme -> LightColorScheme requestedTheme == Theme.None && !systemInDarkTheme -> LightColorScheme
else -> RevoltColorScheme else -> RevoltColorScheme
} }.copy()
val colorSchemeIsDark = when { val colorSchemeIsDark = when {
m3Supported && requestedTheme == Theme.M3Dynamic -> isSystemInDarkTheme() m3Supported && requestedTheme == Theme.M3Dynamic -> isSystemInDarkTheme()
@ -104,6 +113,29 @@ fun RevoltTheme(requestedTheme: Theme, content: @Composable () -> Unit) {
} }
} }
colorScheme::class.memberProperties.forEach {
if (it is KMutableProperty<*>) {
val name = it.name
val value = colourOverrides?.get(name)
if (value != null) {
Log.d("RevoltTheme", "Overriding $name with $value")
it.setter.call(colorScheme, Color(value))
}
}
}
return colorScheme
}
@SuppressLint("NewApi")
@Composable
fun RevoltTheme(
requestedTheme: Theme,
colourOverrides: Map<String, Int>?,
content: @Composable () -> Unit
) {
val colorScheme = getColorScheme(requestedTheme, colourOverrides)
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = RevoltTypography, typography = RevoltTypography,
@ -121,3 +153,16 @@ fun getDefaultTheme(): Theme {
else -> Theme.Revolt else -> Theme.Revolt
} }
} }
object ClearRippleTheme : RippleTheme {
@Composable
override fun defaultColor(): Color = Color.Transparent
@Composable
override fun rippleAlpha() = RippleAlpha(
draggedAlpha = 0.0f,
focusedAlpha = 0.0f,
hoveredAlpha = 0.0f,
pressedAlpha = 0.0f,
)
}

View File

@ -421,6 +421,41 @@
<string name="settings_appearance_theme_m3dynamic_unsupported">Material You (unsupported)</string> <string name="settings_appearance_theme_m3dynamic_unsupported">Material You (unsupported)</string>
<string name="settings_appearance_theme_m3dynamic_unsupported_toast">Material You is not supported on this device.</string> <string name="settings_appearance_theme_m3dynamic_unsupported_toast">Material You is not supported on this device.</string>
<string name="settings_appearance_colour_overrides">Colour overrides</string>
<string name="settings_appearance_colour_overrides_primary">Primary</string>
<string name="settings_appearance_colour_overrides_on_primary">On Primary</string>
<string name="settings_appearance_colour_overrides_primary_container">Primary Container</string>
<string name="settings_appearance_colour_overrides_on_primary_container">On Primary Container</string>
<string name="settings_appearance_colour_overrides_inverse_primary">Inverse Primary</string>
<string name="settings_appearance_colour_overrides_secondary">Secondary</string>
<string name="settings_appearance_colour_overrides_on_secondary">On Secondary</string>
<string name="settings_appearance_colour_overrides_secondary_container">Secondary Container</string>
<string name="settings_appearance_colour_overrides_on_secondary_container">On Secondary Container</string>
<string name="settings_appearance_colour_overrides_tertiary">Tertiary</string>
<string name="settings_appearance_colour_overrides_on_tertiary">On Tertiary</string>
<string name="settings_appearance_colour_overrides_tertiary_container">Tertiary Container</string>
<string name="settings_appearance_colour_overrides_on_tertiary_container">On Tertiary Container</string>
<string name="settings_appearance_colour_overrides_background">Background</string>
<string name="settings_appearance_colour_overrides_on_background">On Background</string>
<string name="settings_appearance_colour_overrides_surface">Surface</string>
<string name="settings_appearance_colour_overrides_on_surface">On Surface</string>
<string name="settings_appearance_colour_overrides_surface_variant">Surface Variant</string>
<string name="settings_appearance_colour_overrides_on_surface_variant">On Surface Variant</string>
<string name="settings_appearance_colour_overrides_surface_tint">Surface Tint</string>
<string name="settings_appearance_colour_overrides_inverse_surface">Inverse Surface</string>
<string name="settings_appearance_colour_overrides_inverse_on_surface">Inverse On Surface</string>
<string name="settings_appearance_colour_overrides_error">Error</string>
<string name="settings_appearance_colour_overrides_on_error">On Error</string>
<string name="settings_appearance_colour_overrides_error_container">Error Container</string>
<string name="settings_appearance_colour_overrides_on_error_container">On Error Container</string>
<string name="settings_appearance_colour_overrides_outline">Outline</string>
<string name="settings_appearance_colour_overrides_outline_variant">Outline Variant</string>
<string name="settings_appearance_colour_overrides_scrim">Scrim</string>
<string name="settings_appearance_colour_overrides_apply">Apply</string>
<string name="settings_appearance_colour_overrides_reset">Reset</string>
<string name="settings_appearance_colour_overrides_export">Export</string>
<string name="settings_appearance_colour_overrides_import">Import</string>
<string name="settings_feedback">Feedback</string> <string name="settings_feedback">Feedback</string>
<string name="settings_feedback_introduction">Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.</string> <string name="settings_feedback_introduction">Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.</string>
<string name="settings_feedback_category">Category</string> <string name="settings_feedback_category">Category</string>