From bf718967ddfb1db10bb22844fec46c3ee248f656 Mon Sep 17 00:00:00 2001 From: Syer10 Date: Fri, 23 Apr 2021 20:49:08 -0400 Subject: [PATCH] Rip the theming from Tachiyomi 1.x and piece it together for JUI --- .../kotlin/ca/gosyer/data/ui/UiPreferences.kt | 10 +- .../ca/gosyer/data/ui/model/ThemeMode.kt | 2 +- .../kotlin/ca/gosyer/ui/base/WindowDialog.kt | 41 ++ .../ui/base/components/ColorPickerDialog.kt | 454 ++++++++++++++++++ .../gosyer/ui/base/components/Scrollable.kt | 29 ++ .../gosyer/ui/base/prefs/ColorPreference.kt | 67 +++ .../ui/base/prefs/PreferenceMutableState.kt | 36 ++ .../ui/base/prefs/PreferencesUiBuilder.kt | 221 +++++++++ .../ui/base/theme/AppColorsPreference.kt | 45 ++ .../ca/gosyer/ui/base/theme/AppTheme.kt | 117 +++++ .../ca/gosyer/ui/base/theme/RandomColors.kt | 41 ++ .../kotlin/ca/gosyer/ui/base/theme/Themes.kt | 47 ++ .../kotlin/ca/gosyer/ui/base/vm/ViewModel.kt | 17 + src/main/kotlin/ca/gosyer/ui/main/main.kt | 5 +- .../kotlin/ca/gosyer/util/compose/State.kt | 16 + 15 files changed, 1135 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/ca/gosyer/ui/base/components/ColorPickerDialog.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/components/Scrollable.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/prefs/ColorPreference.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/prefs/PreferenceMutableState.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/theme/AppColorsPreference.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/theme/AppTheme.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/theme/RandomColors.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/theme/Themes.kt create mode 100644 src/main/kotlin/ca/gosyer/util/compose/State.kt diff --git a/src/main/kotlin/ca/gosyer/data/ui/UiPreferences.kt b/src/main/kotlin/ca/gosyer/data/ui/UiPreferences.kt index efa1115a..09f3b005 100644 --- a/src/main/kotlin/ca/gosyer/data/ui/UiPreferences.kt +++ b/src/main/kotlin/ca/gosyer/data/ui/UiPreferences.kt @@ -14,7 +14,7 @@ import ca.gosyer.data.ui.model.ThemeMode class UiPreferences(private val preferenceStore: PreferenceStore) { fun themeMode(): Preference { - return preferenceStore.getJsonObject("theme_mode", ThemeMode.System, ThemeMode.serializer()) + return preferenceStore.getJsonObject("theme_mode", ThemeMode.Light, ThemeMode.serializer()) } fun lightTheme(): Preference { @@ -41,14 +41,6 @@ class UiPreferences(private val preferenceStore: PreferenceStore) { return preferenceStore.getInt("color_secondary_dark", 0) } - fun colorBarsLight(): Preference { - return preferenceStore.getInt("color_bar_light", 0) - } - - fun colorBarsDark(): Preference { - return preferenceStore.getInt("color_bar_dark", 0) - } - fun startScreen(): Preference { return preferenceStore.getJsonObject("start_screen", Screen.Library, Screen.serializer()) } diff --git a/src/main/kotlin/ca/gosyer/data/ui/model/ThemeMode.kt b/src/main/kotlin/ca/gosyer/data/ui/model/ThemeMode.kt index b8e1958e..51f9c8e2 100644 --- a/src/main/kotlin/ca/gosyer/data/ui/model/ThemeMode.kt +++ b/src/main/kotlin/ca/gosyer/data/ui/model/ThemeMode.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable @Serializable enum class ThemeMode { - System, + /*System,*/ Light, Dark, } \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/WindowDialog.kt b/src/main/kotlin/ca/gosyer/ui/base/WindowDialog.kt index 78ab4e15..efb1ca4f 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/WindowDialog.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/WindowDialog.kt @@ -89,3 +89,44 @@ fun WindowDialog( } } } + +fun WindowDialog( + title: String = "Dialog", + size: IntSize = IntSize(400, 200), + onDismissRequest: (() -> Unit)? = null, + forceFocus: Boolean = true, + buttons: @Composable (AppWindow) -> Unit, + content: @Composable (AppWindow) -> Unit +) = SwingUtilities.invokeLater { + val window = AppWindow( + title = title, + size = size, + location = IntOffset.Zero, + centered = true, + icon = null, + menuBar = null, + undecorated = false, + events = WindowEvents(), + onDismissRequest = onDismissRequest + ) + + if (forceFocus) { + window.events.onFocusLost = { + window.window.requestFocus() + } + } + + window.show { + MaterialTheme { + Surface { + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier.fillMaxSize() + ) { + content(window) + buttons(window) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/ColorPickerDialog.kt b/src/main/kotlin/ca/gosyer/ui/base/components/ColorPickerDialog.kt new file mode 100644 index 00000000..923ec1f1 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/ColorPickerDialog.kt @@ -0,0 +1,454 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import ca.gosyer.ui.base.WindowDialog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.math.round + +fun ColorPickerDialog( + title: String, + onDismissRequest: () -> Unit = {}, + onSelected: (Color) -> Unit, + initialColor: Color = Color.Unspecified, +) { + val currentColor = MutableStateFlow(initialColor) + val showPresets = MutableStateFlow(true) + + WindowDialog( + onDismissRequest = onDismissRequest, + title = title, + content = { + val showPresetsState by showPresets.collectAsState() + val currentColorState by currentColor.collectAsState() + if (showPresetsState) { + ColorPresets( + initialColor = currentColorState, + onColorChanged = { currentColor.value = it } + ) + } else { + ColorPalette( + initialColor = currentColorState, + onColorChanged = { currentColor.value = it } + ) + } + }, + buttons = { + val showPresetsState by showPresets.collectAsState() + val currentColorState by currentColor.collectAsState() + Row(Modifier.fillMaxWidth().padding(8.dp)) { + TextButton(onClick = { + showPresets.value = !showPresetsState + }) { + Text(if (showPresetsState) "Custom" else "Presets") + } + Spacer(Modifier.weight(1f)) + TextButton(onClick = { + onSelected(currentColorState) + it.close() + }) { + Text("Select") + } + } + } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ColorPresets( + initialColor: Color, + onColorChanged: (Color) -> Unit +) { + val presets = remember { + if (initialColor.isSpecified) { + (listOf(initialColor) + presetColors).distinct() + } else { + presetColors + } + } + + var selectedColor by remember { mutableStateOf(initialColor.takeOrElse { presets.first() }) } + var selectedShade by remember { mutableStateOf(null) } + + val shades = remember(selectedColor) { getColorShades(selectedColor) } + + val borderColor = MaterialTheme.colors.onBackground.copy(alpha = 0.54f) + + Column { + LazyVerticalGrid(cells = GridCells.Fixed(5)) { + items(presets) { color -> + ColorPresetItem( + color = color, + borderColor = borderColor, + isSelected = selectedShade == null && initialColor == color, + onClick = { + selectedShade = null + selectedColor = color + onColorChanged(color) + } + ) + } + } + Spacer( + modifier = Modifier.padding(vertical = 16.dp).fillMaxWidth().requiredHeight(1.dp) + .background(MaterialTheme.colors.onBackground.copy(alpha = 0.2f)) + ) + + LazyRow { + items(shades) { color -> + ColorPresetItem( + color = color, + borderColor = borderColor, + isSelected = selectedShade == color, + onClick = { + selectedShade = color + onColorChanged(color) + } + ) + } + } + } +} + +@Composable +private fun ColorPresetItem( + color: Color, + borderColor: Color, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + contentAlignment = Alignment.Center, modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .size(48.dp) + .clip(CircleShape) + .background(color) + .border(BorderStroke(1.dp, borderColor), CircleShape) + .clickable(onClick = onClick) + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + tint = if (color.luminance() > 0.5) Color.Black else Color.White, + contentDescription = null, + modifier = Modifier.requiredWidth(32.dp).requiredHeight(32.dp) + ) + } + } +} + +private fun getColorShades(color: Color): List { + val f = String.format("%06X", 0xFFFFFF and color.toArgb()).toLong(16) + return listOf( + shadeColor(f, 0.9), shadeColor(f, 0.7), shadeColor(f, 0.5), + shadeColor(f, 0.333), shadeColor(f, 0.166), shadeColor(f, -0.125), + shadeColor(f, -0.25), shadeColor(f, -0.375), shadeColor(f, -0.5), + shadeColor(f, -0.675), shadeColor(f, -0.7), shadeColor(f, -0.775) + ) +} + +private fun shadeColor(f: Long, percent: Double): Color { + val t = if (percent < 0) 0.0 else 255.0 + val p = if (percent < 0) percent * -1 else percent + val r = f shr 16 + val g = f shr 8 and 0x00FF + val b = f and 0x0000FF + + val red = (round((t - r) * p) + r).toInt() + val green = (round((t - g) * p) + g).toInt() + val blue = (round((t - b) * p) + b).toInt() + return Color(red = red, green = green, blue = blue, alpha = 255) +} + +@Composable +fun ColorPalette( + initialColor: Color = Color.White, + onColorChanged: (Color) -> Unit = {} +) { + var selectedColor by remember { mutableStateOf(initialColor) } + var textFieldHex by remember { mutableStateOf(initialColor.toHexString()) } + + var hue by remember { mutableStateOf(initialColor.toHsv()[0]) } + var hueCursor by remember { mutableStateOf(0f) } + + var matrixSize by remember { mutableStateOf(IntSize(0, 0)) } + var matrixCursor by remember { mutableStateOf(Offset(0f, 0f)) } + + val saturationGradient = remember(hue, matrixSize) { + Brush.linearGradient( + colors = listOf(Color.White, hueToColor(hue)), + start = Offset(0f, 0f), end = Offset(matrixSize.width.toFloat(), 0f) + ) + } + val valueGradient = remember(matrixSize) { + Brush.linearGradient( + colors = listOf(Color.White, Color.Black), + start = Offset(0f, 0f), end = Offset(0f, matrixSize.height.toFloat()) + ) + } + + val cursorColor = MaterialTheme.colors.onBackground + val cursorStroke = Stroke(4f) + val borderStroke = Stroke(1f) + + fun setSelectedColor(color: Color, invalidate: Boolean = false) { + selectedColor = color + textFieldHex = color.toHexString() + if (invalidate) { + val hsv = color.toHsv() + hue = hsv[0] + matrixCursor = satValToCoordinates(hsv[1], hsv[2], matrixSize) + hueCursor = hueToCoordinate(hsv[0], matrixSize) + } + onColorChanged(color) + } + + Column { + Text("") // TODO workaround: without this text, the color picker doesn't render correctly + Row(Modifier.height(IntrinsicSize.Max)) { + Box(Modifier + .aspectRatio(1f) + .weight(1f) + .onSizeChanged { + matrixSize = it + val hsv = selectedColor.toHsv() + matrixCursor = satValToCoordinates(hsv[1], hsv[2], it) + hueCursor = hueToCoordinate(hue, it) + } + .drawWithContent { + drawRect(brush = valueGradient) + drawRect(brush = saturationGradient, blendMode = BlendMode.Multiply) + drawRect(Color.LightGray, size = size, style = borderStroke) + drawCircle( + Color.Black, + radius = 8f, + center = matrixCursor, + style = cursorStroke + ) + drawCircle( + Color.LightGray, + radius = 12f, + center = matrixCursor, + style = cursorStroke + ) + } + .pointerInput(Unit) { + detectMove { offset -> + val safeOffset = offset.copy( + x = offset.x.coerceIn(0f, matrixSize.width.toFloat()), + y = offset.y.coerceIn(0f, matrixSize.height.toFloat()) + ) + matrixCursor = safeOffset + val newColor = matrixCoordinatesToColor(hue, safeOffset, matrixSize) + setSelectedColor(newColor) + } + } + ) + Box(Modifier + .fillMaxHeight() + .requiredWidth(48.dp) + .padding(start = 8.dp) + .drawWithCache { + var h = 360f + val colors = MutableList(size.height.toInt()) { + hueToColor(h).also { + h -= 360f / size.height + } + } + val cursorSize = Size(size.width, 10f) + val cursorTopLeft = Offset(0f, hueCursor - (cursorSize.height / 2)) + onDrawBehind { + colors.forEachIndexed { i, color -> + val pos = i.toFloat() + drawLine(color, Offset(0f, pos), Offset(size.width, pos)) + } + drawRect(Color.LightGray, size = size, style = borderStroke) + drawRect( + cursorColor, + topLeft = cursorTopLeft, + size = cursorSize, + style = cursorStroke + ) + } + } + .pointerInput(Unit) { + detectMove { offset -> + val safeY = offset.y.coerceIn(0f, matrixSize.height.toFloat()) + hueCursor = safeY + hue = hueCoordinatesToHue(safeY, matrixSize) + val newColor = matrixCoordinatesToColor(hue, matrixCursor, matrixSize) + setSelectedColor(newColor) + } + } + ) + } + Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.Bottom) { + Box( + Modifier.size(72.dp, 48.dp).background(selectedColor) + .border(1.dp, MaterialTheme.colors.onBackground.copy(alpha = 0.54f)) + ) + Spacer(Modifier.requiredWidth(32.dp)) + OutlinedTextField( + value = textFieldHex, + onValueChange = { + val newColor = hexStringToColor(it) + if (newColor != null) { + setSelectedColor(newColor, invalidate = true) + } else { + textFieldHex = it + } + } + ) + } + } +} + +private suspend fun PointerInputScope.detectMove(onMove: (Offset) -> Unit) { + forEachGesture { + awaitPointerEventScope { + var change = awaitFirstDown() + while (change.pressed) { + onMove(change.position) + change = awaitPointerEvent().changes.first() + } + } + } +} + +// Coordinates <-> Color + +private fun matrixCoordinatesToColor(hue: Float, position: Offset, size: IntSize): Color { + val saturation = 1f / size.width * position.x + val value = 1f - (1f / size.height * position.y) + return hsvToColor(hue, saturation, value) +} + +private fun hueCoordinatesToHue(y: Float, size: IntSize): Float { + val hue = 360f - y * 360f / size.height + return hsvToColor(hue, 1f, 1f).toHsv()[0] +} + +private fun satValToCoordinates(saturation: Float, value: Float, size: IntSize): Offset { + return Offset(saturation * size.width, ((1f - value) * size.height)) +} + +private fun hueToCoordinate(hue: Float, size: IntSize): Float { + return size.height - (hue * size.height / 360f) +} + +// Color space conversions + +fun hsvToColor(hue: Float, saturation: Float, value: Float): Color { + return Color(java.awt.Color.HSBtoRGB(hue, saturation, value)) +} + +private fun Color.toHsv(): FloatArray { + fun Float.toIntColor() = (this * 256).toInt() + val result = floatArrayOf(0f, 0f, 0f) + java.awt.Color.RGBtoHSB(red.toIntColor(), green.toIntColor(), blue.toIntColor(), result) + return result +} + +private fun hueToColor(hue: Float): Color { + return hsvToColor(hue, 1f, 1f) +} + +private fun Color.toHexString(): String { + return String.format("#%06X", (0xFFFFFF and toArgb())) +} + +private fun hexStringToColor(hex: String): Color? { + return try { + val color = java.awt.Color.decode(hex) + Color(color.red, color.green, color.blue, color.alpha) + } catch (e: Exception) { + null + } +} + +private val presetColors = listOf( + Color(0xFFF44336), // RED 500 + Color(0xFFE91E63), // PINK 500 + Color(0xFFFF2C93), // LIGHT PINK 500 + Color(0xFF9C27B0), // PURPLE 500 + Color(0xFF673AB7), // DEEP PURPLE 500 + Color(0xFF3F51B5), // INDIGO 500 + Color(0xFF2196F3), // BLUE 500 + Color(0xFF03A9F4), // LIGHT BLUE 500 + Color(0xFF00BCD4), // CYAN 500 + Color(0xFF009688), // TEAL 500 + Color(0xFF4CAF50), // GREEN 500 + Color(0xFF8BC34A), // LIGHT GREEN 500 + Color(0xFFCDDC39), // LIME 500 + Color(0xFFFFEB3B), // YELLOW 500 + Color(0xFFFFC107), // AMBER 500 + Color(0xFFFF9800), // ORANGE 500 + Color(0xFF795548), // BROWN 500 + Color(0xFF607D8B), // BLUE GREY 500 + Color(0xFF9E9E9E), // GREY 500 +) + diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Scrollable.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Scrollable.kt new file mode 100644 index 00000000..8d255b66 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/Scrollable.kt @@ -0,0 +1,29 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.components + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ScrollableColumn( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Column( + modifier = modifier.scrollable( + state = rememberScrollState(), + orientation = Orientation.Vertical + ) + ) { + content() + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/prefs/ColorPreference.kt b/src/main/kotlin/ca/gosyer/ui/base/prefs/ColorPreference.kt new file mode 100644 index 00000000..ff6f0a9f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/prefs/ColorPreference.kt @@ -0,0 +1,67 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.prefs + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import ca.gosyer.common.prefs.Preference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class ColorPreference( + private val preference: Preference +) : Preference { + + override fun key(): String { + return preference.key() + } + + override fun get(): Color { + return if (isSet()) { + Color(preference.get()) + } else { + Color.Unspecified + } + } + + override fun set(value: Color) { + if (value != Color.Unspecified) { + preference.set(value.toArgb()) + } else { + preference.delete() + } + } + + override fun isSet(): Boolean { + return preference.isSet() + } + + override fun delete() { + preference.delete() + } + + override fun defaultValue(): Color { + return Color.Unspecified + } + + override fun changes(): Flow { + return preference.changes() + .map { get() } + } + + override fun stateIn(scope: CoroutineScope): StateFlow { + return preference.changes().map { get() }.stateIn(scope, SharingStarted.Eagerly, get()) + } +} + +fun Preference.asColor(): ColorPreference { + return ColorPreference(this) +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferenceMutableState.kt b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferenceMutableState.kt new file mode 100644 index 00000000..a576a68e --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferenceMutableState.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.prefs + +import ca.gosyer.common.prefs.Preference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class PreferenceMutableStateFlow( + private val preference: Preference, + scope: CoroutineScope, + private val state: MutableStateFlow = MutableStateFlow(preference.get()) +) : MutableStateFlow by state { + + init { + preference.changes() + .onEach { value = it } + .launchIn(scope) + } + + override var value: T + get() = state.value + set(value) { + preference.set(value) + } +} + +fun Preference.asStateIn(scope: CoroutineScope): PreferenceMutableStateFlow { + return PreferenceMutableStateFlow(this, scope) +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt new file mode 100644 index 00000000..2326e108 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt @@ -0,0 +1,221 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.prefs + +import androidx.compose.desktop.AppWindow +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.graphics.takeOrElse +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ca.gosyer.ui.base.WindowDialog +import ca.gosyer.ui.base.components.ColorPickerDialog +import ca.gosyer.ui.base.components.ScrollableColumn + +@Composable +fun PreferencesScrollableColumn( + modifier: Modifier = Modifier, + content: @Composable PreferenceScope.() -> Unit +) { + Box { + ScrollableColumn(modifier) { + val scope = PreferenceScope() + scope.content() + } + } +} + +class PreferenceScope { + @Composable + fun ChoicePref( + preference: PreferenceMutableStateFlow, + choices: Map, + title: String, + subtitle: String? = null + ) { + Pref( + title = title, + subtitle = if (subtitle == null) choices[preference.value] else null, + onClick = { + ChoiceDialog( + items = choices.toList(), + selected = preference.value, + title = title, + onSelected = { selected -> + preference.value = selected + } + ) + } + ) + } + + @Composable + fun ColorPref( + preference: PreferenceMutableStateFlow, + title: String, + subtitle: String? = null, + unsetColor: Color = Color.Unspecified + ) { + val initialColor = preference.value.takeOrElse { unsetColor } + Pref( + title = title, + subtitle = subtitle, + onClick = { + ColorPickerDialog( + title = title, + onSelected = { + preference.value = it + }, + initialColor = initialColor + ) + }, + onLongClick = { preference.value = Color.Unspecified }, + action = { + if (preference.value != Color.Unspecified || unsetColor != Color.Unspecified) { + val borderColor = MaterialTheme.colors.onBackground.copy(alpha = 0.54f) + Box( + modifier = Modifier + .padding(4.dp) + .size(32.dp) + .clip(CircleShape) + .background(color = initialColor) + .border(BorderStroke(1.dp, borderColor), CircleShape) + ) + } + } + ) + } + + private fun ChoiceDialog( + items: List>, + selected: T?, + onDismissRequest: () -> Unit = {}, + onSelected: (T) -> Unit, + title: String, + buttons: @Composable (AppWindow) -> Unit = { } + ) { + WindowDialog(onDismissRequest = onDismissRequest, buttons = buttons, title = title, content = { + LazyColumn { + items(items) { (value, text) -> + Row( + modifier = Modifier.requiredHeight(48.dp).fillMaxWidth().clickable( + onClick = { + onSelected(value) + it.close() + }), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = value == selected, + onClick = { + onSelected(value) + it.close() + }, + ) + Text(text = text, modifier = Modifier.padding(start = 24.dp)) + } + } + } + }) + } +} + +@Composable +fun Pref( + title: String, + icon: ImageVector? = null, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + subtitle: String? = null, + action: @Composable (() -> Unit)? = null, +) { + val height = if (subtitle != null) 72.dp else 56.dp + + Row( + modifier = Modifier.fillMaxWidth().requiredHeight(height) + .combinedClickable( + onLongClick = onLongClick, + onClick = onClick + ), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + imageVector = icon, + modifier = Modifier.padding(horizontal = 16.dp).size(24.dp), + tint = MaterialTheme.colors.primary, + contentDescription = null + ) + } + Column(Modifier.padding(horizontal = 16.dp).weight(1f)) { + Text( + text = title, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.subtitle1, + ) + if (subtitle != null) { + Text( + text = subtitle, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium), + style = MaterialTheme.typography.subtitle1 + ) + } + } + if (action != null) { + Box(Modifier.widthIn(min = 56.dp)) { + action() + } + } + } +} + +@Composable +fun SwitchPref( + preference: PreferenceMutableStateFlow, + title: String, + subtitle: String? = null, + icon: ImageVector? = null, +) { + Pref( + title = title, + subtitle = subtitle, + icon = icon, + action = { Switch(checked = preference.value, onCheckedChange = null) }, + onClick = { preference.value = !preference.value } + ) +} + diff --git a/src/main/kotlin/ca/gosyer/ui/base/theme/AppColorsPreference.kt b/src/main/kotlin/ca/gosyer/ui/base/theme/AppColorsPreference.kt new file mode 100644 index 00000000..b6203fdd --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/theme/AppColorsPreference.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.theme + +import androidx.compose.ui.graphics.Color +import ca.gosyer.common.prefs.Preference +import ca.gosyer.data.ui.UiPreferences +import ca.gosyer.ui.base.prefs.asColor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +data class AppColorsPreference( + val primary: Preference, + val secondary: Preference +) + +class AppColorsPreferenceState( + val primaryStateFlow: StateFlow, + val secondaryStateFlow: StateFlow +) + +fun UiPreferences.getLightColors(): AppColorsPreference { + return AppColorsPreference( + colorPrimaryLight().asColor(), + colorSecondaryLight().asColor() + ) +} + +fun UiPreferences.getDarkColors(): AppColorsPreference { + return AppColorsPreference( + colorPrimaryDark().asColor(), + colorSecondaryDark().asColor() + ) +} + +fun AppColorsPreference.asState(scope: CoroutineScope): AppColorsPreferenceState { + return AppColorsPreferenceState( + primary.stateIn(scope), + secondary.stateIn(scope) + ) +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/theme/AppTheme.kt b/src/main/kotlin/ca/gosyer/ui/base/theme/AppTheme.kt new file mode 100644 index 00000000..81d94d73 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/theme/AppTheme.kt @@ -0,0 +1,117 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.theme + +import androidx.compose.desktop.DesktopMaterialTheme +import androidx.compose.material.Colors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.takeOrElse +import ca.gosyer.data.ui.UiPreferences +import ca.gosyer.data.ui.model.ThemeMode +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.ui.base.vm.viewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelChildren +import javax.inject.Inject + +/** + * Composable used to apply the application colors to [content]. + * It applies the [DesktopMaterialTheme] colors. + */ +@Composable +fun AppTheme(content: @Composable () -> Unit) { + val vm = viewModel() + val colors = vm.getColors() + /*val systemUiController = rememberSystemUiController()*/ + + DesktopMaterialTheme(colors = colors, content = content) +} + +private class AppThemeViewModel @Inject constructor( + private val uiPreferences: UiPreferences +) : ViewModel() { + private val themeMode = uiPreferences.themeMode().asStateFlow() + private val lightTheme = uiPreferences.lightTheme().asStateFlow() + private val darkTheme = uiPreferences.darkTheme().asStateFlow() + + private val baseThemeJob = SupervisorJob() + private val baseThemeScope = CoroutineScope(baseThemeJob) + + @Composable + fun getColors(): Colors { + val themeMode by themeMode.collectAsState() + val lightTheme by lightTheme.collectAsState() + val darkTheme by darkTheme.collectAsState() + + val baseTheme = getBaseTheme(themeMode, lightTheme, darkTheme) + val colors = remember(baseTheme.colors.isLight) { + baseThemeJob.cancelChildren() + + if (baseTheme.colors.isLight) { + uiPreferences.getLightColors().asState(baseThemeScope) + } else { + uiPreferences.getDarkColors().asState(baseThemeScope) + } + } + + val primary by colors.primaryStateFlow.collectAsState() + val secondary by colors.secondaryStateFlow.collectAsState() + + val material = getMaterialColors(baseTheme.colors, primary, secondary) + return material + } + + @Composable + private fun getBaseTheme( + themeMode: ThemeMode, + lightTheme: Int, + darkTheme: Int + ): Theme { + fun getTheme(id: Int, fallbackIsLight: Boolean): Theme { + return themes.find { it.id == id } + ?: themes.first { it.colors.isLight == fallbackIsLight } + } + + return when (themeMode) { + /*ThemeMode.System -> if (!isSystemInDarkTheme()) { + getTheme(lightTheme, true) + } else { + getTheme(darkTheme, false) + }*/ + ThemeMode.Light -> getTheme(lightTheme, true) + ThemeMode.Dark -> getTheme(darkTheme, false) + } + } + + private fun getMaterialColors( + baseColors: Colors, + colorPrimary: Color, + colorSecondary: Color + ): Colors { + val primary = colorPrimary.takeOrElse { baseColors.primary } + val secondary = colorSecondary.takeOrElse { baseColors.secondary } + return baseColors.copy( + primary = primary, + primaryVariant = primary, + secondary = secondary, + secondaryVariant = secondary, + onPrimary = if (primary.luminance() > 0.5) Color.Black else Color.White, + onSecondary = if (secondary.luminance() > 0.5) Color.Black else Color.White, + ) + } + + override fun onDestroy() { + baseThemeScope.cancel() + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/theme/RandomColors.kt b/src/main/kotlin/ca/gosyer/ui/base/theme/RandomColors.kt new file mode 100644 index 00000000..42f58cf2 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/theme/RandomColors.kt @@ -0,0 +1,41 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.theme + +import androidx.compose.ui.graphics.Color +import kotlin.math.abs +import kotlin.random.Random + +object RandomColors { + private val colors = arrayOf( + Color(0xffe57373), + Color(0xfff06292), + Color(0xffba68c8), + Color(0xff9575cd), + Color(0xff7986cb), + Color(0xff64b5f6), + Color(0xff4fc3f7), + Color(0xff4dd0e1), + Color(0xff4db6ac), + Color(0xff81c784), + Color(0xffaed581), + Color(0xffff8a65), + Color(0xffd4e157), + Color(0xffffd54f), + Color(0xffffb74d), + Color(0xffa1887f), + Color(0xff90a4ae) + ) + + fun get(key: Any): Color { + return colors[abs(key.hashCode()) % colors.size] + } + + fun random(): Color { + return colors[Random.nextInt(colors.size)] + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/theme/Themes.kt b/src/main/kotlin/ca/gosyer/ui/base/theme/Themes.kt new file mode 100644 index 00000000..d1511747 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/theme/Themes.kt @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.theme + +import androidx.compose.material.Colors +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.ui.graphics.Color + +data class Theme( + val id: Int, + val colors: Colors +) + +val themes = listOf( + // Pure white + Theme( + 1, lightColors() + ), + // Tachiyomi 0.x default colors + Theme( + 2, lightColors( + primary = Color(0xFF2979FF), + primaryVariant = Color(0xFF2979FF), + onPrimary = Color.White, + secondary = Color(0xFF2979FF), + secondaryVariant = Color(0xFF2979FF), + onSecondary = Color.White + ) + ), + // Tachiyomi 0.x dark theme + Theme( + 3, darkColors() + ), + // AMOLED theme + Theme( + 4, darkColors( + primary = Color.Black, + onPrimary = Color.White, + background = Color.Black + ) + ), +) diff --git a/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModel.kt b/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModel.kt index 9f136dfa..3126a0bf 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModel.kt @@ -6,8 +6,15 @@ package ca.gosyer.ui.base.vm +import ca.gosyer.common.prefs.Preference +import ca.gosyer.ui.base.prefs.PreferenceMutableStateFlow import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch abstract class ViewModel { @@ -19,4 +26,14 @@ abstract class ViewModel { } open fun onDestroy() {} + + fun Preference.asStateFlow() = PreferenceMutableStateFlow(this, scope) + + fun Flow.asStateFlow(initialValue: T): StateFlow { + val state = MutableStateFlow(initialValue) + scope.launch { + collect { state.value = it } + } + return state + } } diff --git a/src/main/kotlin/ca/gosyer/ui/main/main.kt b/src/main/kotlin/ca/gosyer/ui/main/main.kt index 024953f8..bdbbcae3 100644 --- a/src/main/kotlin/ca/gosyer/ui/main/main.kt +++ b/src/main/kotlin/ca/gosyer/ui/main/main.kt @@ -8,7 +8,6 @@ package ca.gosyer.ui.main import androidx.compose.desktop.AppWindow import androidx.compose.desktop.DesktopMaterialTheme -import androidx.compose.desktop.WindowEvents import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -16,7 +15,7 @@ import androidx.compose.ui.input.key.Key import ca.gosyer.BuildConfig import ca.gosyer.data.DataModule import ca.gosyer.ui.base.components.LoadingScreen -import ca.gosyer.util.compose.ThemedWindow +import ca.gosyer.ui.base.theme.AppTheme import ca.gosyer.util.system.userDataDir import com.github.zsoltk.compose.backpress.BackPressHandler import com.github.zsoltk.compose.backpress.LocalBackPressHandler @@ -112,7 +111,7 @@ fun main() { } window.show { - DesktopMaterialTheme { + AppTheme { CompositionLocalProvider( LocalBackPressHandler provides backPressHandler ) { diff --git a/src/main/kotlin/ca/gosyer/util/compose/State.kt b/src/main/kotlin/ca/gosyer/util/compose/State.kt new file mode 100644 index 00000000..c36ada3f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/compose/State.kt @@ -0,0 +1,16 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.util.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import com.github.zsoltk.compose.savedinstancestate.LocalSavedInstanceState + +@Composable +fun State.persistent(key: String) { + val bundle = LocalSavedInstanceState.current +} \ No newline at end of file