From ddac7eea1086a25dfe2ffb2b0ace2c74e3189c43 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 7 May 2024 18:45:53 +0900 Subject: [PATCH] add custom theme color support --- app/build.gradle.kts | 2 + .../flare/data/model/AppearanceSettings.kt | 2 + .../status/bluesky/BlueskyStatusComponent.kt | 4 +- .../ui/screen/settings/AppearanceScreen.kt | 38 ++++- .../ui/screen/settings/ColorPickerDialog.kt | 131 ++++++++++++++++++ .../screen/settings/LocalFilterEditDialog.kt | 2 +- .../action/BlueskyReportStatusDialog.kt | 4 +- .../action/DeleteStatusConfirmDialog.kt | 4 +- .../status/action/MastodonReportDialog.kt | 4 +- .../status/action/MisskeyReportDialog.kt | 4 +- .../dev/dimension/flare/ui/theme/Theme.kt | 7 +- app/src/main/res/values/strings.xml | 5 +- gradle/libs.versions.toml | 4 + 13 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/settings/ColorPickerDialog.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3828643a1..5c181861f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -142,6 +142,8 @@ dependencies { debugImplementation(libs.mlkit.language.id.debug) implementation(projects.shared) implementation(libs.androidx.splash) + implementation(libs.materialKolor) + implementation(libs.colorpicker.compose) if (project.file("google-services.json").exists()) { implementation(platform(libs.firebase.bom)) diff --git a/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt b/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt index 5767810c4..155de343a 100644 --- a/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt +++ b/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.HideSource import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.datastore.core.DataStore import androidx.datastore.core.Serializer @@ -32,6 +33,7 @@ val LocalAppearanceSettings = staticCompositionLocalOf { AppearanceSettings() } data class AppearanceSettings( val theme: Theme = Theme.SYSTEM, val dynamicTheme: Boolean = true, + val colorSeed: ULong = Color.Blue.value, val avatarShape: AvatarShape = AvatarShape.CIRCLE, val showActions: Boolean = true, val showNumbers: Boolean = true, diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt index b11f598fa..9fabbf57f 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt @@ -232,7 +232,7 @@ private fun RowScope.StatusFooterComponent( leadingIcon = { Icon( imageVector = Icons.Default.Report, - contentDescription = null, + contentDescription = stringResource(id = R.string.blusky_item_action_report), ) }, onClick = { @@ -250,7 +250,7 @@ private fun RowScope.StatusFooterComponent( leadingIcon = { Icon( imageVector = Icons.Default.Delete, - contentDescription = null, + contentDescription = stringResource(id = R.string.blusky_item_action_delete), ) }, onClick = { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceScreen.kt index 2a4b332df..010be4e7c 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceScreen.kt @@ -5,14 +5,18 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -43,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.ColorPickerDialogRouteDestination import dev.dimension.flare.R import dev.dimension.flare.data.model.AppearanceSettings import dev.dimension.flare.data.model.AvatarShape @@ -75,6 +80,9 @@ internal fun AppearanceRoute(navigator: ProxyDestinationsNavigator) { SharedTransitionScope { AppearanceScreen( onBack = navigator::navigateUp, + toColorPicker = { + navigator.navigate(ColorPickerDialogRouteDestination) + }, ) } } @@ -83,7 +91,10 @@ internal fun AppearanceRoute(navigator: ProxyDestinationsNavigator) { context(AnimatedVisibilityScope, SharedTransitionScope) @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable -private fun AppearanceScreen(onBack: () -> Unit) { +private fun AppearanceScreen( + onBack: () -> Unit, + toColorPicker: () -> Unit, +) { val state by producePresenter { appearancePresenter() } val appearanceSettings = LocalAppearanceSettings.current FlareScaffold( @@ -272,6 +283,31 @@ private fun AppearanceScreen(onBack: () -> Unit) { }, ) } + AnimatedVisibility(visible = !appearanceSettings.dynamicTheme || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.settings_appearance_theme_color)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.settings_appearance_theme_color_description)) + }, + trailingContent = { + Box( + modifier = + Modifier + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ) + .size(36.dp), + ) + }, + modifier = + Modifier.clickable { + toColorPicker.invoke() + }, + ) + } BoxWithConstraints { var showMenu by remember { mutableStateOf(false) } ListItem( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/ColorPickerDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/ColorPickerDialog.kt new file mode 100644 index 000000000..c70045124 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/ColorPickerDialog.kt @@ -0,0 +1,131 @@ +package dev.dimension.flare.ui.screen.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.skydoves.colorpicker.compose.AlphaTile +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 com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.spec.DestinationStyle +import dev.dimension.flare.R +import dev.dimension.flare.data.model.LocalAppearanceSettings +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.molecule.producePresenter +import dev.dimension.flare.ui.component.ThemeWrapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Destination( + wrappers = [ThemeWrapper::class], + style = DestinationStyle.Dialog::class, +) +@Composable +internal fun ColorPickerDialogRoute(navigator: ProxyDestinationsNavigator) { + ColorPickerDialog( + onBack = navigator::navigateUp, + ) +} + +@Composable +private fun ColorPickerDialog(onBack: () -> Unit) { + val appearanceSettings = LocalAppearanceSettings.current + val state by producePresenter { + presenter(initialColor = appearanceSettings.colorSeed) + } + val controller = rememberColorPickerController() + LaunchedEffect(Unit) { + controller.selectByColor(Color(appearanceSettings.colorSeed), fromUser = true) + } + + AlertDialog( + onDismissRequest = onBack, + confirmButton = { + TextButton( + onClick = { + state.confirm() + onBack.invoke() + }, + ) { + Text(stringResource(id = android.R.string.ok)) + } + }, + text = { + Column( + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), + ) { + HsvColorPicker( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f), + controller = controller, + onColorChanged = { colorEnvelope: ColorEnvelope -> + state.setColor(colorEnvelope.color) + }, + ) + BrightnessSlider( + modifier = + Modifier + .fillMaxWidth() + .height(36.dp), + controller = controller, + ) + AlphaTile( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp) + .clip(RoundedCornerShape(6.dp)), + controller = controller, + ) + } + }, + title = { + Text(stringResource(id = R.string.settings_appearance_theme_color)) + }, + ) +} + +@Composable +private fun presenter( + initialColor: ULong, + settingsRepository: SettingsRepository = koinInject(), + coroutineScope: CoroutineScope = koinInject(), +) = run { + var selectedColor by remember { mutableStateOf(Color(initialColor)) } + + object { + fun setColor(color: Color) { + selectedColor = color + } + + fun confirm() { + coroutineScope.launch { + settingsRepository.updateAppearanceSettings { + copy(colorSeed = selectedColor.value) + } + } + } + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/LocalFilterEditDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/LocalFilterEditDialog.kt index c752d5587..b165459df 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/LocalFilterEditDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/LocalFilterEditDialog.kt @@ -97,7 +97,7 @@ private fun LocalFilterEditDialog( }) { Icon( Icons.Default.Check, - contentDescription = stringResource(id = R.string.done), + contentDescription = stringResource(id = android.R.string.ok), ) } }, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt index 60cb94e77..f460ca3e7 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt @@ -80,12 +80,12 @@ internal fun BlueskyReportStatusDialog( } }, ) { - Text(text = stringResource(id = R.string.confirm)) + Text(text = stringResource(id = android.R.string.ok)) } }, dismissButton = { TextButton(onClick = onBack) { - Text(text = stringResource(id = R.string.cancel)) + Text(text = stringResource(id = android.R.string.cancel)) } }, title = { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt index b5534a782..34c58f6c4 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt @@ -68,12 +68,12 @@ fun DeleteStatusConfirmDialog( onBack.invoke() }, ) { - Text(text = stringResource(id = R.string.confirm)) + Text(text = stringResource(id = android.R.string.ok)) } }, dismissButton = { TextButton(onClick = onBack) { - Text(text = stringResource(id = R.string.cancel)) + Text(text = stringResource(id = android.R.string.cancel)) } }, title = { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt index fcaf301d1..213c6fa40 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt @@ -71,14 +71,14 @@ fun MastodonReportDialog( onBack.invoke() }, ) { - Text(stringResource(R.string.confirm)) + Text(stringResource(android.R.string.ok)) } }, dismissButton = { TextButton( onClick = onBack, ) { - Text(stringResource(R.string.cancel)) + Text(stringResource(android.R.string.cancel)) } }, title = { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt index 75d1f34bc..cb304007c 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt @@ -82,7 +82,7 @@ fun MisskeyReportDialog( }, ) { Text( - text = stringResource(R.string.confirm), + text = stringResource(android.R.string.ok), ) } }, @@ -91,7 +91,7 @@ fun MisskeyReportDialog( onClick = onBack, ) { Text( - text = stringResource(R.string.cancel), + text = stringResource(android.R.string.cancel), ) } }, diff --git a/app/src/main/java/dev/dimension/flare/ui/theme/Theme.kt b/app/src/main/java/dev/dimension/flare/ui/theme/Theme.kt index 1f1867c12..aa7ffb593 100644 --- a/app/src/main/java/dev/dimension/flare/ui/theme/Theme.kt +++ b/app/src/main/java/dev/dimension/flare/ui/theme/Theme.kt @@ -10,9 +10,11 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import com.materialkolor.rememberDynamicColorScheme import dev.dimension.flare.data.model.LocalAppearanceSettings import dev.dimension.flare.data.model.Theme @@ -29,6 +31,7 @@ fun FlareTheme( dynamicColor: Boolean = LocalAppearanceSettings.current.dynamicTheme, content: @Composable () -> Unit, ) { + val seed = Color(LocalAppearanceSettings.current.colorSeed) val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { @@ -36,8 +39,8 @@ fun FlareTheme( if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> DarkColorScheme - else -> LightColorScheme + darkTheme -> rememberDynamicColorScheme(seed, true) + else -> rememberDynamicColorScheme(seed, false) } val view = LocalView.current if (!view.isInEditMode && view.context is Activity) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e67113b72..273fed836 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,8 +3,6 @@ Login Navigate back - Confirm - Cancel Home Notifications @@ -248,6 +246,8 @@ Show link previews in the status Compat link previews Show link previews in compat mode in the status + Theme color + Change the theme color of the app Show visibility Show visibility in the status @@ -302,7 +302,6 @@ Filter in search Delete Filter - Done Delete Translate to %1$s diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d221a4e7..e0d8c0b2b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,6 +50,7 @@ firebase-bom = "33.0.0" google-services = "4.4.1" firebase-crashlytics = "3.0.0" napier = "2.7.1" +materialKolor = "1.4.4" [libraries] bluesky = { module = "sh.christian.ozone:bluesky", version.ref = "bluesky" } @@ -171,6 +172,9 @@ mlkit-language-id-debug = { group = "com.google.mlkit", name = "language-id", ve mlkit-language-id = { group = "com.google.android.gms", name = "play-services-mlkit-language-id", version = "17.0.0" } mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "17.0.2" } +materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } +colorpicker-compose = { module = "com.github.skydoves:colorpicker-compose", version = "1.0.7" } + [bundles] compose = ["ui", "ui-util", "ui-graphics", "ui-tooling", "ui-tooling-preview", "material3", "material3WindowSizeClass", "material3-adaptive-navigation-suite", "material3-adaptive", "material3-adaptive-navigation", "material3-adaptive-layout", "material-icons-extended"] navigation = ["navigation-compose"]