Rip the theming from Tachiyomi 1.x and piece it together for JUI

This commit is contained in:
Syer10
2021-04-23 20:49:08 -04:00
parent 29ed87d0d6
commit bf718967dd
15 changed files with 1135 additions and 13 deletions

View File

@@ -14,7 +14,7 @@ import ca.gosyer.data.ui.model.ThemeMode
class UiPreferences(private val preferenceStore: PreferenceStore) {
fun themeMode(): Preference<ThemeMode> {
return preferenceStore.getJsonObject("theme_mode", ThemeMode.System, ThemeMode.serializer())
return preferenceStore.getJsonObject("theme_mode", ThemeMode.Light, ThemeMode.serializer())
}
fun lightTheme(): Preference<Int> {
@@ -41,14 +41,6 @@ class UiPreferences(private val preferenceStore: PreferenceStore) {
return preferenceStore.getInt("color_secondary_dark", 0)
}
fun colorBarsLight(): Preference<Int> {
return preferenceStore.getInt("color_bar_light", 0)
}
fun colorBarsDark(): Preference<Int> {
return preferenceStore.getInt("color_bar_dark", 0)
}
fun startScreen(): Preference<Screen> {
return preferenceStore.getJsonObject("start_screen", Screen.Library, Screen.serializer())
}

View File

@@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable
@Serializable
enum class ThemeMode {
System,
/*System,*/
Light,
Dark,
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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<Color?>(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<Color> {
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
)

View File

@@ -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()
}
}

View File

@@ -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<Int>
) : Preference<Color> {
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<Color> {
return preference.changes()
.map { get() }
}
override fun stateIn(scope: CoroutineScope): StateFlow<Color> {
return preference.changes().map { get() }.stateIn(scope, SharingStarted.Eagerly, get())
}
}
fun Preference<Int>.asColor(): ColorPreference {
return ColorPreference(this)
}

View File

@@ -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<T>(
private val preference: Preference<T>,
scope: CoroutineScope,
private val state: MutableStateFlow<T> = MutableStateFlow(preference.get())
) : MutableStateFlow<T> by state {
init {
preference.changes()
.onEach { value = it }
.launchIn(scope)
}
override var value: T
get() = state.value
set(value) {
preference.set(value)
}
}
fun <T> Preference<T>.asStateIn(scope: CoroutineScope): PreferenceMutableStateFlow<T> {
return PreferenceMutableStateFlow(this, scope)
}

View File

@@ -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 <Key> ChoicePref(
preference: PreferenceMutableStateFlow<Key>,
choices: Map<Key, String>,
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<Color>,
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 <T> ChoiceDialog(
items: List<Pair<T, String>>,
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<Boolean>,
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 }
)
}

View File

@@ -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<Color>,
val secondary: Preference<Color>
)
class AppColorsPreferenceState(
val primaryStateFlow: StateFlow<Color>,
val secondaryStateFlow: StateFlow<Color>
)
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)
)
}

View File

@@ -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<AppThemeViewModel>()
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()
}
}

View File

@@ -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)]
}
}

View File

@@ -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
)
),
)

View File

@@ -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 <T> Preference<T>.asStateFlow() = PreferenceMutableStateFlow(this, scope)
fun <T> Flow<T>.asStateFlow(initialValue: T): StateFlow<T> {
val state = MutableStateFlow(initialValue)
scope.launch {
collect { state.value = it }
}
return state
}
}

View File

@@ -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
) {

View File

@@ -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 <T> State<T>.persistent(key: String) {
val bundle = LocalSavedInstanceState.current
}