diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 854ba7a37f..8dce04e0de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -171,6 +171,8 @@ dependencies { implementation(androidx.interpolator) + implementation(compose.colorpicker) + implementation(androidx.paging.runtime) implementation(androidx.paging.compose) diff --git a/app/src/main/java/eu/kanade/domain/ui/UiPreferences.kt b/app/src/main/java/eu/kanade/domain/ui/UiPreferences.kt index e54886b456..e7115e573e 100644 --- a/app/src/main/java/eu/kanade/domain/ui/UiPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/ui/UiPreferences.kt @@ -24,6 +24,8 @@ class UiPreferences( if (DeviceUtil.isDynamicColorAvailable) { AppTheme.MONET } else { AppTheme.DEFAULT }, ) + fun colorTheme() = preferenceStore.getInt("pref_color_theme", 0) + fun themeDarkAmoled() = preferenceStore.getBoolean("pref_theme_dark_amoled_key", false) fun relativeTime() = preferenceStore.getBoolean("relative_time_v2", true) diff --git a/app/src/main/java/eu/kanade/domain/ui/model/AppTheme.kt b/app/src/main/java/eu/kanade/domain/ui/model/AppTheme.kt index d81f6b34c6..df94f8e75d 100644 --- a/app/src/main/java/eu/kanade/domain/ui/model/AppTheme.kt +++ b/app/src/main/java/eu/kanade/domain/ui/model/AppTheme.kt @@ -6,6 +6,7 @@ import tachiyomi.i18n.MR enum class AppTheme(val titleRes: StringResource?) { DEFAULT(MR.strings.label_default), MONET(MR.strings.theme_monet), + CUSTOM(MR.strings.theme_custom), CLOUDFLARE(MR.strings.theme_cloudflare), COTTONCANDY(MR.strings.theme_cottoncandy), DOOM(MR.strings.theme_doom), diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt index 99fc61eb28..7cf8da46f6 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt @@ -11,12 +11,14 @@ import androidx.core.app.ActivityCompat import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.domain.ui.UiPreferences +import eu.kanade.domain.ui.model.AppTheme import eu.kanade.domain.ui.model.NavStyle import eu.kanade.domain.ui.model.StartScreen import eu.kanade.domain.ui.model.TabletUiMode import eu.kanade.domain.ui.model.ThemeMode import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.more.settings.screen.appearance.AppCustomThemeColorPickerScreen import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget @@ -48,10 +50,12 @@ object SettingsAppearanceScreen : SearchableSettings { } @Composable + @Suppress("SpreadOperator") private fun getThemeGroup( uiPreferences: UiPreferences, ): Preference.PreferenceGroup { val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow val themeModePref = uiPreferences.themeMode() val themeMode by themeModePref.collectAsState() @@ -62,6 +66,18 @@ object SettingsAppearanceScreen : SearchableSettings { val amoledPref = uiPreferences.themeDarkAmoled() val amoled by amoledPref.collectAsState() + val customPreferenceItem = if (appTheme == AppTheme.CUSTOM) { + listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_custom_color), + subtitle = stringResource(MR.strings.custom_color_description), + onClick = { navigator.push(AppCustomThemeColorPickerScreen()) }, + ), + ) + } else { + emptyList() + } + return Preference.PreferenceGroup( title = stringResource(MR.strings.pref_category_theme), preferenceItems = persistentListOf( @@ -84,6 +100,7 @@ object SettingsAppearanceScreen : SearchableSettings { ) } }, + *customPreferenceItem.toTypedArray(), Preference.PreferenceItem.SwitchPreference( pref = amoledPref, title = stringResource(MR.strings.pref_dark_theme_pure_black), diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/appearance/AppCustomThemeColorPickerScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/appearance/AppCustomThemeColorPickerScreen.kt new file mode 100644 index 0000000000..44967ce9f8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/appearance/AppCustomThemeColorPickerScreen.kt @@ -0,0 +1,78 @@ +package eu.kanade.presentation.more.settings.screen.appearance + +import android.app.Activity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.github.skydoves.colorpicker.compose.rememberColorPickerController +import eu.kanade.domain.ui.UiPreferences +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.more.settings.widget.ThemeColorPickerWidget +import eu.kanade.presentation.util.Screen +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.collectAsState +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AppCustomThemeColorPickerScreen : Screen() { + + @Composable + override fun Content() { + val uiPreferences: UiPreferences = Injekt.get() + + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val controller = rememberColorPickerController() + + val customColorPref = uiPreferences.colorTheme() + val customColor by customColorPref.collectAsState() + + val appThemePref = uiPreferences.appTheme() + + val currentColor by remember { + mutableIntStateOf(customColor) + } + + LaunchedEffect(customColorPref) { + customColorPref.set(currentColor) + } + + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(MR.strings.pref_custom_color), + navigateUp = navigator::pop, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + Column( + modifier = Modifier.padding(contentPadding), + ) { + ThemeColorPickerWidget( + controller = controller, + initialColor = Color(currentColor), + onItemClick = { color, appTheme -> + customColorPref.set(color.toArgb()) + appThemePref.set(appTheme) + (context as? Activity)?.let { ActivityCompat.recreate(it) } + }, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/CustomBrightnessSlider.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/CustomBrightnessSlider.kt new file mode 100644 index 0000000000..a1da01d200 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/CustomBrightnessSlider.kt @@ -0,0 +1,100 @@ +package eu.kanade.presentation.more.settings.widget + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.github.skydoves.colorpicker.compose.BrightnessSlider +import com.github.skydoves.colorpicker.compose.ColorPickerController + +@Composable +fun CustomBrightnessSlider( + initialColor: Color, + controller: ColorPickerController, + modifier: Modifier = Modifier, +) { + // Define your colors and sizes directly + val borderColor = Color.LightGray // Color for the slider border + val thumbRadius = 12.dp // Example thumb radius + val trackHeight = 4.dp // Example track height + val borderSize = 1.dp // Example border size for the slider + + // Set up the paint for the thumb (wheel) + val wheelPaint = Paint().apply { + color = Color.White + alpha = 1.0f + } + + // This function creates the ImageBitmap for the gradient background of the slider + @Composable + fun rememberSliderGradientBitmap( + width: Dp, + height: Dp, + startColor: Color, + endColor: Color, + ): ImageBitmap { + val sizePx = with(LocalDensity.current) { IntSize(width.roundToPx(), height.roundToPx()) } + return remember(sizePx, startColor, endColor) { + ImageBitmap(sizePx.width, sizePx.height, ImageBitmapConfig.Argb8888).apply { + val canvas = Canvas(this) + val shader = LinearGradientShader( + colors = listOf(startColor, endColor), + from = Offset(0f, 0f), + to = Offset(sizePx.width.toFloat(), 0f), + tileMode = TileMode.Clamp, + ) + val paint = Paint().apply { + this.shader = shader + } + canvas.drawRect( + 0f, + 0f, + sizePx.width.toFloat(), + sizePx.height.toFloat(), + paint, + ) + } + } + } + + // Obtain the Composable's size for the gradient background + val sliderWidth = 20.dp // Example width, adjust to your needs + val sliderHeight = thumbRadius * 2 // The height is double the thumb radius + val gradientBitmap = rememberSliderGradientBitmap( + width = sliderWidth, // Subtract the thumb radii from the total width + height = trackHeight, + startColor = Color.White, + endColor = Color.White, + ) + + BrightnessSlider( + modifier = modifier + .height(sliderHeight) + .fillMaxWidth() + .padding(horizontal = thumbRadius), // Padding equals thumb radius + controller = controller, + initialColor = initialColor, + borderRadius = thumbRadius, // Use thumbRadius for the rounded corners + borderSize = borderSize, + borderColor = borderColor, // Use borderColor for the slider border + wheelRadius = thumbRadius, + wheelColor = Color.White, // Thumb (wheel) color + wheelImageBitmap = gradientBitmap, // Use the generated gradient bitmap as the background + wheelAlpha = 1.0f, // Full opacity for the thumb + wheelPaint = wheelPaint, // Use the defined wheel paint + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/ThemeColorPickerWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/ThemeColorPickerWidget.kt new file mode 100644 index 0000000000..c8b6ea3a83 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/ThemeColorPickerWidget.kt @@ -0,0 +1,113 @@ +package eu.kanade.presentation.more.settings.widget + +import android.graphics.Bitmap +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.github.skydoves.colorpicker.compose.ColorEnvelope +import com.github.skydoves.colorpicker.compose.ColorPickerController +import com.github.skydoves.colorpicker.compose.HsvColorPicker +import eu.kanade.domain.ui.model.AppTheme +import kotlin.math.roundToInt + +@Composable +internal fun ThemeColorPickerWidget( + initialColor: Color, + controller: ColorPickerController, + onItemClick: (Color, AppTheme) -> Unit, +) { + var selectedColor by remember { mutableStateOf(initialColor) } + var showConfirmButton by remember { mutableStateOf(false) } + + val wheelSize = with(LocalDensity.current) { 20.dp.toPx().roundToInt() } + val wheelStrokeWidth = with(LocalDensity.current) { 2.dp.toPx() } + + // Remember a wheel bitmap + val wheelBitmap = remember(wheelSize, wheelStrokeWidth) { + val bitmap = Bitmap.createBitmap(wheelSize, wheelSize, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bitmap) + val paint = android.graphics.Paint().apply { + color = android.graphics.Color.WHITE + style = android.graphics.Paint.Style.STROKE + strokeWidth = wheelStrokeWidth + isAntiAlias = true + } + + // Draw the circle for wheel indicator + canvas.drawCircle( + wheelSize / 2f, + wheelSize / 2f, + wheelSize / 2f - wheelStrokeWidth, + paint, + ) + bitmap.asImageBitmap() + } + + BasePreferenceWidget( + subcomponent = { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier) { + HsvColorPicker( + modifier = Modifier + .size(with(LocalDensity.current) { 300.dp }), + controller = controller, + wheelImageBitmap = wheelBitmap, + initialColor = initialColor, + onColorChanged = { colorEnvelope: ColorEnvelope -> + selectedColor = colorEnvelope.color + showConfirmButton = true + }, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + CustomBrightnessSlider( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + controller = controller, + initialColor = initialColor, + ) + AnimatedVisibility( + visible = showConfirmButton, + enter = fadeIn() + expandVertically(), + ) { + Button( + onClick = { + onItemClick(selectedColor, AppTheme.CUSTOM) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .height(48.dp), + content = { + Text("Confirm Color") + }, + ) + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt index 5d860b702e..bdccf07d3d 100644 --- a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt +++ b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt @@ -11,6 +11,7 @@ import eu.kanade.domain.ui.model.AppTheme import eu.kanade.presentation.theme.colorscheme.BaseColorScheme import eu.kanade.presentation.theme.colorscheme.CloudflareColorScheme import eu.kanade.presentation.theme.colorscheme.CottoncandyColorScheme +import eu.kanade.presentation.theme.colorscheme.CustomColorScheme import eu.kanade.presentation.theme.colorscheme.DoomColorScheme import eu.kanade.presentation.theme.colorscheme.GreenAppleColorScheme import eu.kanade.presentation.theme.colorscheme.LavenderColorScheme @@ -72,6 +73,8 @@ private fun getThemeColorScheme( val uiPreferences = Injekt.get() val colorScheme = if (appTheme == AppTheme.MONET) { MonetColorScheme(LocalContext.current) + } else if (appTheme == AppTheme.CUSTOM) { + CustomColorScheme(uiPreferences) } else { colorSchemes.getOrDefault(appTheme, TachiyomiColorScheme) } diff --git a/app/src/main/java/eu/kanade/presentation/theme/colorscheme/CustomColorScheme.kt b/app/src/main/java/eu/kanade/presentation/theme/colorscheme/CustomColorScheme.kt new file mode 100644 index 0000000000..8a1c326164 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/theme/colorscheme/CustomColorScheme.kt @@ -0,0 +1,80 @@ +package eu.kanade.presentation.theme.colorscheme + +import android.annotation.SuppressLint +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import com.google.android.material.color.utilities.Hct +import com.google.android.material.color.utilities.MaterialDynamicColors +import com.google.android.material.color.utilities.SchemeContent +import eu.kanade.domain.ui.UiPreferences + +internal class CustomColorScheme(uiPreferences: UiPreferences) : BaseColorScheme() { + + private val seed = uiPreferences.colorTheme().get() + + private val custom = CustomCompatColorScheme(seed) + + override val darkScheme + get() = custom.darkScheme + + override val lightScheme + get() = custom.lightScheme +} + +private class CustomCompatColorScheme(seed: Int) : BaseColorScheme() { + + override val lightScheme = generateColorSchemeFromSeed(seed = seed, dark = false) + override val darkScheme = generateColorSchemeFromSeed(seed = seed, dark = true) + + companion object { + private fun Int.toComposeColor(): Color = Color(this) + + @SuppressLint("RestrictedApi") + private fun generateColorSchemeFromSeed(seed: Int, dark: Boolean): ColorScheme { + val scheme = SchemeContent( + Hct.fromInt(seed), + dark, + 0.0, + ) + val dynamicColors = MaterialDynamicColors() + return ColorScheme( + primary = dynamicColors.primary().getArgb(scheme).toComposeColor(), + onPrimary = dynamicColors.onPrimary().getArgb(scheme).toComposeColor(), + primaryContainer = dynamicColors.primaryContainer().getArgb(scheme).toComposeColor(), + onPrimaryContainer = dynamicColors.onPrimaryContainer().getArgb(scheme).toComposeColor(), + inversePrimary = dynamicColors.inversePrimary().getArgb(scheme).toComposeColor(), + secondary = dynamicColors.secondary().getArgb(scheme).toComposeColor(), + onSecondary = dynamicColors.onSecondary().getArgb(scheme).toComposeColor(), + secondaryContainer = dynamicColors.secondaryContainer().getArgb(scheme).toComposeColor(), + onSecondaryContainer = dynamicColors.onSecondaryContainer().getArgb(scheme).toComposeColor(), + tertiary = dynamicColors.tertiary().getArgb(scheme).toComposeColor(), + onTertiary = dynamicColors.onTertiary().getArgb(scheme).toComposeColor(), + tertiaryContainer = dynamicColors.tertiary().getArgb(scheme).toComposeColor(), + onTertiaryContainer = dynamicColors.onTertiaryContainer().getArgb(scheme).toComposeColor(), + background = dynamicColors.background().getArgb(scheme).toComposeColor(), + onBackground = dynamicColors.onBackground().getArgb(scheme).toComposeColor(), + surface = dynamicColors.surface().getArgb(scheme).toComposeColor(), + onSurface = dynamicColors.onSurface().getArgb(scheme).toComposeColor(), + surfaceVariant = dynamicColors.surfaceVariant().getArgb(scheme).toComposeColor(), + onSurfaceVariant = dynamicColors.onSurfaceVariant().getArgb(scheme).toComposeColor(), + surfaceTint = dynamicColors.surfaceTint().getArgb(scheme).toComposeColor(), + inverseSurface = dynamicColors.inverseSurface().getArgb(scheme).toComposeColor(), + inverseOnSurface = dynamicColors.inverseOnSurface().getArgb(scheme).toComposeColor(), + error = dynamicColors.error().getArgb(scheme).toComposeColor(), + onError = dynamicColors.onError().getArgb(scheme).toComposeColor(), + errorContainer = dynamicColors.errorContainer().getArgb(scheme).toComposeColor(), + onErrorContainer = dynamicColors.onErrorContainer().getArgb(scheme).toComposeColor(), + outline = dynamicColors.outline().getArgb(scheme).toComposeColor(), + outlineVariant = dynamicColors.outlineVariant().getArgb(scheme).toComposeColor(), + scrim = Color.Black, + surfaceBright = dynamicColors.surfaceBright().getArgb(scheme).toComposeColor(), + surfaceDim = dynamicColors.surfaceDim().getArgb(scheme).toComposeColor(), + surfaceContainer = dynamicColors.surfaceContainer().getArgb(scheme).toComposeColor(), + surfaceContainerHigh = dynamicColors.surfaceContainerHigh().getArgb(scheme).toComposeColor(), + surfaceContainerHighest = dynamicColors.surfaceContainerHighest().getArgb(scheme).toComposeColor(), + surfaceContainerLow = dynamicColors.surfaceContainerLow().getArgb(scheme).toComposeColor(), + surfaceContainerLowest = dynamicColors.surfaceContainerLowest().getArgb(scheme).toComposeColor(), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/ThemingDelegate.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/ThemingDelegate.kt index 93b69adb3e..cd1b44693a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/ThemingDelegate.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/ThemingDelegate.kt @@ -32,6 +32,7 @@ class ThemingDelegateImpl : ThemingDelegate { } private val themeResources: Map = mapOf( + AppTheme.CUSTOM to R.style.Theme_Tachiyomi_Custom, AppTheme.MONET to R.style.Theme_Tachiyomi_Monet, AppTheme.COTTONCANDY to R.style.Theme_Tachiyomi_CottonCandy, AppTheme.GREEN_APPLE to R.style.Theme_Tachiyomi_GreenApple, diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 14d86a2473..0e43847e14 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -85,6 +85,9 @@