From 7482f797c7cc27c9bbc04a92e602fa33f23fd332 Mon Sep 17 00:00:00 2001 From: FX1922 <120828496+FX192@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:26:53 +0300 Subject: [PATCH] feat: custom Gradient theme with primary and secondary colors (#1309) * feat: a custom Gradient theme with a primary and a secondary colour, picked with a wheel, sliders and a hex box * Gradient theme: OK waits for a valid hex, deep blues are made paler, button text suits the worse gradient colour * Gradient theme: nudge only the gradient colour that button text cannot read on * fix: meet readable contrast across gradient themes * fix: preserve contrast through gradient interpolation --------- Co-authored-by: xXJsonDeruloXx --- .../main/java/com/droiddeck/launcher/App.kt | 1 + .../com/droiddeck/launcher/SessionActivity.kt | 4 +- .../launcher/session/SessionPrefs.kt | 10 ++ .../launcher/ui/ColorPickerDialog.kt | 141 ++++++++++++++++ .../droiddeck/launcher/ui/FrontEndSetup.kt | 19 +++ .../java/com/droiddeck/launcher/ui/Theme.kt | 34 +++- .../com/droiddeck/launcher/ui/ThemeColors.kt | 157 ++++++++++++++++++ .../launcher/ui/TrackingRecoveryView.kt | 4 +- app/src/main/res/values-ar/strings.xml | 8 + app/src/main/res/values-b+es+419/strings.xml | 8 + app/src/main/res/values-bg/strings.xml | 8 + app/src/main/res/values-cs/strings.xml | 8 + app/src/main/res/values-da/strings.xml | 8 + app/src/main/res/values-de/strings.xml | 8 + app/src/main/res/values-el/strings.xml | 8 + app/src/main/res/values-es/strings.xml | 8 + app/src/main/res/values-fi/strings.xml | 8 + app/src/main/res/values-fr/strings.xml | 8 + app/src/main/res/values-hi/strings.xml | 8 + app/src/main/res/values-hu/strings.xml | 8 + app/src/main/res/values-in/strings.xml | 8 + app/src/main/res/values-it/strings.xml | 8 + app/src/main/res/values-ja/strings.xml | 8 + app/src/main/res/values-ko/strings.xml | 8 + app/src/main/res/values-ms/strings.xml | 8 + app/src/main/res/values-nb/strings.xml | 8 + app/src/main/res/values-nl/strings.xml | 8 + app/src/main/res/values-pl/strings.xml | 8 + app/src/main/res/values-pt-rPT/strings.xml | 8 + app/src/main/res/values-pt/strings.xml | 8 + app/src/main/res/values-ro/strings.xml | 8 + app/src/main/res/values-ru/strings.xml | 8 + app/src/main/res/values-sv/strings.xml | 8 + app/src/main/res/values-th/strings.xml | 8 + app/src/main/res/values-tr/strings.xml | 8 + app/src/main/res/values-uk/strings.xml | 8 + app/src/main/res/values-vi/strings.xml | 8 + app/src/main/res/values-zh-rCN/strings.xml | 8 + app/src/main/res/values-zh-rHK/strings.xml | 8 + app/src/main/res/values-zh-rTW/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../droiddeck/launcher/ui/ThemeColorsTest.kt | 135 +++++++++++++++ docs/development/themes.md | 25 +++ 43 files changed, 789 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt create mode 100644 docs/development/themes.md diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 68df13b2c..3fc438377 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -16,6 +16,7 @@ class App : Application() { override fun onCreate() { super.onCreate() + com.droiddeck.launcher.ui.Themes.loadGradient(this) // Helper processes must not initialize the main USB provider or guest graphics context. val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName() else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index f43a27286..ace8b51f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -3418,7 +3418,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun finishFlooded() { if (isFinishing) return - val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal + val signal = com.droiddeck.launcher.ui.Themes.current(this).signal com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb()) window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb())) quitFlooded = true @@ -3449,7 +3449,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun completeLeave() { if (isFinishing) return - val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal + val signal = com.droiddeck.launcher.ui.Themes.current(this).signal com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb()) window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb())) quitFlooded = true diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index b548e56de..cd0a750ac 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -759,6 +759,16 @@ object SessionPrefs { prefs(context).edit().putString("theme", id).apply() } + /** The custom Gradient theme's primary and secondary colours (ARGB). */ + fun gradientColors(context: Context): Pair = prefs(context).let { + it.getInt("gradientPrimary", com.droiddeck.launcher.ui.Themes.DEFAULT_PRIMARY) to + it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY) + } + + fun setGradientColors(context: Context, primary: Int, secondary: Int) { + prefs(context).edit().putInt("gradientPrimary", primary).putInt("gradientSecondary", secondary).apply() + } + private fun customResolution(context: Context, mode: String): Pair? = parseResolution(prefs(context).getString("customRes.$mode", null)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt new file mode 100644 index 000000000..e94f193e9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt @@ -0,0 +1,141 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import kotlin.math.atan2 +import kotlin.math.hypot +import kotlin.math.min + +/** A small colour square; pressing it opens the colour picker. */ +@Composable +internal fun ColorCube(color: Color, description: String, focus: FocusRequester? = null, onClick: () -> Unit) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(10.dp) + Box( + Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).size(44.dp).clip(shape).background(color) + .border(1.dp, pal.line2, shape).glideBorder(hot, shape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = description }, + ) +} + +/** Colour wheel (touch), hue / saturation / brightness sliders (controller and touch) and a hex box for one colour. */ +@Composable +internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Unit, onApply: (Int) -> Unit) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val start = remember { ThemeColors.toHsv(initial) } + var hue by remember { mutableStateOf(start.h) } + var sat by remember { mutableStateOf(start.s) } + var value by remember { mutableStateOf(start.v) } + var hexText by remember { mutableStateOf(ThemeColors.hex(initial)) } + val current = ThemeColors.fromHsv(hue, sat, value) + val first = remember { FocusRequester() } + LaunchedEffect(Unit) { focusSoon { first } } + fun picked(h: Float = hue, s: Float = sat, v: Float = value) { + hue = h.coerceIn(0f, 360f); sat = s.coerceIn(0f, 1f); value = v.coerceIn(0f, 1f) + hexText = ThemeColors.hex(ThemeColors.fromHsv(hue, sat, value)) + } + AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp) { + DialogTitleRow(title, close) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + ColorWheel(hue, sat, value) { h, s -> picked(h = h, s = s) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Swatch(Color(initial)) + Swatch(Color(current)) + } + } + ValueSlider(hue.toInt(), 0..360, step = 5, enabled = true, label = stringResource(R.string.theme_color_hue), format = { "$it°" }, + modifier = Modifier.focusRequester(first)) { picked(h = it.toFloat()) } + ValueSlider((sat * 100).toInt(), 0..100, step = 2, enabled = true, label = stringResource(R.string.theme_color_saturation), format = { "$it%" }) { picked(s = it / 100f) } + ValueSlider((value * 100).toInt(), 0..100, step = 2, enabled = true, label = stringResource(R.string.theme_color_brightness), format = { "$it%" }) { picked(v = it / 100f) } + AdbTextField( + value = hexText, + onValueChange = { text -> + hexText = text + ThemeColors.parseHex(text)?.let { argb -> ThemeColors.toHsv(argb).let { hsv -> hue = hsv.h; sat = hsv.s; value = hsv.v } } + }, + label = "", keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done, placeholder = stringResource(R.string.theme_color_hex), + onDone = { ThemeColors.parseHex(hexText)?.let { onApply(it); close() } }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), modifier = Modifier.weight(1f), onClick = close) + PrimaryButton(stringResource(R.string.common_ok), enabled = ThemeColors.parseHex(hexText) != null, modifier = Modifier.weight(1f)) { + ThemeColors.parseHex(hexText)?.let { onApply(it); close() } + } + } + } +} + +@Composable +private fun Swatch(color: Color) { + val shape = RoundedCornerShape(10.dp) + Box(Modifier.size(48.dp).clip(shape).background(color).border(1.dp, LocalPalette.current.line2, shape)) +} + +/** Hue around the circle and saturation out from the middle, at full brightness; brightness has its own slider. */ +@Composable +private fun ColorWheel(hue: Float, sat: Float, value: Float, onPick: (hue: Float, sat: Float) -> Unit) { + val rim = MaterialTheme.colorScheme.onBackground + val size = 168.dp + fun at(offset: Offset, side: Float) { + val center = side / 2f + val dx = offset.x - center + val dy = offset.y - center + val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat() + onPick((angle + 360f) % 360f, min(1f, hypot(dx, dy) / center)) + } + Canvas( + Modifier.size(size) + .pointerInput(Unit) { detectTapGestures { at(it, this.size.width.toFloat()) } } + .pointerInput(Unit) { detectDragGestures { change, _ -> at(change.position, this.size.width.toFloat()); change.consume() } }, + ) { + val radius = this.size.minDimension / 2f + drawCircle(Brush.sweepGradient(List(7) { Color.hsv(it * 60f, 1f, 1f) }), radius) + drawCircle(Brush.radialGradient(listOf(Color.White, Color.Transparent), radius = radius), radius) + val theta = Math.toRadians(hue.toDouble()) + val marker = Offset(radius + (kotlin.math.cos(theta) * sat * radius).toFloat(), radius + (kotlin.math.sin(theta) * sat * radius).toFloat()) + drawCircle(Color.Black.copy(alpha = 0.55f), 9.dp.toPx(), marker) + drawCircle(rim, 7.dp.toPx(), marker) + drawCircle(Color.hsv(hue, sat, value), 5.dp.toPx(), marker) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 9b65cc8a9..b5bbaa2e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -359,6 +359,7 @@ internal fun SetupPanel( } } } + if (s.theme == Themes.GRADIENT) GradientColorRows() ChoiceRow( host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent -> @@ -790,3 +791,21 @@ private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit } } } + +/** The two colours of the Gradient theme, each a small square that opens the colour picker. */ +@Composable +private fun GradientColorRows() { + val ctx = androidx.compose.ui.platform.LocalContext.current.applicationContext + val (primary, secondary) = Themes.gradientColors + var picking by remember { androidx.compose.runtime.mutableIntStateOf(0) } + val primaryLabel = stringResource(R.string.theme_gradient_primary) + val secondaryLabel = stringResource(R.string.theme_gradient_secondary) + SettingsRow(primaryLabel, null) { ColorCube(androidx.compose.ui.graphics.Color(primary), primaryLabel) { picking = 1 } } + SettingsRow(secondaryLabel, null) { ColorCube(androidx.compose.ui.graphics.Color(secondary), secondaryLabel) { picking = 2 } } + if (picking != 0) ColorPickerDialog( + if (picking == 1) primaryLabel else secondaryLabel, if (picking == 1) primary else secondary, + onDismiss = { picking = 0 }, + ) { argb -> + if (picking == 1) Themes.setGradient(ctx, argb, secondary) else Themes.setGradient(ctx, primary, argb) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index e33ebde33..0f3843fca 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -6,6 +6,10 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import android.content.Context import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance @@ -29,6 +33,30 @@ object Themes { const val PAPER = "paper" const val ICON_BLUE = "blue" const val ELECTRIC = "electric" + const val GRADIENT = "gradient" + + const val DEFAULT_PRIMARY = 0xFF8B5CF6.toInt() + const val DEFAULT_SECONDARY = 0xFF22D3EE.toInt() + + /** The two colours of the custom Gradient theme: primary, then secondary. Changing them restyles the app at once. */ + var gradientColors by mutableStateOf(DEFAULT_PRIMARY to DEFAULT_SECONDARY) + private set + + fun loadGradient(context: Context) { + gradientColors = SessionPrefs.gradientColors(context) + } + + fun setGradient(context: Context, primary: Int, secondary: Int) { + SessionPrefs.setGradientColors(context, primary, secondary) + gradientColors = primary to secondary + } + + /** The theme the person chose, with the custom colours read first; for code that is not in a composition. */ + fun current(context: Context): Palette { + val id = SessionPrefs.theme(context) + if (id == GRADIENT) loadGradient(context) + return byId(id) + } val all: List = listOf( Palette( @@ -59,9 +87,13 @@ object Themes { primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF), good = Color(0xFF4CD37F), ), + ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail), ) - fun byId(id: String): Palette = all.firstOrNull { it.id == id } ?: all[0] + fun byId(id: String): Palette { + if (id == GRADIENT) return ThemeColors.palette(gradientColors.first, gradientColors.second, R.string.theme_gradient, R.string.theme_gradient_detail) + return all.firstOrNull { it.id == id } ?: all[0] + } } val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt new file mode 100644 index 000000000..b4b57737e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt @@ -0,0 +1,157 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** Colour maths for the custom gradient theme: hue, saturation and brightness in, a readable palette out. */ +internal object ThemeColors { + /** Hue 0..360, saturation and brightness 0..1. */ + class Hsv(val h: Float, val s: Float, val v: Float) + + fun toHsv(argb: Int): Hsv { + val r = (argb shr 16 and 0xFF) / 255f + val g = (argb shr 8 and 0xFF) / 255f + val b = (argb and 0xFF) / 255f + val top = max(r, max(g, b)) + val delta = top - min(r, min(g, b)) + val hue = when { + delta == 0f -> 0f + top == r -> 60f * (((g - b) / delta) % 6f) + top == g -> 60f * ((b - r) / delta + 2f) + else -> 60f * ((r - g) / delta + 4f) + } + return Hsv(if (hue < 0f) hue + 360f else hue, if (top == 0f) 0f else delta / top, top) + } + + fun fromHsv(h: Float, s: Float, v: Float): Int = + Color.hsv(h.mod(360f), s.coerceIn(0f, 1f), v.coerceIn(0f, 1f)).toArgb() + + fun hex(argb: Int): String = "#%06X".format(argb and 0xFFFFFF) + + /** `#RRGGBB`, `RRGGBB` or `#RGB`; null for anything else. */ + fun parseHex(text: String): Int? { + val digits = text.trim().removePrefix("#") + if (!digits.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null + val full = when (digits.length) { + 3 -> digits.map { "$it$it" }.joinToString("") + 6 -> digits + else -> return null + } + return 0xFF000000.toInt() or full.toInt(16) + } + + private const val MIN_LUMINANCE = 0.10f + + /** The colour, made brighter (then paler, for a deep blue that brightness alone cannot lift) when it is too dark to show on the dark background. */ + fun readable(argb: Int): Int { + if (Color(argb).luminance() >= MIN_LUMINANCE) return argb or 0xFF000000.toInt() + val hsv = toHsv(argb) + var v = hsv.v + var s = hsv.s + var out = argb + while (v < 1f && Color(out).luminance() < MIN_LUMINANCE) { + v = min(1f, v + 0.04f) + out = fromHsv(hsv.h, s, v) + } + while (s > 0f && Color(out).luminance() < MIN_LUMINANCE) { + s = max(0f, s - 0.04f) + out = fromHsv(hsv.h, s, v) + } + return out + } + + /** A dark neutral tinted with the primary's hue; plain greys stay grey. */ + fun tint(primary: Int, saturation: Float, brightness: Float): Color { + val hsv = toHsv(primary) + val strength = (hsv.s / 0.35f).coerceIn(0f, 1f) + return Color(fromHsv(hsv.h, saturation * strength, brightness)) + } + + private val DARK_INK = Color(0xFF03111F) + + private fun contrast(a: Color, b: Color): Float { + val x = a.luminance() + val y = b.luminance() + return (max(x, y) + 0.05f) / (min(x, y) + 0.05f) + } + + /** Dark or white text, whichever reads better on the worse of the two gradient colours. */ + internal fun inkFor(first: Int, second: Int): Color { + fun worst(ink: Color) = min(contrast(ink, Color(first)), contrast(ink, Color(second))) + return if (worst(DARK_INK) >= worst(Color.White)) DARK_INK else Color.White + } + + private const val MIN_CONTRAST = 4.5f + + /** The colour nudged just enough, lighter for dark text and darker for white text, to give [ink] a readable contrast on it. */ + private fun fitTo(ink: Color, argb: Int): Int { + fun short(color: Int) = contrast(ink, Color(color)) < MIN_CONTRAST + if (!short(argb)) return argb + val hsv = toHsv(argb) + var v = hsv.v + var s = hsv.s + var out = argb + if (ink == DARK_INK) { + while (v < 1f && short(out)) { v = min(1f, v + 0.04f); out = fromHsv(hsv.h, s, v) } + while (s > 0f && short(out)) { s = max(0f, s - 0.04f); out = fromHsv(hsv.h, s, v) } + } else { + while (v > 0f && short(out)) { v = max(0f, v - 0.04f); out = fromHsv(hsv.h, s, v) } + } + return out + } + + private fun gradientColor(first: Int, second: Int, fraction: Float): Int { + fun channel(shift: Int): Int { + val a = first shr shift and 0xFF + val b = second shr shift and 0xFF + return (a + (b - a) * fraction).roundToInt().coerceIn(0, 255) + } + return (0xFF shl 24) or (channel(16) shl 16) or (channel(8) shl 8) or channel(0) + } + + private fun gradientFits(ink: Color, first: Int, second: Int): Boolean = + (0..32).all { step -> + val color = gradientColor(first, second, step / 32f) + contrast(ink, Color(color)) >= MIN_CONTRAST + 0.02f + } + + private fun moveTowardContrast(ink: Color, argb: Int): Int { + val hsv = toHsv(argb) + return if (ink == DARK_INK) { + when { + hsv.v < 1f -> fromHsv(hsv.h, hsv.s, min(1f, hsv.v + 0.04f)) + hsv.s > 0f -> fromHsv(hsv.h, max(0f, hsv.s - 0.04f), hsv.v) + else -> argb + } + } else { + fromHsv(hsv.h, hsv.s, max(0f, hsv.v - 0.04f)) + } + } + + fun palette(primary: Int, secondary: Int, label: Int, detail: Int): Palette { + val seen = readable(primary) + val seenSecond = readable(secondary) + val ink = inkFor(seen, seenSecond) + var first = fitTo(ink, seen) + var second = fitTo(ink, seenSecond) + while (!gradientFits(ink, first, second)) { + val nextFirst = moveTowardContrast(ink, first) + val nextSecond = moveTowardContrast(ink, second) + if (nextFirst == first && nextSecond == second) break + first = nextFirst + second = nextSecond + } + return Palette( + Themes.GRADIENT, label, detail, + background = tint(first, 0.50f, 0.045f), surface = tint(first, 0.45f, 0.075f), surfaceVariant = tint(first, 0.42f, 0.115f), + line = tint(first, 0.40f, 0.16f), line2 = tint(first, 0.36f, 0.22f), + onBackground = tint(first, 0.05f, 0.97f), onSurfaceVariant = tint(first, 0.16f, 0.68f), + primary = Color(first), primary2 = Color(second), onPrimary = ink, signal = Color(first), + good = Color(0xFF4CD37F), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt b/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt index 44c53a670..3a122445c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt @@ -16,7 +16,7 @@ import com.droiddeck.launcher.session.SessionPrefs internal class TrackingRecoveryView(context: Context) : View(context) { private var recovery: TrackingRecovery? = null private var stereo = false - private var palette = Themes.byId(SessionPrefs.theme(context)) + private var palette = Themes.current(context) private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private var blocks = emptyList() @@ -26,7 +26,7 @@ internal class TrackingRecoveryView(context: Context) : View(context) { importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES } - fun update(state: TrackingRecovery?, stereo: Boolean, palette: Palette = Themes.byId(SessionPrefs.theme(context))) { + fun update(state: TrackingRecovery?, stereo: Boolean, palette: Palette = Themes.current(context)) { if (recovery == state && this.stereo == stereo && this.palette == palette) return recovery = state this.stereo = stereo diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8002743d4..b54ebd9aa 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1460,6 +1460,14 @@ خلفية سوداء ونص أبيض، والأزرق لكل عنصر محدَّد أو مُركَّز عليه أو مضغوط. كحلي كهربائي أزرق أكثر سطوعًا، على أسطح تميل إلى الكحلي بدلاً من الرمادي. + تدرج لوني + لونك الأساسي والثانوي، ممزوجان على الأزرار وعناصر التمييز. + الأساسي + الثانوي + درجة اللون + التشبع + السطوع + لون سداسي (Hex) Esc Ins Del diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index fb9b4c3c9..a7908694c 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1468,6 +1468,14 @@ Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o presionado. Azul marino eléctrico Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris. + Degradado + Tus propios colores primario y secundario, combinados en botones y resaltados. + Primario + Secundario + Tono + Saturación + Brillo + Color hexadecimal Esc Insert Supr diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 52c09a015..ecd90da46 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1404,6 +1404,14 @@ Черен фон, бял текст, синьото за всичко избрано, на фокус или натиснато. Яркосиньо По-яркото синьо, върху повърхности, по-близки до тъмносиньо, вместо сиво. + Градиент + Вашите основен и вторичен цвят, смесени в бутоните и акцентите. + Основен + Вторичен + Нюанс + Наситеност + Яркост + Hex цвят Esc Ins Del diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9df47991c..292988476 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1432,6 +1432,14 @@ Černé pozadí, bílé písmo, modrá pro vše vybrané, zaměřené nebo stisknuté. Elektrická modř Jasnější modrá na povrchách, které jsou spíše tmavomodré než šedé. + Přechod + Vaše vlastní primární a sekundární barva, prolnuté na tlačítkách a zvýrazněních. + Primární + Sekundární + Odstín + Sytost + Jas + Barva hex Esc Ins Del diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6b200711d..a7d2f5575 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1404,6 +1404,14 @@ Sort baggrund, hvid skrift, blå til alt, der er valgt, fokuseret eller trykket. Elektrisk marineblå Den blå er klarere, på overflader der trækker mod marineblåt i stedet for gråt. + Gradient + Dine egne primære og sekundære farver, blandet på knapper og fremhævninger. + Primær + Sekundær + Farvetone + Mætning + Lysstyrke + Hex-farve Esc Ins Del diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d284aa127..8376b925a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1393,6 +1393,14 @@ Schwarzer Grund, weiße Schrift, das Blau für alles Ausgewählte, Fokussierte oder Gedrückte. Elektrisches Marineblau Das Blau heller, auf Flächen, die eher zu Marineblau als zu Grau tendieren. + Farbverlauf + Eigene Primär- und Sekundärfarbe, als Verlauf auf Schaltflächen und Hervorhebungen. + Primär + Sekundär + Farbton + Sättigung + Helligkeit + Hex-Farbe Esc Einfg Entf diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 409cda9ec..3e75ec7cf 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1404,6 +1404,14 @@ Μαύρο φόντο, λευκά γράμματα, το μπλε για ό,τι είναι επιλεγμένο, εστιασμένο ή πατημένο. Ηλεκτρικό ναυτικό μπλε Το μπλε πιο έντονο, σε επιφάνειες που τείνουν προς το ναυτικό μπλε αντί για γκρι. + Διαβάθμιση + Δικά σας πρωτεύον και δευτερεύον χρώμα, σε μίξη στα κουμπιά και τις επισημάνσεις. + Πρωτεύον + Δευτερεύον + Απόχρωση + Κορεσμός + Φωτεινότητα + Χρώμα hex Esc Ins Del diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 85e1bf0a4..1e44f9402 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1417,6 +1417,14 @@ Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o pulsado. Azul marino eléctrico Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris. + Degradado + Tus propios colores primario y secundario, combinados en botones y resaltados. + Primario + Secundario + Tono + Saturación + Brillo + Color hexadecimal Esc Insert Supr diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index b6757f5e7..3f8ada5c8 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1404,6 +1404,14 @@ Musta pohja, valkoinen teksti, sininen kaikessa valitussa, kohdistetussa tai painetussa. Sähköinen laivastonsininen Sininen kirkkaampana pinnoilla, jotka taittuvat harmaan sijaan laivastonsiniseen. + Liukuväri + Omat ensisijainen ja toissijainen värisi, sulautettuina painikkeisiin ja korostuksiin. + Ensisijainen + Toissijainen + Sävy + Kylläisyys + Kirkkaus + Hex-väri Esc Ins Del diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index af743337c..f1b72a75a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1417,6 +1417,14 @@ Fond noir, texte blanc, et le bleu pour tout élément sélectionné, ciblé ou pressé. Marine électrique Un bleu plus vif, sur des surfaces qui tirent vers le marine plutôt que vers le gris. + Dégradé + Vos propres couleurs primaire et secondaire, fondues sur les boutons et les éléments en surbrillance. + Primaire + Secondaire + Teinte + Saturation + Luminosité + Couleur hex Échap Inser Suppr diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 38eac1193..3087a4f5d 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1404,6 +1404,14 @@ काली ज़मीन, सफ़ेद टाइप, चुने, फ़ोकस या दबाए गए हर चीज़ के लिए ब्लू रंग। इलेक्ट्रिक नेवी ब्लू रंग ज़्यादा चमकीला, उन सतहों पर जो ग्रे के बजाय नेवी की ओर झुकती हैं। + ग्रेडिएंट + आपके अपने प्राथमिक और द्वितीयक रंग, बटनों और हाइलाइट पर मिश्रित। + प्राथमिक + द्वितीयक + रंगत + संतृप्ति + चमक + हेक्स रंग Esc Ins Del diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a307b5aed..b2f654b13 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1404,6 +1404,14 @@ Fekete alap, fehér betűk, a kék minden kijelölt, fókuszált vagy lenyomott elemhez. Elektromos tengerészkék Fényesebb kék, szürke helyett tengerészkék felületeken. + Színátmenet + Saját elsődleges és másodlagos színed, átmenettel a gombokon és a kiemeléseken. + Elsődleges + Másodlagos + Színárnyalat + Telítettség + Fényerő + Hex szín Esc Ins Del diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index f1cbe9543..da22c11fe 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1390,6 +1390,14 @@ Dasar hitam, tipe putih, biru untuk semua yang dipilih, difokuskan, atau ditekan. Navy elektrik Biru yang lebih cerah, pada permukaan yang condong ke navy, bukan abu-abu. + Gradasi + Warna primer dan sekunder pilihan Anda, dipadukan pada tombol dan sorotan. + Primer + Sekunder + Rona + Saturasi + Kecerahan + Warna hex Esc Ins Del diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9493ae1b1..1588bfe3f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1407,6 +1407,14 @@ Fondo nero, testo bianco, il blu per tutto ciò che è selezionato, attivo o premuto. Blu elettrico Il blu più brillante, su superfici che tendono al blu navy invece che al grigio. + Gradiente + I tuoi colori primario e secondario, sfumati su pulsanti ed evidenziazioni. + Primario + Secondario + Tonalità + Saturazione + Luminosità + Colore esadecimale Esc Ins Canc diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 143351f82..f3ce1c5f7 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1392,6 +1392,14 @@ 黒の背景に白の文字、選択・フォーカス・押下時には青を使用します。 エレクトリックネイビー より明るい青を、グレーではなくネイビー寄りの画面に使用します。 + グラデーション + 自分で選んだプライマリとセカンダリの色を、ボタンやハイライトにグラデーションで使います。 + プライマリ + セカンダリ + 色相 + 彩度 + 明度 + 16進カラー Esc Ins Del diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 901a84e7d..45966e788 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1392,6 +1392,14 @@ 검은색 배경에 흰색 글자, 선택·포커스·누름 상태에는 파란색을 사용합니다. 일렉트릭 네이비 회색 대신 네이비 톤의 화면에 더 밝은 파란색을 사용합니다. + 그라데이션 + 직접 고른 기본색과 보조색이 버튼과 강조 표시에 섞여 적용됩니다. + 기본색 + 보조색 + 색조 + 채도 + 명도 + 16진수 색상 Esc Ins Del diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 6c5acab20..49cf1e98a 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1390,6 +1390,14 @@ Latar hitam, teks putih, biru untuk semua yang dipilih, difokus atau ditekan. Navy elektrik Biru lebih cerah, pada permukaan yang lebih cenderung navy daripada kelabu. + Kecerunan + Warna utama dan sekunder pilihan anda, diadun pada butang dan sorotan. + Utama + Sekunder + Rona + Ketepuan + Kecerahan + Warna hex Esc Ins Del diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 6bbab5819..d391715a7 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1404,6 +1404,14 @@ Svart bakgrunn, hvit skrift, det blå for alt som er valgt, fokusert eller trykket. Elektrisk marineblå Det blå lysere, på flater som lener mer mot marineblått enn grått. + Gradient + Dine egne primær- og sekundærfarger, blandet på knapper og uthevinger. + Primær + Sekundær + Fargetone + Metning + Lysstyrke + Hex-farge Esc Ins Del diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 12400b3e8..fa6cca7f0 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1404,6 +1404,14 @@ Zwarte achtergrond, witte tekst, het blauw voor alles wat geselecteerd, gefocust of ingedrukt is. Elektrisch marineblauw Het blauw feller, op vlakken die naar marineblauw neigen in plaats van grijs. + Kleurverloop + Je eigen primaire en secundaire kleur, doorlopend op knoppen en accenten. + Primair + Secundair + Tint + Verzadiging + Helderheid + Hex-kleur Esc Ins Del diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d80cd8cec..ab532f448 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1432,6 +1432,14 @@ Czarne tło, biały tekst i niebieski dla wszystkiego, co zaznaczone, aktywne lub naciśnięte. Elektryczny granat Jaśniejszy niebieski na powierzchniach wpadających w granat zamiast w szarość. + Gradient + Własny kolor podstawowy i dodatkowy, płynnie przechodzące na przyciskach i wyróżnieniach. + Podstawowy + Dodatkowy + Odcień + Nasycenie + Jasność + Kolor hex Esc Ins Del diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index fed2cf78e..32d7c1615 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1407,6 +1407,14 @@ Fundo preto, texto branco e o azul para tudo o que está selecionado, focado ou premido. Azul-marinho elétrico O azul mais vivo, em superfícies que tendem para o azul-marinho em vez do cinzento. + Gradiente + As suas próprias cores primária e secundária, combinadas nos botões e destaques. + Primária + Secundária + Matiz + Saturação + Brilho + Cor hexadecimal Esc Ins Del diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 37559d301..f23e816ca 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1407,6 +1407,14 @@ Fundo preto, texto branco e o azul em tudo que estiver selecionado, em foco ou pressionado. Marinho elétrico O azul mais vivo, sobre superfícies puxadas para o marinho em vez do cinza. + Gradiente + Suas próprias cores primária e secundária, combinadas em botões e destaques. + Primária + Secundária + Matiz + Saturação + Brilho + Cor hexadecimal Esc Ins Del diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 5ddf3f0a5..3307a935e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1418,6 +1418,14 @@ Fundal negru, text alb, albastrul pentru tot ce este selectat, focalizat sau apăsat. Bleumarin electric Albastrul mai strălucitor, pe suprafețe care tind spre bleumarin în loc de gri. + Gradient + Culorile tale primară și secundară, combinate pe butoane și evidențieri. + Primară + Secundară + Nuanță + Saturație + Luminozitate + Culoare hex Esc Ins Del diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 83295afb9..b912603eb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1431,6 +1431,14 @@ Черный фон, белый текст, синий — для всего выбранного, активного или нажатого. Электрик Более яркий синий на поверхностях темно-синего, а не серого оттенка. + Градиент + Ваши основной и дополнительный цвета, плавно переходящие на кнопках и выделениях. + Основной + Дополнительный + Оттенок + Насыщенность + Яркость + Цвет hex Esc Ins Del diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3bab33523..2ff6e6084 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1404,6 +1404,14 @@ Svart bakgrund, vit text, den blå färgen för allt som är markerat, i fokus eller nedtryckt. Elektrisk marinblå Den blå färgen klarare, på ytor som lutar åt marinblått i stället för grått. + Gradient + Dina egna primära och sekundära färger, blandade på knappar och markeringar. + Primär + Sekundär + Nyans + Mättnad + Ljusstyrka + Hex-färg Esc Ins Del diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 621087784..3019dc8e4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1390,6 +1390,14 @@ พื้นดำ ตัวอักษรขาว สีฟ้าสำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด กรมท่าไฟฟ้า สีฟ้าสว่างขึ้น บนพื้นผิวที่เอียงไปทางกรมท่ามากกว่าเทา + ไล่เฉดสี + สีหลักและสีรองที่คุณเลือกเอง ผสมกันบนปุ่มและส่วนที่เน้น + สีหลัก + สีรอง + เฉดสี + ความอิ่มตัว + ความสว่าง + สีเลขฐานสิบหก Esc Ins Del diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 33107674a..f7a6c9c24 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1393,6 +1393,14 @@ Siyah zemin, beyaz yazı; seçilen, odaklanan veya basılan her şey için mavi. Elektrik laciverti Daha parlak mavi; gri yerine laciverte çalan yüzeylerde. + Geçiş + Kendi birincil ve ikincil renklerin, düğmelerde ve vurgularda harmanlanır. + Birincil + İkincil + Ton + Doygunluk + Parlaklık + Hex rengi Esc Ins Del diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5bc3cd8f1..30c30d0b1 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1432,6 +1432,14 @@ Чорне тло, білий текст і синій для всього вибраного, у фокусі чи натиснутого. Електричний темно-синій Яскравіший синій на поверхнях із темно-синім відтінком замість сірого. + Градієнт + Ваші основний і додатковий кольори, що плавно переходять на кнопках і виділеннях. + Основний + Додатковий + Відтінок + Насиченість + Яскравість + Колір hex Esc Ins Del diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 874e91cd7..73e1d1395 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1390,6 +1390,14 @@ Nền đen, chữ trắng, xanh cho mọi thứ được chọn, lấy nét hoặc nhấn. Xanh điện navy Màu xanh sáng hơn, trên các bề mặt nghiêng về navy thay vì xám. + Chuyển màu + Màu chính và màu phụ do bạn chọn, hòa trộn trên các nút và phần nổi bật. + Chính + Phụ + Sắc độ + Độ bão hòa + Độ sáng + Màu hex Esc Ins Del diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 92966d30f..59737e325 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1468,6 +1468,14 @@ 黑色背景、白色文字,选中、聚焦或按下的元素使用蓝色。 电光藏青 更明亮的蓝色,配以偏藏青而非灰色的界面。 + 渐变 + 你自己选择的主色和辅色,在按钮和高亮处渐变呈现。 + 主色 + 辅色 + 色相 + 饱和度 + 亮度 + 十六进制颜色 Esc Ins Del diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 2b7b254d1..3c75bce45 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1392,6 +1392,14 @@ 黑色背景、白色文字,所有已選取、焦點所在或按下的項目都使用藍色。 電光海軍藍 更明亮的藍色,搭配偏海軍藍而非灰色的介面。 + 漸層 + 你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。 + 主色 + 輔色 + 色相 + 飽和度 + 亮度 + 十六進位色彩 Esc Ins Del diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 47a76574f..c87e74a20 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1392,6 +1392,14 @@ 黑色背景、白色文字,選取、聚焦或按下的項目使用藍色。 電光藏青 更亮的藍色,搭配偏藏青而非灰色的介面。 + 漸層 + 你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。 + 主色 + 輔色 + 色相 + 飽和度 + 亮度 + 十六進位色彩 Esc Ins Del diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ea43eba0..6e95848c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1459,6 +1459,14 @@ Black ground, white type, the blue for everything selected, focused or pressed. Electric navy The blue brighter, on surfaces that lean navy instead of grey. + Gradient + Your own primary and secondary colors, blended on buttons and highlights. + Primary + Secondary + Hue + Saturation + Brightness + Hex color Esc Ins Del diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt new file mode 100644 index 000000000..b6f4de204 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt @@ -0,0 +1,135 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ThemeColorsTest { + private val app get() = RuntimeEnvironment.getApplication() + + @Before fun clear() { + app.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + } + + private fun near(expected: Int, actual: Int) { + for (shift in listOf(16, 8, 0)) assertTrue("${ThemeColors.hex(expected)} vs ${ThemeColors.hex(actual)}", Math.abs((expected shr shift and 0xFF) - (actual shr shift and 0xFF)) <= 1) + } + + @Test fun hueSaturationAndBrightnessComeBackAsTheSameColour() { + for (argb in listOf(0xFF8B5CF6.toInt(), 0xFF22D3EE.toInt(), 0xFFFF0000.toInt(), 0xFF336699.toInt(), 0xFF808080.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt())) { + val hsv = ThemeColors.toHsv(argb) + near(argb, ThemeColors.fromHsv(hsv.h, hsv.s, hsv.v)) + } + } + + @Test fun hexIsReadWithOrWithoutTheHashAndInShortForm() { + assertEquals(0xFF8B5CF6.toInt(), ThemeColors.parseHex("#8B5CF6")) + assertEquals(0xFF8B5CF6.toInt(), ThemeColors.parseHex("8b5cf6")) + assertEquals(0xFFFF00AA.toInt(), ThemeColors.parseHex(" #f0a ")) + assertEquals("#8B5CF6", ThemeColors.hex(0xFF8B5CF6.toInt())) + for (bad in listOf("", "#", "#12", "#1234", "#1234567", "zzzzzz", "#12345g")) assertNull(bad, ThemeColors.parseHex(bad)) + } + + @Test fun aTooDarkColourIsLiftedAndABrightOneIsLeftAlone() { + val dark = ThemeColors.readable(0xFF050505.toInt()) + assertTrue(Color(dark).luminance() >= 0.10f) + assertEquals(0xFF8B5CF6.toInt(), ThemeColors.readable(0xFF8B5CF6.toInt())) + } + + @Test fun aDeepBlueThatBrightnessAloneCannotLiftIsMadePaler() { + assertTrue(Color(ThemeColors.readable(0xFF0000FF.toInt())).luminance() >= 0.10f) + assertTrue(Color(ThemeColors.readable(0xFF000000.toInt())).luminance() >= 0.10f) + } + + @Test fun theTextColourSuitsTheWorseOfTheTwoGradientColours() { + assertEquals(Color(0xFF03111F), ThemeColors.inkFor(0xFFFFEB3B.toInt(), 0xFF3B4FA8.toInt())) + assertEquals(Color.White, ThemeColors.inkFor(0xFF3B4FA8.toInt(), 0xFF5B2FA8.toInt())) + assertEquals(Color(0xFF03111F), ThemeColors.inkFor(0xFFFFFFFF.toInt(), 0xFFFFEB3B.toInt())) + } + + private fun contrast(a: Color, b: Color) = (maxOf(a.luminance(), b.luminance()) + 0.05f) / (minOf(a.luminance(), b.luminance()) + 0.05f) + + @Test fun buttonTextIsReadableOnBothGradientColoursForAnyPair() { + val picks = listOf(0xFFFFEB3B, 0xFF1E3A8A, 0xFF0000FF, 0xFFFF0000, 0xFF00FF00, 0xFF808080, 0xFFFFFFFF, 0xFF000000, 0xFF8B5CF6, 0xFF22D3EE, 0xFFFF5722, 0xFF3F51B5) + for (a in picks) for (b in picks) { + val p = ThemeColors.palette(a.toInt(), b.toInt(), 0, 0) + val first = p.primary.toArgb() + val second = p.primary2.toArgb() + for (step in 0..100) { + val fraction = step / 100f + val color = Color( + 0xFF000000.toInt() or + (channel(first, 16, second, fraction) shl 16) or + (channel(first, 8, second, fraction) shl 8) or + channel(first, 0, second, fraction), + ) + assertTrue( + "${ThemeColors.hex(first)} ${ThemeColors.hex(second)} at $fraction", + contrast(p.onPrimary, color) >= 4.5f, + ) + } + } + } + + private fun channel(first: Int, shift: Int, second: Int, fraction: Float): Int { + val start = first shr shift and 0xFF + val end = second shr shift and 0xFF + return (start + (end - start) * fraction).toInt().coerceIn(0, 255) + } + + @Test fun onlyTheStopThatFailsIsNudged() { + val p = ThemeColors.palette(0xFFFFEB3B.toInt(), 0xFF1E3A8A.toInt(), 0, 0) + assertEquals(Color(0xFFFFEB3B), p.primary) + assertTrue(p.primary2 != Color(0xFF1E3A8A)) + assertEquals(Color(0xFF03111F), p.onPrimary) + } + + @Test fun thePaletteKeepsTheTwoColoursAndStaysDarkAndReadable() { + val p = ThemeColors.palette(0xFF8B5CF6.toInt(), 0xFF22D3EE.toInt(), 0, 0) + assertEquals(p.primary, p.signal) + assertEquals(Color(0xFF22D3EE), p.primary2) + assertTrue(contrast(p.onPrimary, p.primary) >= 4.5f) + assertTrue(contrast(p.onPrimary, p.primary2) >= 4.5f) + assertTrue(p.background.luminance() < p.surface.luminance()) + assertTrue(p.surface.luminance() < p.surfaceVariant.luminance()) + assertTrue(p.background.luminance() < 0.02f) + assertTrue(p.onBackground.luminance() > 0.8f) + assertEquals(Color(0xFF03111F), ThemeColors.palette(0xFFFFEB3B.toInt(), 0xFFFFFFFF.toInt(), 0, 0).onPrimary) + assertEquals(Color.White, ThemeColors.palette(0xFF1E40AF.toInt(), 0xFF1D4ED8.toInt(), 0, 0).onPrimary) + } + + @Test fun aGreyPrimaryGivesGreyNeutrals() { + val p = ThemeColors.palette(0xFFB0B0B0.toInt(), 0xFFFFFFFF.toInt(), 0, 0) + assertEquals(p.background.red, p.background.blue, 0.002f) + } + + @Test fun theGradientThemeFollowsTheSavedColoursAndComesBackAfterARestart() { + Themes.setGradient(app, 0xFFFF5722.toInt(), 0xFF3F51B5.toInt()) + val live = Themes.byId(Themes.GRADIENT) + assertEquals(0xFFFF5722.toInt() to 0xFF3F51B5.toInt(), SessionPrefs.gradientColors(app)) + assertTrue(contrast(live.onPrimary, live.primary) >= 4.5f) + assertTrue(contrast(live.onPrimary, live.primary2) >= 4.5f) + SessionPrefs.setTheme(app, Themes.GRADIENT) + Themes.setGradient(app, Themes.DEFAULT_PRIMARY, Themes.DEFAULT_SECONDARY) + SessionPrefs.setGradientColors(app, 0xFF00FF00.toInt(), 0xFF0000FF.toInt()) + assertEquals(Color(0xFF00FF00), Themes.current(app).primary) + } + + @Test fun theGradientIsAChoiceInTheThemeList() { + assertTrue(Themes.all.any { it.id == Themes.GRADIENT }) + assertEquals(Themes.GRAPHITE, Themes.byId("nonsense").id) + } +} diff --git a/docs/development/themes.md b/docs/development/themes.md new file mode 100644 index 000000000..b7f1a01eb --- /dev/null +++ b/docs/development/themes.md @@ -0,0 +1,25 @@ +# Themes + +A theme is a `Palette` (`ui/Theme.kt`): a dark background scale (`background`, `surface`, +`surfaceVariant`, `line`, `line2`), two text colours, and the accent: `primary` / `signal` +(flat accent, also Material's primary), `primary2` (the darker or second accent) and +`onPrimary`. `PrimaryButton` and the launch flood already draw a gradient from `primary` to +`primary2`. The choice is stored as an id (`SessionPrefs.theme`) and picked in Setup › Launcher. + +## Gradient theme + +`Themes.GRADIENT` is the one theme whose colours the person picks: a primary and a secondary, +kept in `SessionPrefs.gradientColors` and in `Themes.gradientColors`, a Compose state, so a change +restyles the whole app at once. `App.onCreate` loads the saved colours; code outside a composition +uses `Themes.current(context)`. + +`ThemeColors.palette` builds the full palette from the two colours: primary becomes +`primary` / `signal`, secondary becomes `primary2`, the background scale is a dark neutral tinted with +the primary's hue (plain grey for a grey primary), and `onPrimary` is dark or white by the +luminance of the pair. A colour too dark to show on the dark background is lifted +(`ThemeColors.readable`) rather than refused. + +Setup shows Primary and Secondary under the theme when Gradient is chosen, each a small colour +square. It opens `ColorPickerDialog`: a wheel (hue and saturation, touch), hue / saturation / +brightness sliders (they work with a controller) and a hex box, with the old and new colour side by +side. OK applies it.