diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index 1a3703822d..ce24ac78d6 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -119,7 +119,7 @@ actual fun ImageBitmap.addLogo(size: Float): ImageBitmap = asAndroidBitmap().app drawBitmap(logo, null, android.graphics.Rect(0, 0, logoSize, logoSize), null) }.asImageBitmap() -actual fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap = asAndroidBitmap().scale(width, height).asImageBitmap() +actual fun ImageBitmap.scale(width: Int, height: Int, highQuality: Boolean): ImageBitmap = asAndroidBitmap().scale(width, height).asImageBitmap() actual fun isImage(uri: URI): Boolean = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(uri)?.split(".")?.last())?.contains("image/") == true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index d7a241d5ee..e8a8f86344 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -25,7 +25,7 @@ expect fun GrayU8.toImageBitmap(): ImageBitmap expect fun ImageBitmap.hasAlpha(): Boolean expect fun ImageBitmap.addLogo(size: Float): ImageBitmap -expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap +expect fun ImageBitmap.scale(width: Int, height: Int, highQuality: Boolean = false): ImageBitmap expect fun isImage(uri: URI): Boolean expect fun isAnimImage(uri: URI, drawable: Any?): Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt index 6df5eefbe3..24e55ec18c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt @@ -1,36 +1,364 @@ -package chat.simplex.common.ui.theme - -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.colorspace.ColorSpaces -import kotlin.math.cos -import kotlin.math.sin - -fun oklch(L: Float, C: Float, H: Float, alpha: Float = 1f): Color { - val hRad = H * (Math.PI.toFloat() / 180f) - return Color(L, C * cos(hRad), C * sin(hRad), alpha, ColorSpaces.Oklab) -} - -val Indigo = Color(0xFF9966FF) -val SimplexBlue = oklch(0.6320536f, 0.2017874f, 254.0879f) // If this value changes also need to update #0088ff in string resource files -val SimplexGreen = oklch(0.7871495f, 0.1979258f, 146.6814f) // #ff4dda67 -val SecretColor = oklch(0.5998708f, 0f, 0f, 0.2509804f) // #40808080 -val LightGray = oklch(0.9615242f, 0.005440391f, 274.9652f) // #fff1f2f6 -val DarkGray = oklch(0.2928853f, 0.003884885f, 264.5058f) // #ff2b2c2e -val HighOrLowlight = oklch(0.6265517f, 0.005036114f, 34.30441f) // #ff8b8786 -val MessagePreviewDark = Color(179, 175, 174, 255) -val MessagePreviewLight = Color(49, 45, 44, 255) -val ToolbarLight = Color(220, 220, 220, 12) -val ToolbarDark = Color(80, 80, 80, 12) -val SettingsSecondaryLight = Color(200, 196, 195, 90) -val GroupDark = Color(80, 80, 80, 60) -val IncomingCallLight = Color(239, 237, 236, 255) -val WarningOrange = Color(255, 127, 0, 255) -val WarningYellow = Color(255, 192, 0, 255) -val FileLight = Color(191, 194, 199, 255) -val FileDark = Color(94, 94, 98, 255) - -val MenuTextColor: Color @Composable get () = if (isInDarkTheme()) LocalContentColor.current.copy(alpha = 0.8f) else Color.Black -val NoteFolderIconColor: Color @Composable get() = MaterialTheme.appColors.primaryVariant2 +package chat.simplex.common.ui.theme + +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.views.helpers.PresetWallpaper +import chat.simplex.common.views.helpers.WallpaperType +import chat.simplex.common.views.helpers.mixWith +import kotlin.math.* + +fun oklch(L: Float, C: Float, H: Float, alpha: Float = 1f): Color { + val hRad = H * (Math.PI.toFloat() / 180f) + val targetSpace = if (appPlatform.isDesktop) ColorSpaces.Srgb else ColorSpaces.DisplayP3 + return Color(L, C * cos(hRad), C * sin(hRad), alpha, ColorSpaces.Oklab).convert(targetSpace) +} + +/** Extract oklch components (L, C, H) from a Color. Round-trip safe with oklch(). */ +fun Color.toOklch(): FormulaSlot { + val lab = convert(ColorSpaces.Oklab) + val L = lab.component1() + val a = lab.component2() + val b = lab.component3() + val C = sqrt(a * a + b * b) + val H = if (C < 1e-6f) 0f else (atan2(b, a) * 180f / PI.toFloat()).let { if (it < 0) it + 360f else it } + return FormulaSlot(L, C, H) +} + +val Indigo = Color(0xFF9966FF) +val SimplexBlue = oklch(0.6320536f, 0.2017874f, 254.0879f) // If this value changes also need to update #0088ff in string resource files +val SimplexGreen = oklch(0.7871495f, 0.1979258f, 146.6814f) // #ff4dda67 +val SecretColor = oklch(0.5998708f, 0f, 0f, 0.2509804f) // #40808080 +val LightGray = oklch(0.9615242f, 0.005440391f, 274.9652f) // #fff1f2f6 +val DarkGray = oklch(0.2928853f, 0.003884885f, 264.5058f) // #ff2b2c2e +val HighOrLowlight = oklch(0.6265517f, 0.005036114f, 34.30441f) // #ff8b8786 +val MessagePreviewDark = Color(179, 175, 174, 255) +val MessagePreviewLight = Color(49, 45, 44, 255) +val ToolbarLight = Color(220, 220, 220, 12) +val ToolbarDark = Color(80, 80, 80, 12) +val SettingsSecondaryLight = Color(200, 196, 195, 90) +val GroupDark = Color(80, 80, 80, 60) +val IncomingCallLight = Color(239, 237, 236, 255) +val WarningOrange = Color(255, 127, 0, 255) +val WarningYellow = Color(255, 192, 0, 255) +val FileLight = Color(191, 194, 199, 255) +val FileDark = Color(94, 94, 98, 255) + +val MenuTextColor: Color @Composable get () = if (isInDarkTheme()) LocalContentColor.current.copy(alpha = 0.8f) else Color.Black +val NoteFolderIconColor: Color @Composable get() = MaterialTheme.appColors.primaryVariant2 + +/** Background color for panels (top app bar, bottom nav, status bar overlay). + * When current wallpaper is a preset and theme is LIGHT or DARK, panel gets a subtle hue tint + * matching the wallpaper. Otherwise falls back to the existing bg.mixWith(onBg, 0.97f) elevation. + * BLACK and SIMPLEX themes are not tinted (BLACK keeps pure dark, SIMPLEX has its own custom panel). */ +@Composable +fun panelBackgroundColor(): Color { + return currentWallpaperPanelTint() + ?: MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) +} + +@Composable +private fun currentWallpaperPanelTint(): Color? { + val state = CurrentColors.collectAsState().value + val type = state.wallpaper.type as? WallpaperType.Preset ?: return null + val preset = PresetWallpaper.from(type.filename) ?: return null + val hue = preset.hue(state.base) + return when (state.base) { + DefaultTheme.LIGHT -> oklch(1.0f, 0.03f, hue) + DefaultTheme.DARK -> oklch(0.1822f, 0.01f, hue) + else -> null + } +} + +// ===== Gamut boundary (analytical, Cardano) ===== + +/** Max chroma in Display P3 gamut for given oklch L and H (degrees). O(1), exact. */ +fun maxChromaP3(L: Float, H: Float): Float = maxChromaForMatrix(L, H, P3_FROM_LMS) + +/** Max chroma in sRGB gamut for given oklch L and H (degrees). O(1), exact. */ +fun maxChromaSRGB(L: Float, H: Float): Float = maxChromaForMatrix(L, H, SRGB_FROM_LMS) + +/** Max chroma for current platform's gamut. */ +fun maxChroma(L: Float, H: Float): Float = if (appPlatform.isDesktop) maxChromaSRGB(L, H) else maxChromaP3(L, H) + +// oklab → LMS coefficients: l_ = L + a*ka + b*kb +private val LMS_FROM_LAB = arrayOf( + floatArrayOf(1f, 0.3963377774f, 0.2158037573f), + floatArrayOf(1f, -0.1055613458f, -0.0638541728f), + floatArrayOf(1f, -0.0894841775f, -1.2914855480f), +) + +// LMS³ → linear Display P3 +private val P3_FROM_LMS = arrayOf( + floatArrayOf( 3.1281105148f, -2.2570749853f, 0.1293047593f), + floatArrayOf(-1.0911282009f, 2.4132668169f, -0.3221681599f), + floatArrayOf(-0.0260136845f, -0.5080276339f, 1.5333166364f), +) + +// LMS³ → linear sRGB +private val SRGB_FROM_LMS = arrayOf( + floatArrayOf( 4.0767416621f, -3.3077115913f, 0.2309699292f), + floatArrayOf(-1.2684380046f, 2.6097574011f, -0.3413193965f), + floatArrayOf(-0.0041960863f, -0.7034186147f, 1.7076147010f), +) + +private fun maxChromaForMatrix(L: Float, H: Float, matrix: Array): Float { + val hRad = H * (PI.toFloat() / 180f) + val cosH = cos(hRad); val sinH = sin(hRad) + + // Each LMS component is linear in C: l_(C) = p + q*C + val p = FloatArray(3); val q = FloatArray(3) + for (i in 0..2) { + p[i] = LMS_FROM_LAB[i][0] * L + q[i] = LMS_FROM_LAB[i][1] * cosH + LMS_FROM_LAB[i][2] * sinH + } + + // Each RGB channel = cubic in C. Solve channel=0 and channel=1. + var minC = Float.MAX_VALUE + for (ch in 0..2) { + val (m0, m1, m2) = matrix[ch] + val a = m0*q[0]*q[0]*q[0] + m1*q[1]*q[1]*q[1] + m2*q[2]*q[2]*q[2] + val b = 3f*(m0*q[0]*q[0]*p[0] + m1*q[1]*q[1]*p[1] + m2*q[2]*q[2]*p[2]) + val d = 3f*(m0*q[0]*p[0]*p[0] + m1*q[1]*p[1]*p[1] + m2*q[2]*p[2]*p[2]) + val e = m0*p[0]*p[0]*p[0] + m1*p[1]*p[1]*p[1] + m2*p[2]*p[2]*p[2] + + // channel = 0 + for (root in solveCubic(a, b, d, e)) { + if (root > 1e-6f && root < minC && allChannelsInGamut(root, p, q, matrix, ch)) minC = root + } + // channel = 1 + for (root in solveCubic(a, b, d, e - 1f)) { + if (root > 1e-6f && root < minC && allChannelsInGamut(root, p, q, matrix, ch)) minC = root + } + } + return if (minC == Float.MAX_VALUE) 0f else minC +} + +private fun allChannelsInGamut(C: Float, p: FloatArray, q: FloatArray, matrix: Array, skipCh: Int): Boolean { + val l = (p[0] + q[0] * C).let { it * it * it } + val m = (p[1] + q[1] * C).let { it * it * it } + val s = (p[2] + q[2] * C).let { it * it * it } + for (ch in 0..2) { + if (ch == skipCh) continue + val v = matrix[ch][0] * l + matrix[ch][1] * m + matrix[ch][2] * s + if (v < -1e-6f || v > 1f + 1e-6f) return false + } + return true +} + +private fun solveCubic(a: Float, b: Float, c: Float, d: Float): FloatArray { + if (abs(a) < 1e-10f) { + if (abs(b) < 1e-10f) { + return if (abs(c) < 1e-10f) floatArrayOf() else floatArrayOf(-d / c) + } + val disc = c * c - 4f * b * d + if (disc < 0f) return floatArrayOf() + val sq = sqrt(disc) + return floatArrayOf((-c + sq) / (2f * b), (-c - sq) / (2f * b)) + } + val p = b / a; val q = c / a; val r = d / a + val p1 = q - p * p / 3f + val q1 = r - p * q / 3f + 2f * p * p * p / 27f + val disc = q1 * q1 / 4f + p1 * p1 * p1 / 27f + + return when { + disc > 1e-10f -> { + val sqD = sqrt(disc) + val u = cbrt(-q1 / 2f + sqD) + val v = cbrt(-q1 / 2f - sqD) + floatArrayOf(u + v - p / 3f) + } + abs(disc) <= 1e-10f -> { + if (abs(q1) < 1e-10f) floatArrayOf(-p / 3f) + else { + val u = cbrt(-q1 / 2f) + floatArrayOf(2f * u - p / 3f, -u - p / 3f) + } + } + else -> { + val m = 2f * sqrt(-p1 / 3f) + val theta = acos(3f * q1 / (p1 * m)) / 3f + floatArrayOf( + m * cos(theta) - p / 3f, + m * cos(theta - 2f * PI.toFloat() / 3f) - p / 3f, + m * cos(theta - 4f * PI.toFloat() / 3f) - p / 3f + ) + } + } +} + +private fun cbrt(x: Float): Float { + return if (x >= 0f) x.toDouble().pow(1.0 / 3.0).toFloat() + else -((-x).toDouble().pow(1.0 / 3.0).toFloat()) +} + +// ===== Theme color formula ===== + +data class FormulaSlot(val L: Float, val C: Float, val H: Float) { + fun toColor(): Color = oklch(L, C, H) + fun toCodeString(): String = "oklch(${L}f, ${C}f, ${H}f)" +} + +data class FormulaResult( + val background: FormulaSlot, + val pattern: FormulaSlot, + val sentMessage: FormulaSlot, + val sentQuote: FormulaSlot, + val receivedMessage: FormulaSlot, + val receivedQuote: FormulaSlot, +) + +// ─── LIGHT ─── + +fun generateSchemeLight( + hue: Float, + bgL: Float, + bgC: Float, + step: Float, + patternDepth: Float = 2.5f, + patternChroma: Float? = null, + receivedTint: Float = 0.005f, + bgLOffset: Float = 0f, + saturationScale: Float = 1f, + contrastScale: Float = 1f, + patternIntensity: Float = 1f, +): FormulaResult { + val effBgC = bgC * saturationScale + val effStep = step * contrastScale + val effDepth = patternDepth * patternIntensity + val effPatternC = patternChroma?.let { it * patternIntensity } + val maxBg = maxChroma(bgL, hue) + val satRatio = if (maxBg > 0f) effBgC / maxBg else 0f + val loRatio = 0.35f + + data class Raw(val name: String, val L: Float) + val slots = listOf( + Raw("receivedMessage", 1f - receivedTint), + Raw("receivedQuote", 1f - effStep), + Raw("sentMessage", bgL + effStep / 3f), + Raw("background", (bgL + bgLOffset).coerceIn(0f, 1f)), + Raw("sentQuote", bgL - effStep), + Raw("pattern", bgL - effDepth * effStep), + ) + + val computed = mutableMapOf() + for (slot in slots) { + val maxC = maxChroma(slot.L, hue) + val c = when (slot.name) { + "receivedMessage" -> maxC + "background" -> effBgC + "pattern" -> if (effPatternC != null) min(effPatternC, maxC) else { + if (slot.L >= bgL) maxC * satRatio + else { + val t = if (effStep > 0f) min(1f, (bgL - slot.L) / (2.5f * effStep)) else 0f + maxC * (satRatio - (satRatio - loRatio) * t) + } + } + else -> if (slot.L >= bgL) maxC * satRatio + else { + val t = if (effStep > 0f) min(1f, (bgL - slot.L) / (2.5f * effStep)) else 0f + maxC * (satRatio - (satRatio - loRatio) * t) + } + } + computed[slot.name] = FormulaSlot(slot.L, min(c, maxC), hue) + } + + return FormulaResult( + background = computed["background"]!!, + pattern = computed["pattern"]!!, + sentMessage = computed["sentMessage"]!!, + sentQuote = computed["sentQuote"]!!, + receivedMessage = computed["receivedMessage"]!!, + receivedQuote = computed["receivedQuote"]!!, + ) +} + +// ─── DARK / SIMPLEX / BLACK ─── + +private data class DarkSlotDef(val lightnessMult: Float, val cluster: String, val intraFactor: Float) + +private val DARK_SLOTS = mapOf( + "background" to DarkSlotDef(0f, "muted", 0.90f), + "receivedMessage" to DarkSlotDef(2.0f, "muted", 1.00f), + "receivedQuote" to DarkSlotDef(3.0f, "muted", 1.15f), + "pattern" to DarkSlotDef(2.5f, "color", 0.90f), + "sentMessage" to DarkSlotDef(3.5f, "color", 1.00f), + "sentQuote" to DarkSlotDef(5.0f, "color", 1.15f), +) + +private val BLACK_SLOTS = mapOf( + "background" to DarkSlotDef(0f, "muted", 1.0f), + "receivedMessage" to DarkSlotDef(3.0f, "muted", 1.0f), + "receivedQuote" to DarkSlotDef(5.5f, "muted", 1.0f), + "sentMessage" to DarkSlotDef(6.0f, "color", 1.0f), + "sentQuote" to DarkSlotDef(8.25f, "color", 1.362f), + "pattern" to DarkSlotDef(9.0f, "color", 1.490f), +) + +fun generateSchemeDark( + hue: Float, + bgL: Float = 0.166f, + step: Float = 0.038f, + mutedChroma: Float = 0.020f, + colorChroma: Float = 0.063f, + saturationScale: Float = 1f, + contrastScale: Float = 1f, + patternIntensity: Float = 1f, +): FormulaResult = generateDarkFromSlots(hue, bgL, step, mutedChroma, colorChroma, DARK_SLOTS, false, saturationScale, contrastScale, patternIntensity) + +fun generateSchemeBlack( + hue: Float, + step: Float = 0.04f, + colorChroma: Float = 0.0522f, + saturationScale: Float = 1f, + contrastScale: Float = 1f, + patternIntensity: Float = 1f, +): FormulaResult = generateDarkFromSlots(hue, 0f, step, 0f, colorChroma, BLACK_SLOTS, true, saturationScale, contrastScale, patternIntensity) + +private fun generateDarkFromSlots( + hue: Float, bgL: Float, step: Float, + mutedChroma: Float, colorChroma: Float, + slotDefs: Map, + patternPinsToP3: Boolean, + saturationScale: Float, contrastScale: Float, patternIntensity: Float, +): FormulaResult { + val effStep = step * contrastScale + + // Baseline chroma per slot + val baselineC = mutableMapOf() + for ((name, def) in slotDefs) { + val clusterC = if (def.cluster == "muted") mutedChroma else colorChroma + baselineC[name] = clusterC * def.intraFactor + } + val bgCAnchor = baselineC["background"]!! + + val computed = mutableMapOf() + for ((name, def) in slotDefs) { + var lMult = def.lightnessMult + var baseC = baselineC[name]!! + if (name == "pattern") { + lMult *= patternIntensity + baseC = bgCAnchor + (baseC - bgCAnchor) * patternIntensity + } + val L = bgL + lMult * effStep + val C = when { + name == "background" -> bgCAnchor + name == "pattern" && patternPinsToP3 -> maxChroma(L, hue) + else -> bgCAnchor + (baseC - bgCAnchor) * saturationScale + }.let { min(it, maxChroma(L, hue)) } + computed[name] = FormulaSlot(L, C, hue) + } + + return FormulaResult( + background = computed["background"]!!, + pattern = computed["pattern"]!!, + sentMessage = computed["sentMessage"]!!, + sentQuote = computed["sentQuote"]!!, + receivedMessage = computed["receivedMessage"]!!, + receivedQuote = computed["receivedQuote"]!!, + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index fbc17c5e56..2069d7f67a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -57,6 +57,7 @@ class AppColors( sentQuote: Color, receivedMessage: Color, receivedQuote: Color, + toolbar: Color = Color.Transparent, ) { var title by mutableStateOf(title, structuralEqualityPolicy()) internal set @@ -70,6 +71,8 @@ class AppColors( internal set var receivedQuote by mutableStateOf(receivedQuote, structuralEqualityPolicy()) internal set + var toolbar by mutableStateOf(toolbar, structuralEqualityPolicy()) + internal set fun copy( title: Color = this.title, @@ -78,6 +81,7 @@ class AppColors( sentQuote: Color = this.sentQuote, receivedMessage: Color = this.receivedMessage, receivedQuote: Color = this.receivedQuote, + toolbar: Color = this.toolbar, ): AppColors = AppColors( title, primaryVariant2, @@ -85,6 +89,7 @@ class AppColors( sentQuote, receivedMessage, receivedQuote, + toolbar, ) override fun toString(): String { @@ -95,7 +100,8 @@ class AppColors( append("sentMessage=$sentMessage, ") append("sentQuote=$sentQuote, ") append("receivedMessage=$receivedMessage, ") - append("receivedQuote=$receivedQuote") + append("receivedQuote=$receivedQuote, ") + append("toolbar=$toolbar") append(")") } } @@ -138,7 +144,7 @@ class AppWallpaper( // Spec: spec/services/theme.md#ThemeColor enum class ThemeColor { - PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT; + PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TOOLBAR, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT; fun fromColors(colors: Colors, appColors: AppColors, appWallpaper: AppWallpaper): Color? { return when (this) { @@ -148,6 +154,7 @@ enum class ThemeColor { SECONDARY_VARIANT -> colors.secondaryVariant BACKGROUND -> colors.background SURFACE -> colors.surface + TOOLBAR -> appColors.toolbar TITLE -> appColors.title PRIMARY_VARIANT2 -> appColors.primaryVariant2 SENT_MESSAGE -> appColors.sentMessage @@ -167,6 +174,7 @@ enum class ThemeColor { SECONDARY_VARIANT -> generalGetString(MR.strings.color_secondary_variant) BACKGROUND -> generalGetString(MR.strings.color_background) SURFACE -> generalGetString(MR.strings.color_surface) + TOOLBAR -> "Toolbar" TITLE -> generalGetString(MR.strings.color_title) PRIMARY_VARIANT2 -> generalGetString(MR.strings.color_primary_variant2) SENT_MESSAGE -> generalGetString(MR.strings.color_sent_message) @@ -190,6 +198,7 @@ data class ThemeColors( val background: String? = null, @SerialName("menus") val surface: String? = null, + val toolbar: String? = null, val title: String? = null, @SerialName("accentVariant2") val primaryVariant2: String? = null, @@ -209,6 +218,7 @@ data class ThemeColors( secondaryVariant = colors.secondaryVariant.toReadableHex(), background = colors.background.toReadableHex(), surface = colors.surface.toReadableHex(), + toolbar = if (appColors.toolbar.alpha > 0f) appColors.toolbar.toReadableHex() else null, title = appColors.title.toReadableHex(), primaryVariant2 = appColors.primaryVariant2.toReadableHex(), sentMessage = appColors.sentMessage.toReadableHex(), @@ -227,6 +237,7 @@ data class ResolvedColors( val secondaryVariant: Color? = null, val background: Color? = null, val surface: Color? = null, + val toolbar: Color? = null, val title: Color? = null, val primaryVariant2: Color? = null, val sentMessage: Color? = null, @@ -243,6 +254,7 @@ data class ResolvedColors( secondaryVariant = tc.secondaryVariant?.colorFromReadableHex(), background = tc.background?.colorFromReadableHex(), surface = tc.surface?.colorFromReadableHex(), + toolbar = tc.toolbar?.colorFromReadableHex(), title = tc.title?.colorFromReadableHex(), primaryVariant2 = tc.primaryVariant2?.colorFromReadableHex(), sentMessage = tc.sentMessage?.colorFromReadableHex(), @@ -358,6 +370,7 @@ data class ThemeOverrides ( ThemeColor.SECONDARY_VARIANT -> colors.copy(secondaryVariant = color) ThemeColor.BACKGROUND -> colors.copy(background = color) ThemeColor.SURFACE -> colors.copy(surface = color) + ThemeColor.TOOLBAR -> colors.copy(toolbar = color) ThemeColor.TITLE -> colors.copy(title = color) ThemeColor.PRIMARY_VARIANT2 -> colors.copy(primaryVariant2 = color) ThemeColor.SENT_MESSAGE -> colors.copy(sentMessage = color) @@ -406,6 +419,7 @@ data class ThemeOverrides ( return baseColors.copy( title = perChatTheme?.title?.colorFromReadableHex() ?: perUserTheme?.title?.colorFromReadableHex() ?: colors.title?.colorFromReadableHex() ?: presetWallpaperTheme?.title ?: baseColors.title, primaryVariant2 = perChatTheme?.primaryVariant2?.colorFromReadableHex() ?: perUserTheme?.primaryVariant2?.colorFromReadableHex() ?: colors.primaryVariant2?.colorFromReadableHex() ?: presetWallpaperTheme?.primaryVariant2 ?: baseColors.primaryVariant2, + toolbar = perChatTheme?.toolbar?.colorFromReadableHex() ?: perUserTheme?.toolbar?.colorFromReadableHex() ?: colors.toolbar?.colorFromReadableHex() ?: presetWallpaperTheme?.toolbar ?: baseColors.toolbar, sentMessage = if (perChatTheme?.sentMessage != null) perChatTheme.sentMessage.colorFromReadableHex() else if (perUserTheme != null && (perChatWallpaperType == null || perUserWallpaperType == null || perChatWallpaperType.sameType(perUserWallpaperType))) perUserTheme.sentMessage?.colorFromReadableHex() ?: sentMessageFallback else sentMessageFallback, @@ -460,6 +474,7 @@ data class ThemeOverrides ( secondaryVariant = c.secondaryVariant.toReadableHex(), background = c.background.toReadableHex(), surface = c.surface.toReadableHex(), + toolbar = if (ac.toolbar.alpha > 0f) ac.toolbar.toReadableHex() else null, title = ac.title.toReadableHex(), primaryVariant2 = ac.primaryVariant2.toReadableHex(), sentMessage = ac.sentMessage.toReadableHex(), @@ -535,6 +550,7 @@ data class ThemeModeOverride ( ThemeColor.SECONDARY_VARIANT -> colors.copy(secondaryVariant = color) ThemeColor.BACKGROUND -> colors.copy(background = color) ThemeColor.SURFACE -> colors.copy(surface = color) + ThemeColor.TOOLBAR -> colors.copy(toolbar = color) ThemeColor.TITLE -> colors.copy(title = color) ThemeColor.PRIMARY_VARIANT2 -> colors.copy(primaryVariant2 = color) ThemeColor.SENT_MESSAGE -> colors.copy(sentMessage = color) @@ -601,6 +617,7 @@ data class ThemeModeOverride ( secondaryVariant = if (colors.secondaryVariant?.colorFromReadableHex() != c.secondaryVariant) colors.secondaryVariant else null, background = if (colors.background?.colorFromReadableHex() != c.background) colors.background else null, surface = if (colors.surface?.colorFromReadableHex() != c.surface) colors.surface else null, + toolbar = if (colors.toolbar?.colorFromReadableHex() != ac.toolbar) colors.toolbar else null, title = if (colors.title?.colorFromReadableHex() != ac.title) colors.title else null, primaryVariant2 = if (colors.primaryVariant2?.colorFromReadableHex() != ac.primaryVariant2) colors.primary else null, sentMessage = if (colors.sentMessage?.colorFromReadableHex() != ac.sentMessage) colors.sentMessage else null, @@ -788,6 +805,7 @@ fun AppColors.updateColorsFrom(other: AppColors) { sentQuote = other.sentQuote receivedMessage = other.receivedMessage receivedQuote = other.receivedQuote + toolbar = other.toolbar } fun AppWallpaper.updateWallpaperFrom(other: AppWallpaper) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 01dcd021f7..48a1786b10 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -854,7 +854,7 @@ enum class ScrollDirection { @Composable fun BoxScope.StatusBarBackground() { if (appPlatform.isAndroid) { - val finalColor = MaterialTheme.colors.background.copy(0.88f) + val finalColor = panelBackgroundColor().copy(0.88f) Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(finalColor)) } } @@ -864,14 +864,14 @@ fun BoxScope.NavigationBarBackground(appBarOnBottom: Boolean = false, mixedColor if (appPlatform.isAndroid) { val barPadding = WindowInsets.navigationBars.asPaddingValues() val paddingBottom = barPadding.calculateBottomPadding() - val color = if (mixedColor) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) else MaterialTheme.colors.background + val color = if (mixedColor) panelBackgroundColor() else MaterialTheme.colors.background val finalColor = color.copy(if (noAlpha) 1f else if (appBarOnBottom) remember { appPrefs.inAppBarsAlpha.state }.value else 0.6f) Box(Modifier.align(Alignment.BottomStart).height(paddingBottom).fillMaxWidth().background(finalColor)) } } @Composable -fun BoxScope.NavigationBarBackground(modifier: Modifier, color: Color = MaterialTheme.colors.background) { +fun BoxScope.NavigationBarBackground(modifier: Modifier, color: Color = panelBackgroundColor()) { val keyboardState = getKeyboardState() if (appPlatform.isAndroid && keyboardState.value == KeyboardState.Closed) { val barPadding = WindowInsets.navigationBars.asPaddingValues() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt index 0632512414..37102c9a45 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt @@ -1,513 +1,549 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.runtime.* -import androidx.compose.ui.draw.CacheDrawScope -import androidx.compose.ui.draw.DrawResult -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.drawscope.* -import androidx.compose.ui.graphics.layer.GraphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.* -import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.* -import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex -import chat.simplex.res.MR -import dev.icerock.moko.resources.ImageResource -import dev.icerock.moko.resources.StringResource -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import java.io.File -import kotlin.math.* - -enum class PresetWallpaper( - val res: ImageResource, - val filename: String, - val scale: Float, - val hue: Float, - val cScale: Float, - private val _background: Map, - private val _tint: Map, - private val _colors: Map, -) { - CATS(MR.images.wallpaper_cats, "cats", 0.63f, 88.34f, 0.8172f, - wallpaperBackgrounds(light = oklch(0.9714242f, 0.01596467f, 98.99223f)), // #ffF8F6EA - _tint = mapOf( - DefaultTheme.LIGHT to oklch(0.897064f, 0.07281305f, 90.95935f), // #ffefdca6 - DefaultTheme.DARK to oklch(0.3603656f, 0.0643012f, 88.54155f), // #ff4b3b0e - DefaultTheme.SIMPLEX to oklch(0.3797781f, 0.06842897f, 88.88896f), // #ff51400f - DefaultTheme.BLACK to oklch(0.3603656f, 0.0643012f, 88.54155f) // #ff4b3b0e - ), - _colors = mapOf( - DefaultTheme.LIGHT to ResolvedColors( - sentMessage = oklch(0.9854474f, 0.01790464f, 89.3544f), // #fffffaed - sentQuote = oklch(0.9562038f, 0.0357691f, 89.44265f), // #fffaf0d6 - receivedMessage = oklch(0.9760699f, 0.004115805f, 91.44609f), // #ffF8F7F4 - receivedQuote = oklch(0.9465333f, 0.005762915f, 84.56661f), // #ffefede9 - ), - DefaultTheme.DARK to ResolvedColors( - sentMessage = oklch(0.2827141f, 0.02844628f, 89.80136f), // #ff2f2919 - sentQuote = oklch(0.3550253f, 0.04770112f, 85.80835f), // #ff473a1d - receivedMessage = oklch(0.2689313f, 0.003935312f, 84.58291f), // #ff272624 - receivedQuote = oklch(0.332832f, 0.005361989f, 91.54412f), // #ff373633 - ), - DefaultTheme.SIMPLEX to ResolvedColors( - sentMessage = oklch(0.3402031f, 0.04537511f, 90.2498f), // #ff41371b - sentQuote = oklch(0.4398707f, 0.0737883f, 85.23908f), // #ff654f1c - receivedMessage = oklch(0.2689313f, 0.003935312f, 84.58291f), // #ff272624 - receivedQuote = oklch(0.332832f, 0.005361989f, 91.54412f), // #ff373633 - ), - DefaultTheme.BLACK to ResolvedColors( - sentMessage = oklch(0.3402031f, 0.04537511f, 90.2498f), // #ff41371b - sentQuote = oklch(0.4398707f, 0.0737883f, 85.23908f), // #ff654f1c - receivedMessage = oklch(0.2349937f, 0.005828091f, 91.60813f), // #ff1f1e1b - receivedQuote = oklch(0.2971596f, 0.01092985f, 91.6846f), // #ff2f2d27 - ), - ) - ), - FLOWERS(MR.images.wallpaper_flowers, "flowers", 0.53f, 143.42f, 1.3553f, - wallpaperBackgrounds(light = oklch(0.9718878f, 0.04671557f, 147.1246f)), // #ffE2FFE4 - _tint = mapOf( - DefaultTheme.LIGHT to oklch(0.8574244f, 0.1932141f, 133.0531f), // #ff9CEA59 - DefaultTheme.DARK to oklch(0.409874f, 0.1074549f, 133.4271f), // #ff31560D - DefaultTheme.SIMPLEX to oklch(0.4415422f, 0.1170956f, 133.8571f), // #ff36600f - DefaultTheme.BLACK to oklch(0.409874f, 0.1074549f, 133.4271f) // #ff31560D - ), - _colors = mapOf( - DefaultTheme.LIGHT to ResolvedColors( - sentMessage = oklch(0.9827452f, 0.03710413f, 130.3627f), // #fff1ffe5 - sentQuote = oklch(0.9477894f, 0.07588911f, 131.1257f), // #ffdcf9c4 - receivedMessage = oklch(0.9744452f, 0.008958742f, 134.8726f), // #ffF4F8F2 - receivedQuote = oklch(0.9378814f, 0.008518542f, 145.5074f), // #ffe7ece7 - ), - DefaultTheme.DARK to ResolvedColors( - sentMessage = oklch(0.2986395f, 0.05211595f, 153.5889f), // #ff163521 - sentQuote = oklch(0.3954021f, 0.08319059f, 152.8037f), // #ff1B5330 - receivedMessage = oklch(0.2626721f, 0.003936427f, 128.6285f), // #ff242523 - receivedQuote = oklch(0.3334174f, 0.007411477f, 128.7105f), // #ff353733 - ), - DefaultTheme.SIMPLEX to ResolvedColors( - sentMessage = oklch(0.3611755f, 0.05678164f, 170.3752f), // #ff184739 - sentQuote = oklch(0.484029f, 0.09629127f, 159.5568f), // #ff1F6F4B - receivedMessage = oklch(0.2626721f, 0.003936427f, 128.6285f), // #ff242523 - receivedQuote = oklch(0.3334174f, 0.007411477f, 128.7105f), // #ff353733 - ), - DefaultTheme.BLACK to ResolvedColors( - sentMessage = oklch(0.3611755f, 0.05678164f, 170.3752f), // #ff184739 - sentQuote = oklch(0.484029f, 0.09629127f, 159.5568f), // #ff1F6F4B - receivedMessage = oklch(0.2342548f, 0.01039849f, 132.6996f), // #ff1c1f1a - receivedQuote = oklch(0.2838948f, 0.01154375f, 128.9221f), // #ff282b25 - ), - ) - ), - HEARTS(MR.images.wallpaper_hearts, "hearts", 0.59f, 17.95f, 1.0504f, - wallpaperBackgrounds(light = oklch(0.9565624f, 0.01848713f, 17.48077f)), // #ffFDECEC - _tint = mapOf( - DefaultTheme.LIGHT to oklch(0.9304586f, 0.03207239f, 17.7425f), // #fffde0e0 - DefaultTheme.DARK to oklch(0.2458526f, 0.07098409f, 23.94782f), // #ff3C0F0F - DefaultTheme.SIMPLEX to oklch(0.2574974f, 0.07614605f, 24.19117f), // #ff411010 - DefaultTheme.BLACK to oklch(0.2458526f, 0.07098409f, 23.94782f) // #ff3C0F0F - ), - _colors = mapOf( - DefaultTheme.LIGHT to ResolvedColors( - sentMessage = oklch(0.9757184f, 0.01184164f, 17.35934f), // #fffff4f4 - sentQuote = oklch(0.9300344f, 0.0354728f, 17.80723f), // #ffffdfdf - receivedMessage = oklch(0.9746758f, 0.002137086f, 17.19433f), // #fff8f6f6 - receivedQuote = oklch(0.9431687f, 0.004317648f, 17.23361f), // #ffefebeb - ), - DefaultTheme.DARK to ResolvedColors( - sentMessage = oklch(0.2353791f, 0.04398437f, 20.94719f), // #ff301515 - sentQuote = oklch(0.2920391f, 0.07914221f, 23.35544f), // #ff4C1818 - receivedMessage = oklch(0.2510736f, 0.004554155f, 17.46058f), // #ff242121 - receivedQuote = oklch(0.3352158f, 0.008515606f, 17.58481f), // #ff3b3535 - ), - DefaultTheme.SIMPLEX to ResolvedColors( - sentMessage = oklch(0.2941874f, 0.07322977f, 4.102547f), // #ff491A28 - sentQuote = oklch(0.3831088f, 0.1201278f, 18.61089f), // #ff761F29 - receivedMessage = oklch(0.2510736f, 0.004554155f, 17.46058f), // #ff242121 - receivedQuote = oklch(0.3352158f, 0.008515606f, 17.58481f), // #ff3b3535 - ), - DefaultTheme.BLACK to ResolvedColors( - sentMessage = oklch(0.2941874f, 0.07322977f, 4.102547f), // #ff491A28 - sentQuote = oklch(0.3831088f, 0.1201278f, 18.61089f), // #ff761F29 - receivedMessage = oklch(0.2267386f, 0.00626924f, 17.6236f), // #ff1f1b1b - receivedQuote = oklch(0.2776199f, 0.012034f, 17.89987f), // #ff2e2626 - ), - ) - ), - KIDS(MR.images.wallpaper_kids, "kids", 0.53f, 200.75f, 0.7723f, - wallpaperBackgrounds(light = oklch(0.9693045f, 0.03516977f, 192.2433f)), // #ffdbfdfb - _tint = mapOf( - DefaultTheme.LIGHT to oklch(0.9123625f, 0.06815507f, 211.1344f), // #ffadeffc - DefaultTheme.DARK to oklch(0.3473769f, 0.04958945f, 218.0525f), // #ff16404B - DefaultTheme.SIMPLEX to oklch(0.3716418f, 0.05389406f, 217.7104f), // #ff184753 - DefaultTheme.BLACK to oklch(0.3473769f, 0.04958945f, 218.0525f) // #ff16404B - ), - _colors = mapOf( - DefaultTheme.LIGHT to ResolvedColors( - sentMessage = oklch(0.9827091f, 0.02093746f, 200.4479f), // #ffeafeff - sentQuote = oklch(0.9392156f, 0.04239295f, 201.9221f), // #ffcbf4f7 - receivedMessage = oklch(0.9798523f, 0.007408877f, 197.0357f), // #fff3fafa - receivedQuote = oklch(0.9438775f, 0.0117012f, 196.9581f), // #ffe4efef - ), - DefaultTheme.DARK to ResolvedColors( - sentMessage = oklch(0.2881511f, 0.03214503f, 192.2759f), // #ff16302F - sentQuote = oklch(0.3764664f, 0.05129536f, 193.292f), // #ff1a4a49 - receivedMessage = oklch(0.2675764f, 0.001466786f, 197.0692f), // #ff252626 - receivedQuote = oklch(0.3451987f, 0.004436687f, 174.2088f), // #ff373a39 - ), - DefaultTheme.SIMPLEX to ResolvedColors( - sentMessage = oklch(0.3662882f, 0.04909204f, 191.2229f), // #ff1a4745 - sentQuote = oklch(0.4817563f, 0.07299667f, 192.4874f), // #ff1d6b69 - receivedMessage = oklch(0.2675764f, 0.001466786f, 197.0692f), // #ff252626 - receivedQuote = oklch(0.3451987f, 0.004436687f, 174.2088f), // #ff373a39 - ), - DefaultTheme.BLACK to ResolvedColors( - sentMessage = oklch(0.3662882f, 0.04909204f, 191.2229f), // #ff1a4745 - sentQuote = oklch(0.4817563f, 0.07299667f, 192.4874f), // #ff1d6b69 - receivedMessage = oklch(0.2382215f, 0.001508911f, 197.0555f), // #ff1e1f1f - receivedQuote = oklch(0.2833724f, 0.007955636f, 169.798f), // #ff262b29 - ), - ) - ), - SCHOOL(MR.images.wallpaper_school, "school", 0.53f, 243.37f, 0.7950f, - wallpaperBackgrounds(light = oklch(0.9626785f, 0.02004578f, 238.6614f)), // #ffE7F5FF - _tint = mapOf( - DefaultTheme.LIGHT to oklch(0.9252349f, 0.04096641f, 238.0518f), // #ffCEEBFF - DefaultTheme.DARK to oklch(0.2700986f, 0.04630937f, 241.5568f), // #ff0F293B - DefaultTheme.SIMPLEX to oklch(0.2929108f, 0.05102392f, 240.8139f), // #ff112f43 - DefaultTheme.BLACK to oklch(0.2700986f, 0.04630937f, 241.5568f) // #ff0F293B - ), - _colors = mapOf( - DefaultTheme.LIGHT to ResolvedColors( - sentMessage = oklch(0.9756479f, 0.01416295f, 231.2013f), // #ffeef9ff - sentQuote = oklch(0.9331527f, 0.03006113f, 232.4212f), // #ffD6EDFA - receivedMessage = oklch(0.9697657f, 0.005748723f, 264.5325f), // #ffF3F5F9 - receivedQuote = oklch(0.9296755f, 0.00918803f, 258.3366f), // #ffe4e8ee - ), - DefaultTheme.DARK to ResolvedColors( - sentMessage = oklch(0.267226f, 0.03061943f, 237.8609f), // #ff172833 - sentQuote = oklch(0.3464064f, 0.04943852f, 232.4005f), // #ff1C3E4F - receivedMessage = oklch(0.2764251f, 0.007910622f, 264.4375f), // #ff26282c - receivedQuote = oklch(0.3548081f, 0.008034593f, 255.5451f), // #ff393c40 - ), - DefaultTheme.SIMPLEX to ResolvedColors( - sentMessage = oklch(0.3481476f, 0.07023845f, 249.9259f), // #ff1A3C5D - sentQuote = oklch(0.4520089f, 0.08394516f, 241.1934f), // #ff235b80 - receivedMessage = oklch(0.2764251f, 0.007910622f, 264.4375f), // #ff26282c - receivedQuote = oklch(0.3548081f, 0.008034593f, 255.5451f), // #ff393c40 - ), - DefaultTheme.BLACK to ResolvedColors( - sentMessage = oklch(0.3481476f, 0.07023845f, 249.9259f), // #ff1A3C5D - sentQuote = oklch(0.4520089f, 0.08394516f, 241.1934f), // #ff235b80 - receivedMessage = oklch(0.2356588f, 0.007789041f, 274.6063f), // #ff1d1e22 - receivedQuote = oklch(0.2886546f, 0.007823012f, 264.445f), // #ff292b2f - ), - ) - ), - TRAVEL(MR.images.wallpaper_travel, "travel", 0.68f, 304.95f, 1.2099f, - wallpaperBackgrounds(light = oklch(0.9626377f, 0.0253131f, 313.9639f)), // #fff9eeff - _tint = mapOf( - DefaultTheme.LIGHT to oklch(0.9174161f, 0.05105522f, 309.6281f), // #ffeedbfe - DefaultTheme.DARK to oklch(0.2817417f, 0.07665313f, 302.6645f), // #ff311E48 - DefaultTheme.SIMPLEX to oklch(0.2948376f, 0.08277514f, 302.7197f), // #ff35204e - DefaultTheme.BLACK to oklch(0.2817417f, 0.07665313f, 302.6645f) // #ff311E48 - ), - _colors = mapOf( - DefaultTheme.LIGHT to ResolvedColors( - sentMessage = oklch(0.9803204f, 0.01342671f, 314.7601f), // #fffcf6ff - sentQuote = oklch(0.9294779f, 0.04197705f, 313.6968f), // #fff2e0fc - receivedMessage = oklch(0.9695303f, 0.004487354f, 314.8044f), // #ffF6F4F7 - receivedQuote = oklch(0.9385522f, 0.007899312f, 319.4466f), // #ffede9ee - ), - DefaultTheme.DARK to ResolvedColors( - sentMessage = oklch(0.2929984f, 0.04120036f, 312.1162f), // #ff33263B - sentQuote = oklch(0.3876602f, 0.07087001f, 315.7654f), // #ff53385E - receivedMessage = oklch(0.2678179f, 0.006190444f, 314.7144f), // #ff272528 - receivedQuote = oklch(0.3435397f, 0.01317027f, 310.9424f), // #ff3B373E - ), - DefaultTheme.SIMPLEX to ResolvedColors( - sentMessage = oklch(0.3234681f, 0.09690244f, 299.9634f), // #ff3C255D - sentQuote = oklch(0.4226042f, 0.1341495f, 307.8573f), // #ff623485 - receivedMessage = oklch(0.2812692f, 0.03669397f, 281.5485f), // #ff26273B - receivedQuote = oklch(0.355058f, 0.03791292f, 286.3773f), // #ff3A394F - ), - DefaultTheme.BLACK to ResolvedColors( - sentMessage = oklch(0.3234681f, 0.09690244f, 299.9634f), // #ff3C255D - sentQuote = oklch(0.4226042f, 0.1341495f, 307.8573f), // #ff623485 - receivedMessage = oklch(0.2454222f, 0.009540156f, 325.8636f), // #ff231f23 - receivedQuote = oklch(0.2874049f, 0.0149843f, 302.5009f), // #ff2c2931 - ), - ) - ); - - val background: Map get() = generateBackground(this) // legacy: _background - val tint: Map get() = generateTint(this) // legacy: _tint - val colors: Map get() = generateColors(this) // legacy: _colors - - fun toType(base: DefaultTheme, scale: Float? = null): WallpaperType = - WallpaperType.Preset( - filename, - scale ?: appPrefs.themeOverrides.get().firstOrNull { it.wallpaper != null && it.wallpaper.preset == filename && it.base == base }?.wallpaper?.scale ?: 1f - ) - - companion object { - fun from(filename: String): PresetWallpaper? = - entries.firstOrNull { it.filename == filename } - } -} - -fun wallpaperBackgrounds(light: Color): Map = - mapOf( - DefaultTheme.LIGHT to light, - DefaultTheme.DARK to oklch(0.1822037f, 0f, 0f), // #ff121212 - DefaultTheme.SIMPLEX to oklch(0.2024453f, 0.03849037f, 273.4875f), // #ff111528 - DefaultTheme.BLACK to oklch(0.1285578f, 0f, 0f) // #ff070707 - ) - -// ===== Theme color formula ===== -// L = mode.lBase + mode.lSpread * elem.lOffset -// C = mode.cFactor * elem.cFactor * theme.cScale -// H = theme.hue - -private data class ModeParams(val lBase: Float, val lSpread: Float, val cFactor: Float) -private data class ElemParams(val lOffset: Float, val cFactor: Float) - -private val MODE_PARAMS = mapOf( - DefaultTheme.LIGHT to ModeParams(lBase = 0.9481f, lSpread = 0.2482f, cFactor = 0.7528f), - DefaultTheme.DARK to ModeParams(lBase = 0.3124f, lSpread = -0.6795f, cFactor = 0.8827f), - DefaultTheme.SIMPLEX to ModeParams(lBase = 0.3467f, lSpread = -1.3361f, cFactor = 1.1992f), - DefaultTheme.BLACK to ModeParams(lBase = 0.3249f, lSpread = -1.6120f, cFactor = 1.1654f), -) - -private enum class ThemeElem(val params: ElemParams) { - TINT(ElemParams(lOffset = 0.0007f, cFactor = 0.07096f)), - SENT_MESSAGE(ElemParams(lOffset = 0.0040f, cFactor = 0.04810f)), - SENT_QUOTE(ElemParams(lOffset = -0.0725f, cFactor = 0.07623f)), - RECEIVED_MESSAGE(ElemParams(lOffset = 0.0584f, cFactor = 0.00691f)), - RECEIVED_QUOTE(ElemParams(lOffset = 0.0094f, cFactor = 0.00969f)), -} - -private const val BG_LIGHT_L = 0.9657f -private const val BG_LIGHT_C_BASE = 0.02721f - -private fun gen(mode: DefaultTheme, elem: ThemeElem, p: PresetWallpaper): Color { - val m = MODE_PARAMS[mode]!! - val e = elem.params - return oklch(m.lBase + m.lSpread * e.lOffset, m.cFactor * e.cFactor * p.cScale, p.hue) -} - -private fun generateBackground(p: PresetWallpaper): Map = - wallpaperBackgrounds(light = oklch(BG_LIGHT_L, BG_LIGHT_C_BASE * p.cScale, p.hue)) - -private fun generateTint(p: PresetWallpaper): Map = - DefaultTheme.entries.associateWith { gen(it, ThemeElem.TINT, p) } - -private fun generateColors(p: PresetWallpaper): Map = - DefaultTheme.entries.associateWith { mode -> - ResolvedColors( - sentMessage = gen(mode, ThemeElem.SENT_MESSAGE, p), - sentQuote = gen(mode, ThemeElem.SENT_QUOTE, p), - receivedMessage = gen(mode, ThemeElem.RECEIVED_MESSAGE, p), - receivedQuote = gen(mode, ThemeElem.RECEIVED_QUOTE, p), - ) - } - -@Serializable -enum class WallpaperScaleType(val contentScale: ContentScale, val text: StringResource) { - @SerialName("fill") FILL(ContentScale.Crop, MR.strings.wallpaper_scale_fill), - @SerialName("fit") FIT(ContentScale.Fit, MR.strings.wallpaper_scale_fit), - @SerialName("repeat") REPEAT(ContentScale.Fit, MR.strings.wallpaper_scale_repeat), -} - -sealed class WallpaperType { - abstract val scale: Float? - - val image by lazy { - val filename = when (this) { - is Preset -> filename - is Image -> filename - else -> return@lazy null - } - if (filename == "") return@lazy null - if (cachedImages[filename] != null) { - cachedImages[filename] - } else { - val res = if (this is Preset) { - (PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).res.toComposeImageBitmap()!! - } else { - try { - // In case of unintentional image deletion don't crash the app - File(getWallpaperFilePath(filename)).inputStream().use { loadImageBitmap(it) } - } catch (e: Exception) { - Log.e(TAG, "Error while loading wallpaper file: ${e.stackTraceToString()}") - null - } - } - res?.prepareToDraw() - cachedImages[filename] = res ?: return@lazy null - res - } - } - - fun sameType(other: WallpaperType?): Boolean = - if (this is Preset && other is Preset) this.filename == other.filename - else this.javaClass == other?.javaClass - - fun samePreset(other: PresetWallpaper?): Boolean = this is Preset && filename == other?.filename - - data class Preset( - val filename: String, - override val scale: Float?, - ): WallpaperType() { - val predefinedImageScale = PresetWallpaper.from(filename)?.scale ?: 1f - } - - data class Image( - val filename: String, - override val scale: Float?, - val scaleType: WallpaperScaleType?, - ): WallpaperType() - - object Empty: WallpaperType() { - override val scale: Float? - get() = null - } - - fun defaultBackgroundColor(theme: DefaultTheme, materialBackground: Color): Color = - if (this is Preset) { - (PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).background[theme]!! - } else { - materialBackground - } - - fun defaultTintColor(theme: DefaultTheme): Color = - if (this is Preset) { - (PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).tint[theme]!! - } else if (this is Image && scaleType == WallpaperScaleType.REPEAT) { - Color.Transparent - } else { - Color.Transparent - } - - companion object { - var cachedImages: MutableMap = mutableMapOf() - - fun from(wallpaper: ThemeWallpaper?): WallpaperType? { - return if (wallpaper == null) { - null - } else if (wallpaper.preset != null) { - Preset(wallpaper.preset, wallpaper.scale) - } else if (wallpaper.imageFile != null) { - Image(wallpaper.imageFile, wallpaper.scale, wallpaper.scaleType) - } else { - Empty - } - } - } -} - -private fun drawToBitmap(image: ImageBitmap, imageScale: Float, tint: Color, size: Size, density: Float, layoutDirection: LayoutDirection): ImageBitmap { - val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low - val drawScope = CanvasDrawScope() - // Don't allow to make zero size because it crashes the app when reducing a size of a window on desktop - val bitmap = ImageBitmap(size.width.toInt().coerceAtLeast(1), size.height.toInt().coerceAtLeast(1)) - val canvas = Canvas(bitmap) - drawScope.draw( - density = Density(density), - layoutDirection = layoutDirection, - canvas = canvas, - size = size, - ) { - val scale = imageScale * density - for (h in 0..(size.height / image.height / scale).roundToInt()) { - for (w in 0..(size.width / image.width / scale).roundToInt()) { - drawImage( - image, - dstOffset = IntOffset(x = (w * image.width * scale).roundToInt(), y = (h * image.height * scale).roundToInt()), - dstSize = IntSize((image.width * scale).roundToInt(), (image.height * scale).roundToInt()), - colorFilter = ColorFilter.tint(tint, BlendMode.SrcIn), - filterQuality = quality - ) - } - } - } - return bitmap -} - -fun CacheDrawScope.chatViewBackground( - image: ImageBitmap, - imageType: WallpaperType, - background: Color, - tint: Color, - graphicsLayerSize: MutableState? = null, - backgroundGraphicsLayer: GraphicsLayer? = null -): DrawResult { - val imageScale = if (imageType is WallpaperType.Preset) { - (imageType.scale ?: 1f) * imageType.predefinedImageScale - } else if (imageType is WallpaperType.Image && imageType.scaleType == WallpaperScaleType.REPEAT) { - imageType.scale ?: 1f - } else { - 1f - } - val image = if (imageType is WallpaperType.Preset || (imageType is WallpaperType.Image && imageType.scaleType == WallpaperScaleType.REPEAT)) { - drawToBitmap(image, imageScale, tint, size, density, layoutDirection) - } else { - image - } - - return onDrawBehind { - copyBackgroundToAppBar(graphicsLayerSize, backgroundGraphicsLayer) { - val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low - drawRect(background) - when (imageType) { - is WallpaperType.Preset -> drawImage(image) - is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) { - WallpaperScaleType.REPEAT -> drawImage(image) - WallpaperScaleType.FILL, WallpaperScaleType.FIT -> { - clipRect { - val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height)) - val scaledWidth = (image.width * scale.scaleX).roundToInt() - val scaledHeight = (image.height * scale.scaleY).roundToInt() - // Large image will cause freeze - if (image.width > 4320 || image.height > 4320) return@clipRect - - drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) - if (scaleType == WallpaperScaleType.FIT) { - if (scaledWidth < size.width) { - // has black lines at left and right sides - var x = (size.width - scaledWidth) / 2 - while (x > 0) { - drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) - x -= scaledWidth - } - x = size.width - (size.width - scaledWidth) / 2 - while (x < size.width) { - drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) - x += scaledWidth - } - } else { - // has black lines at top and bottom sides - var y = (size.height - scaledHeight) / 2 - while (y > 0) { - drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) - y -= scaledHeight - } - y = size.height - (size.height - scaledHeight) / 2 - while (y < size.height) { - drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) - y += scaledHeight - } - } - } - } - drawRect(tint) - } - } - is WallpaperType.Empty -> {} - } - } - } -} +package chat.simplex.common.views.helpers + +import androidx.compose.runtime.* +import androidx.compose.ui.draw.CacheDrawScope +import androidx.compose.ui.draw.DrawResult +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.* +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex +import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.io.File +import kotlin.math.* + +enum class PresetWallpaper( + val res: ImageResource, + val filename: String, + val scale: Float, + val hue: Float, + val cScale: Float, + private val _background: Map, + private val _tint: Map, + private val _colors: Map, +) { + CATS(MR.images.wallpaper_cats, "cats", 0.5f, 77f, 0.8172f, + wallpaperBackgrounds( + light = oklch(0.97393197f, 0.04026258f, 94.315125f), + dark = oklch(0.1800f, 0.0250f, 77f), + ), + _tint = mapOf( + DefaultTheme.LIGHT to oklch(0.9049206f, 0.07393196f, 94.315125f), + DefaultTheme.DARK to oklch(0.2940f, 0.0567f, 77f), + DefaultTheme.SIMPLEX to oklch(0.3797781f, 0.06842897f, 88.88896f), // #ff51400f + DefaultTheme.BLACK to oklch(0.3600f, 0.0866f, 77f) + ), + _colors = mapOf( + DefaultTheme.LIGHT to ResolvedColors( + sentMessage = oklch(0.97874475f, 0.03285575f, 94.315125f), + sentQuote = oklch(0.95411396f, 0.05781051f, 94.315125f), + receivedMessage = oklch(0.9946289f, 0.00832808f, 94.315125f), + receivedQuote = oklch(0.9815269f, 0.028569698f, 94.315125f), + ), + DefaultTheme.DARK to ResolvedColors( + sentMessage = oklch(0.3130f, 0.0630f, 77f), + sentQuote = oklch(0.3700f, 0.0725f, 77f), + receivedMessage = oklch(0.2560f, 0.0278f, 77f), + receivedQuote = oklch(0.2940f, 0.0320f, 77f), + ), + DefaultTheme.SIMPLEX to ResolvedColors( + sentMessage = oklch(0.3402031f, 0.04537511f, 90.2498f), // #ff41371b + sentQuote = oklch(0.4398707f, 0.0737883f, 85.23908f), // #ff654f1c + receivedMessage = oklch(0.2689313f, 0.003935312f, 84.58291f), // #ff272624 + receivedQuote = oklch(0.332832f, 0.005361989f, 91.54412f), // #ff373633 + ), + DefaultTheme.BLACK to ResolvedColors( + sentMessage = oklch(0.24f, 0.0596f, 70f), + sentQuote = oklch(0.33f, 0.0815f, 70f), + receivedMessage = oklch(0.12f, 0f, 0f), + receivedQuote = oklch(0.22f, 0f, 0f), + ), + ), + ), + FLOWERS(MR.images.wallpaper_flowers, "flowers", 0.5f, 130f, 1.3553f, + wallpaperBackgrounds( + light = oklch(0.9506836f, 0.035730995f, 129.80244f), + dark = oklch(0.1800f, 0.0250f, 130f), + ), + _tint = mapOf( + DefaultTheme.LIGHT to oklch(0.85039175f, 0.09360638f, 129.80244f), + DefaultTheme.DARK to oklch(0.3130f, 0.0567f, 130f), + DefaultTheme.SIMPLEX to oklch(0.4415422f, 0.1170956f, 133.8571f), // #ff36600f + DefaultTheme.BLACK to oklch(0.3600f, 0.1133f, 130f) + ), + _colors = mapOf( + DefaultTheme.LIGHT to ResolvedColors( + sentMessage = oklch(0.96106595f, 0.027363745f, 129.80244f), + sentQuote = oklch(0.9195365f, 0.06853871f, 129.80244f), + receivedMessage = oklch(0.9946289f, 0.011358023f, 129.80244f), + receivedQuote = oklch(0.9688529f, 0.021421315f, 129.80244f), + ), + DefaultTheme.DARK to ResolvedColors( + sentMessage = oklch(0.3130f, 0.0630f, 130f), + sentQuote = oklch(0.3700f, 0.0725f, 130f), + receivedMessage = oklch(0.2560f, 0.0278f, 130f), + receivedQuote = oklch(0.2940f, 0.0320f, 130f), + ), + DefaultTheme.SIMPLEX to ResolvedColors( + sentMessage = oklch(0.3611755f, 0.05678164f, 170.3752f), // #ff184739 + sentQuote = oklch(0.484029f, 0.09629127f, 159.5568f), // #ff1F6F4B + receivedMessage = oklch(0.2626721f, 0.003936427f, 128.6285f), // #ff242523 + receivedQuote = oklch(0.3334174f, 0.007411477f, 128.7105f), // #ff353733 + ), + DefaultTheme.BLACK to ResolvedColors( + sentMessage = oklch(0.24f, 0.0756f, 130f), + sentQuote = oklch(0.33f, 0.1029f, 130f), + receivedMessage = oklch(0.12f, 0f, 0f), + receivedQuote = oklch(0.22f, 0f, 0f), + ), + ), + ), + HEARTS(MR.images.wallpaper_hearts, "hearts", 0.5f, 15f, 1.0504f, + wallpaperBackgrounds( + light = oklch(0.9560547f, 0.021765456f, 13.841402f), + dark = oklch(0.1800f, 0.0250f, 5f), + ), + _tint = mapOf( + DefaultTheme.LIGHT to oklch(0.90485084f, 0.04976797f, 13.841402f), + DefaultTheme.DARK to oklch(0.2940f, 0.0567f, 5f), + DefaultTheme.SIMPLEX to oklch(0.2574974f, 0.07614605f, 24.19117f), // #ff411010 + DefaultTheme.BLACK to oklch(0.3600f, 0.1630f, 5f) + ), + _colors = mapOf( + DefaultTheme.LIGHT to ResolvedColors( + sentMessage = oklch(0.96500653f, 0.017198082f, 13.841402f), + sentQuote = oklch(0.9291992f, 0.026614813f, 13.841402f), + receivedMessage = oklch(0.99560547f, 0.002124548f, 13.841402f), + receivedQuote = oklch(0.97314453f, 0.013107679f, 13.841402f), + ), + DefaultTheme.DARK to ResolvedColors( + sentMessage = oklch(0.3130f, 0.0630f, 5f), + sentQuote = oklch(0.3700f, 0.0725f, 5f), + receivedMessage = oklch(0.2560f, 0.0278f, 5f), + receivedQuote = oklch(0.2940f, 0.0320f, 5f), + ), + DefaultTheme.SIMPLEX to ResolvedColors( + sentMessage = oklch(0.2941874f, 0.07322977f, 4.102547f), // #ff491A28 + sentQuote = oklch(0.3831088f, 0.1201278f, 18.61089f), // #ff761F29 + receivedMessage = oklch(0.2510736f, 0.004554155f, 17.46058f), // #ff242121 + receivedQuote = oklch(0.3352158f, 0.008515606f, 17.58481f), // #ff3b3535 + ), + DefaultTheme.BLACK to ResolvedColors( + sentMessage = oklch(0.24f, 0.1087f, 5f), + sentQuote = oklch(0.33f, 0.1480f, 5f), + receivedMessage = oklch(0.12f, 0f, 0f), + receivedQuote = oklch(0.22f, 0f, 0f), + ), + ), + ), + KIDS(MR.images.wallpaper_kids, "kids", 0.5f, 200f, 0.7723f, + wallpaperBackgrounds( + light = oklch(0.9628906f, 0.023496835f, 198.73976f), + dark = oklch(0.1800f, 0.0250f, 200f), + ), + _tint = mapOf( + DefaultTheme.LIGHT to oklch(0.8909232f, 0.055181965f, 198.73976f), + DefaultTheme.DARK to oklch(0.2560f, 0.0567f, 200f), + DefaultTheme.SIMPLEX to oklch(0.3716418f, 0.05389406f, 217.7104f), // #ff184753 + DefaultTheme.BLACK to oklch(0.3000f, 0.0684f, 200f) + ), + _colors = mapOf( + DefaultTheme.LIGHT to ResolvedColors( + sentMessage = oklch(0.9716797f, 0.017883832f, 198.73976f), + sentQuote = oklch(0.93652344f, 0.036067758f, 198.73976f), + receivedMessage = oklch(0.9946289f, 0.0069881678f, 198.73976f), + receivedQuote = oklch(0.9736328f, 0.016644018f, 198.73976f), + ), + DefaultTheme.DARK to ResolvedColors( + sentMessage = oklch(0.3130f, 0.0630f, 200f), + sentQuote = oklch(0.3700f, 0.0725f, 200f), + receivedMessage = oklch(0.2560f, 0.0278f, 200f), + receivedQuote = oklch(0.2940f, 0.0320f, 200f), + ), + DefaultTheme.SIMPLEX to ResolvedColors( + sentMessage = oklch(0.3662882f, 0.04909204f, 191.2229f), // #ff1a4745 + sentQuote = oklch(0.4817563f, 0.07299667f, 192.4874f), // #ff1d6b69 + receivedMessage = oklch(0.2675764f, 0.001466786f, 197.0692f), // #ff252626 + receivedQuote = oklch(0.3451987f, 0.004436687f, 174.2088f), // #ff373a39 + ), + DefaultTheme.BLACK to ResolvedColors( + sentMessage = oklch(0.24f, 0.0555f, 192f), + sentQuote = oklch(0.33f, 0.0756f, 192f), + receivedMessage = oklch(0.12f, 0f, 0f), + receivedQuote = oklch(0.22f, 0f, 0f), + ), + ), + ), + SCHOOL(MR.images.wallpaper_school, "school", 0.5f, 239f, 0.7950f, + wallpaperBackgrounds( + light = oklch(0.9604492f, 0.025148988f, 225.3911f), + dark = oklch(0.1800f, 0.0250f, 249f), + ), + _tint = mapOf( + DefaultTheme.LIGHT to oklch(0.9019366f, 0.04948576f, 225.3911f), + DefaultTheme.DARK to oklch(0.2560f, 0.0567f, 249f), + DefaultTheme.SIMPLEX to oklch(0.2929108f, 0.05102392f, 240.8139f), // #ff112f43 + DefaultTheme.BLACK to oklch(0.3000f, 0.1070f, 249f) + ), + _colors = mapOf( + DefaultTheme.LIGHT to ResolvedColors( + sentMessage = oklch(0.96875f, 0.019812047f, 225.3911f), + sentQuote = oklch(0.9355469f, 0.036309462f, 225.3911f), + receivedMessage = oklch(0.99560547f, 0.0027611256f, 225.3911f), + receivedQuote = oklch(0.97509766f, 0.015755296f, 225.3911f), + ), + DefaultTheme.DARK to ResolvedColors( + sentMessage = oklch(0.3130f, 0.0630f, 249f), + sentQuote = oklch(0.3700f, 0.0725f, 249f), + receivedMessage = oklch(0.2560f, 0.0278f, 249f), + receivedQuote = oklch(0.2940f, 0.0320f, 249f), + ), + DefaultTheme.SIMPLEX to ResolvedColors( + sentMessage = oklch(0.4187f, 0.1388f, 262.50f), // #204797 + sentQuote = oklch(0.4766f, 0.1487f, 262.58f), // #2c57af + receivedMessage = oklch(0.2314f, 0.0685f, 268.59f), // #101a3d + receivedQuote = oklch(0.3023f, 0.0900f, 267.90f), // #1b2a5b + ), + DefaultTheme.BLACK to ResolvedColors( + sentMessage = oklch(0.24f, 0.0856f, 249f), + sentQuote = oklch(0.33f, 0.1166f, 249f), + receivedMessage = oklch(0.12f, 0f, 0f), + receivedQuote = oklch(0.22f, 0f, 0f), + ), + ), + ), + TRAVEL(MR.images.wallpaper_travel, "travel", 0.5f, 315f, 1.2099f, + wallpaperBackgrounds( + light = oklch(0.95996094f, 0.023496835f, 322.59464f), + dark = oklch(0.1800f, 0.0250f, 315f), + ), + _tint = mapOf( + DefaultTheme.LIGHT to oklch(0.9120599f, 0.056131333f, 322.59464f), + DefaultTheme.DARK to oklch(0.2750f, 0.0567f, 315f), + DefaultTheme.SIMPLEX to oklch(0.2948376f, 0.08277514f, 302.7197f), // #ff35204e + DefaultTheme.BLACK to oklch(0.3000f, 0.1579f, 315f) + ), + _colors = mapOf( + DefaultTheme.LIGHT to ResolvedColors( + sentMessage = oklch(0.96809894f, 0.01866399f, 322.59464f), + sentQuote = oklch(0.9355469f, 0.030384803f, 322.59464f), + receivedMessage = oklch(0.99560547f, 0.0035372972f, 322.59464f), + receivedQuote = oklch(0.97558594f, 0.014230033f, 322.59464f), + ), + DefaultTheme.DARK to ResolvedColors( + sentMessage = oklch(0.3130f, 0.0630f, 315f), + sentQuote = oklch(0.3700f, 0.0725f, 315f), + receivedMessage = oklch(0.2560f, 0.0278f, 315f), + receivedQuote = oklch(0.2940f, 0.0320f, 315f), + ), + DefaultTheme.SIMPLEX to ResolvedColors( + sentMessage = oklch(0.3234681f, 0.09690244f, 299.9634f), // #ff3C255D + sentQuote = oklch(0.4226042f, 0.1341495f, 307.8573f), // #ff623485 + receivedMessage = oklch(0.2812692f, 0.03669397f, 281.5485f), // #ff26273B + receivedQuote = oklch(0.355058f, 0.03791292f, 286.3773f), // #ff3A394F + ), + DefaultTheme.BLACK to ResolvedColors( + sentMessage = oklch(0.24f, 0.1263f, 315f), + sentQuote = oklch(0.33f, 0.1721f, 315f), + receivedMessage = oklch(0.12f, 0f, 0f), + receivedQuote = oklch(0.22f, 0f, 0f), + ), + ), + ); + + val background: Map get() = _background + val tint: Map get() = _tint + val colors: Map get() = _colors + + /** Hue for a given theme. Most wallpapers use the same hue across all themes, + * but hearts (15→5) and school (239→249) shift hue in dark themes for perceptual correction on AMOLED. */ + fun hue(theme: DefaultTheme): Float = when (theme) { + DefaultTheme.DARK, DefaultTheme.BLACK -> when (this) { + HEARTS -> 5f + SCHOOL -> 249f + else -> hue + } + else -> hue + } + + fun toType(base: DefaultTheme, scale: Float? = null): WallpaperType = + WallpaperType.Preset( + filename, + scale ?: appPrefs.themeOverrides.get().firstOrNull { it.wallpaper != null && it.wallpaper.preset == filename && it.base == base }?.wallpaper?.scale ?: 1f + ) + + companion object { + fun from(filename: String): PresetWallpaper? = + entries.firstOrNull { it.filename == filename } + } +} + +fun wallpaperBackgrounds( + light: Color, + dark: Color = oklch(0.1822037f, 0f, 0f), // #ff121212 +): Map = + mapOf( + DefaultTheme.LIGHT to light, + DefaultTheme.DARK to dark, + DefaultTheme.SIMPLEX to oklch(0.2024453f, 0.03849037f, 273.4875f), // #ff111528 + DefaultTheme.BLACK to oklch(0f, 0f, 0f) // #ff000000 — pure black for hyper-contrast theme + ) + +// ===== Theme color formula ===== +// L = mode.lBase + mode.lSpread * elem.lOffset +// C = mode.cFactor * elem.cFactor * theme.cScale +// H = theme.hue + +private data class ModeParams(val lBase: Float, val lSpread: Float, val cFactor: Float) +private data class ElemParams(val lOffset: Float, val cFactor: Float) + +private val MODE_PARAMS = mapOf( + DefaultTheme.LIGHT to ModeParams(lBase = 0.9481f, lSpread = 0.2482f, cFactor = 0.7528f), + DefaultTheme.DARK to ModeParams(lBase = 0.3124f, lSpread = -0.6795f, cFactor = 0.8827f), + DefaultTheme.SIMPLEX to ModeParams(lBase = 0.3467f, lSpread = -1.3361f, cFactor = 1.1992f), + DefaultTheme.BLACK to ModeParams(lBase = 0.3249f, lSpread = -1.6120f, cFactor = 1.1654f), +) + +private enum class ThemeElem(val params: ElemParams) { + TINT(ElemParams(lOffset = 0.0007f, cFactor = 0.07096f)), + SENT_MESSAGE(ElemParams(lOffset = 0.0040f, cFactor = 0.04810f)), + SENT_QUOTE(ElemParams(lOffset = -0.0725f, cFactor = 0.07623f)), + RECEIVED_MESSAGE(ElemParams(lOffset = 0.0584f, cFactor = 0.00691f)), + RECEIVED_QUOTE(ElemParams(lOffset = 0.0094f, cFactor = 0.00969f)), +} + +private const val BG_LIGHT_L = 0.9657f +private const val BG_LIGHT_C_BASE = 0.02721f + +private fun gen(mode: DefaultTheme, elem: ThemeElem, p: PresetWallpaper): Color { + val m = MODE_PARAMS[mode]!! + val e = elem.params + return oklch(m.lBase + m.lSpread * e.lOffset, m.cFactor * e.cFactor * p.cScale, p.hue) +} + +private fun generateBackground(p: PresetWallpaper): Map = + wallpaperBackgrounds(light = oklch(BG_LIGHT_L, BG_LIGHT_C_BASE * p.cScale, p.hue)) + +private fun generateTint(p: PresetWallpaper): Map = + DefaultTheme.entries.associateWith { gen(it, ThemeElem.TINT, p) } + +private fun generateColors(p: PresetWallpaper): Map = + DefaultTheme.entries.associateWith { mode -> + ResolvedColors( + sentMessage = gen(mode, ThemeElem.SENT_MESSAGE, p), + sentQuote = gen(mode, ThemeElem.SENT_QUOTE, p), + receivedMessage = gen(mode, ThemeElem.RECEIVED_MESSAGE, p), + receivedQuote = gen(mode, ThemeElem.RECEIVED_QUOTE, p), + ) + } + +@Serializable +enum class WallpaperScaleType(val contentScale: ContentScale, val text: StringResource) { + @SerialName("fill") FILL(ContentScale.Crop, MR.strings.wallpaper_scale_fill), + @SerialName("fit") FIT(ContentScale.Fit, MR.strings.wallpaper_scale_fit), + @SerialName("repeat") REPEAT(ContentScale.Fit, MR.strings.wallpaper_scale_repeat), +} + +sealed class WallpaperType { + abstract val scale: Float? + + val image by lazy { + val filename = when (this) { + is Preset -> filename + is Image -> filename + else -> return@lazy null + } + if (filename == "") return@lazy null + if (cachedImages[filename] != null) { + cachedImages[filename] + } else { + val res = if (this is Preset) { + (PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).res.toComposeImageBitmap()!! + } else { + try { + // In case of unintentional image deletion don't crash the app + File(getWallpaperFilePath(filename)).inputStream().use { loadImageBitmap(it) } + } catch (e: Exception) { + Log.e(TAG, "Error while loading wallpaper file: ${e.stackTraceToString()}") + null + } + } + res?.prepareToDraw() + cachedImages[filename] = res ?: return@lazy null + res + } + } + + fun sameType(other: WallpaperType?): Boolean = + if (this is Preset && other is Preset) this.filename == other.filename + else this.javaClass == other?.javaClass + + fun samePreset(other: PresetWallpaper?): Boolean = this is Preset && filename == other?.filename + + data class Preset( + val filename: String, + override val scale: Float?, + ): WallpaperType() { + val predefinedImageScale = PresetWallpaper.from(filename)?.scale ?: 1f + } + + data class Image( + val filename: String, + override val scale: Float?, + val scaleType: WallpaperScaleType?, + ): WallpaperType() + + object Empty: WallpaperType() { + override val scale: Float? + get() = null + } + + fun defaultBackgroundColor(theme: DefaultTheme, materialBackground: Color): Color = + if (this is Preset) { + (PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).background[theme]!! + } else { + materialBackground + } + + fun defaultTintColor(theme: DefaultTheme): Color = + if (this is Preset) { + (PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).tint[theme]!! + } else if (this is Image && scaleType == WallpaperScaleType.REPEAT) { + Color.Transparent + } else { + Color.Transparent + } + + companion object { + var cachedImages: MutableMap = mutableMapOf() + + fun from(wallpaper: ThemeWallpaper?): WallpaperType? { + return if (wallpaper == null) { + null + } else if (wallpaper.preset != null) { + Preset(wallpaper.preset, wallpaper.scale) + } else if (wallpaper.imageFile != null) { + Image(wallpaper.imageFile, wallpaper.scale, wallpaper.scaleType) + } else { + Empty + } + } + } +} + +private fun drawToBitmap(image: ImageBitmap, imageScale: Float, tint: Color, size: Size, density: Float, layoutDirection: LayoutDirection, highQuality: Boolean = false): ImageBitmap { + val drawScope = CanvasDrawScope() + // Don't allow to make zero size because it crashes the app when reducing a size of a window on desktop + val bitmap = ImageBitmap(size.width.toInt().coerceAtLeast(1), size.height.toInt().coerceAtLeast(1)) + val canvas = Canvas(bitmap) + drawScope.draw( + density = Density(density), + layoutDirection = layoutDirection, + canvas = canvas, + size = size, + ) { + val scale = imageScale * density + val tileW = (image.width * scale).roundToInt().coerceAtLeast(1) + val tileH = (image.height * scale).roundToInt().coerceAtLeast(1) + // Pre-scale: Compose Desktop ignores FilterQuality in drawImage, scale via platform API instead + val tile = if (tileW == image.width && tileH == image.height) image else image.scale(tileW, tileH, highQuality) + + for (h in 0..(size.height / tileH).roundToInt()) { + for (w in 0..(size.width / tileW).roundToInt()) { + drawImage( + tile, + topLeft = Offset(w * tileW.toFloat(), h * tileH.toFloat()), + colorFilter = ColorFilter.tint(tint, BlendMode.SrcIn), + ) + } + } + } + return bitmap +} + +fun CacheDrawScope.chatViewBackground( + image: ImageBitmap, + imageType: WallpaperType, + background: Color, + tint: Color, + graphicsLayerSize: MutableState? = null, + backgroundGraphicsLayer: GraphicsLayer? = null, + highQuality: Boolean = true +): DrawResult { + val desktopPatternScale = if (appPlatform.isDesktop) 0.55f else 1f + val imageScale = if (imageType is WallpaperType.Preset) { + (imageType.scale ?: 1f) * imageType.predefinedImageScale * desktopPatternScale + } else if (imageType is WallpaperType.Image && imageType.scaleType == WallpaperScaleType.REPEAT) { + imageType.scale ?: 1f + } else { + 1f + } + val image = if (imageType is WallpaperType.Preset || (imageType is WallpaperType.Image && imageType.scaleType == WallpaperScaleType.REPEAT)) { + drawToBitmap(image, imageScale, tint, size, density, layoutDirection, highQuality) + } else { + image + } + + return onDrawBehind { + copyBackgroundToAppBar(graphicsLayerSize, backgroundGraphicsLayer) { + val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low + drawRect(background) + when (imageType) { + is WallpaperType.Preset -> drawImage(image) + is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) { + WallpaperScaleType.REPEAT -> drawImage(image) + WallpaperScaleType.FILL, WallpaperScaleType.FIT -> { + clipRect { + val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height)) + val scaledWidth = (image.width * scale.scaleX).roundToInt() + val scaledHeight = (image.height * scale.scaleY).roundToInt() + // Large image will cause freeze + if (image.width > 4320 || image.height > 4320) return@clipRect + + drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) + if (scaleType == WallpaperScaleType.FIT) { + if (scaledWidth < size.width) { + // has black lines at left and right sides + var x = (size.width - scaledWidth) / 2 + while (x > 0) { + drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) + x -= scaledWidth + } + x = size.width - (size.width - scaledWidth) / 2 + while (x < size.width) { + drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) + x += scaledWidth + } + } else { + // has black lines at top and bottom sides + var y = (size.height - scaledHeight) / 2 + while (y > 0) { + drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) + y -= scaledHeight + } + y = size.height - (size.height - scaledHeight) / 2 + while (y < size.height) { + drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality) + y += scaledHeight + } + } + } + } + drawRect(tint) + } + } + is WallpaperType.Empty -> {} + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 81fac40a40..7d123b194c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -1,270 +1,271 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.* -import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.* -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.item.CenteredRowLayout -import chat.simplex.res.MR -import kotlin.math.absoluteValue - -@Composable -fun DefaultAppBar( - navigationButton: (@Composable RowScope.() -> Unit)? = null, - title: (@Composable () -> Unit)? = null, - fixedTitleText: String? = null, - onTitleClick: (() -> Unit)? = null, - onTop: Boolean, - showSearch: Boolean = false, - searchAlwaysVisible: Boolean = false, - searchPlaceholder: String? = null, - onSearchValueChanged: (String) -> Unit = {}, - searchTrailingContent: @Composable (() -> Unit)? = null, - buttons: @Composable RowScope.() -> Unit = {}, -) { - // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier - val modifier = if (!showSearch) { - Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) - } else if (!onTop) Modifier.imePadding() - else Modifier - - val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) - val prefAlpha = remember { appPrefs.inAppBarsAlpha.state } - val handler = LocalAppBarHandler.current - val connection = LocalAppBarHandler.current?.connection - val titleText = remember(handler?.title?.value, fixedTitleText) { - if (fixedTitleText != null) { - mutableStateOf(fixedTitleText) - } else { - handler?.title ?: mutableStateOf("") - } - } - val keyboardInset = WindowInsets.ime - Box(modifier) { - val density = LocalDensity.current - val blurRadius = remember { appPrefs.appearanceBarsBlurRadius.state } - Box(Modifier - .matchParentSize() - .blurredBackgroundModifier(keyboardInset, handler, blurRadius, prefAlpha, handler?.keyboardCoversBar == true, onTop, density) - .drawWithCache { - // store it as a variable, don't put it inside if without holding it here. Compiler don't see it changes otherwise - val alpha = prefAlpha.value - val backgroundColor = if (title != null || fixedTitleText != null || connection == null || !onTop) { - themeBackgroundMix.copy(alpha) - } else { - themeBackgroundMix.copy(topTitleAlpha(false, connection)) - } - onDrawBehind { - drawRect(backgroundColor) - } - } - ) - Box( - Modifier - .fillMaxWidth() - .then(if (!onTop) Modifier.navigationBarsPadding() else Modifier) - .heightIn(min = AppBarHeight * fontSizeSqrtMultiplier) - ) { - AppBar( - title = { - if (showSearch) { - val placeholder = searchPlaceholder ?: stringResource(MR.strings.search_verb) - SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) - } else if (title != null) { - title() - } else if (titleText.value.isNotEmpty() && connection != null) { - Row( - Modifier - .graphicsLayer { - alpha = if (fixedTitleText != null) 1f else topTitleAlpha(true, connection) - } - ) { - Text( - titleText.value, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - }, - navigationIcon = navigationButton, - buttons = if (!showSearch) buttons else {{}}, - centered = !showSearch && (title != null || !onTop), - onTop = onTop, - ) - AppBarDivider(onTop, title != null || fixedTitleText != null, connection) - } - } -} - - -@Composable -fun CallAppBar( - title: @Composable () -> Unit, - onBack: () -> Unit -) { - AppBar( - title, - navigationIcon = { NavigationButtonBack(tintColor = Color(0xFFFFFFD8), onButtonClicked = onBack) }, - centered = false, - onTop = true - ) -} - -@Composable -fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) { - IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { - Icon( - painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor - ) - } -} - -@Composable -fun NavigationButtonClose(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) { - IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { - Icon( - painterResource(MR.images.ic_close), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor - ) - } -} - -@Composable -fun ShareButton(onButtonClicked: () -> Unit) { - IconButton(onButtonClicked) { - Icon( - painterResource(MR.images.ic_share), stringResource(MR.strings.share_verb), tint = MaterialTheme.colors.primary - ) - } -} - -@Composable -fun NavigationButtonMenu(onButtonClicked: () -> Unit) { - IconButton(onClick = onButtonClicked) { - Icon( - painterResource(MR.images.ic_menu), - stringResource(MR.strings.icon_descr_settings), - tint = MaterialTheme.colors.primary, - ) - } -} - -@Composable -private fun BoxScope.AppBarDivider(onTop: Boolean, fixedAlpha: Boolean, connection: CollapsingAppBarNestedScrollConnection?) { - if (connection != null) { - Divider( - Modifier - .align(if (onTop) Alignment.BottomStart else Alignment.TopStart) - .graphicsLayer { - alpha = if (!onTop || fixedAlpha) 1f else topTitleAlpha(false, connection, 1f) - } - ) - } else { - Divider(Modifier.align(if (onTop) Alignment.BottomStart else Alignment.TopStart)) - } -} - -@Composable -private fun AppBar( - title: @Composable () -> Unit, - modifier: Modifier = Modifier, - navigationIcon: @Composable (RowScope.() -> Unit)? = null, - buttons: @Composable RowScope.() -> Unit = {}, - centered: Boolean, - onTop: Boolean, -) { - val adjustedModifier = modifier - .then(if (onTop) Modifier.statusBarsPadding() else Modifier) - .height(AppBarHeight * fontSizeSqrtMultiplier) - .fillMaxWidth() - .padding(horizontal = AppBarHorizontalPadding) - if (centered) { - AppBarCenterAligned(adjustedModifier, title, navigationIcon, buttons) - } else { - AppBarStartAligned(adjustedModifier, title, navigationIcon, buttons) - } -} - -@Composable -private fun AppBarStartAligned( - modifier: Modifier, - title: @Composable () -> Unit, - navigationIcon: @Composable (RowScope.() -> Unit)? = null, - buttons: @Composable RowScope.() -> Unit -) { - Row( - modifier, - verticalAlignment = Alignment.CenterVertically - ) { - if (navigationIcon != null) { - navigationIcon() - Spacer(Modifier.width(AppBarHorizontalPadding)) - } else { - Spacer(Modifier.width(DEFAULT_PADDING)) - } - Row(Modifier - .weight(1f) - .padding(end = DEFAULT_PADDING_HALF) - ) { - title() - } - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - buttons() - } - } -} - -@Composable -private fun AppBarCenterAligned( - modifier: Modifier, - title: @Composable () -> Unit, - navigationIcon: @Composable (RowScope.() -> Unit)? = null, - buttons: @Composable RowScope.() -> Unit, -) { - CenteredRowLayout(modifier) { - if (navigationIcon != null) { - Row( - Modifier.padding(end = AppBarHorizontalPadding), - verticalAlignment = Alignment.CenterVertically, - content = navigationIcon - ) - } else { - Spacer(Modifier) - } - Row( - Modifier.padding(end = DEFAULT_PADDING_HALF) - ) { - title() - } - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - buttons() - } - } -} - -private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) = - if (!connection.scrollTrackingEnabled) 0f - else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f - else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha) - -val AppBarHeight = 56.dp -val AppBarHorizontalPadding = 2.dp +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.* +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.CenteredRowLayout +import chat.simplex.res.MR +import kotlin.math.absoluteValue + +@Composable +fun DefaultAppBar( + navigationButton: (@Composable RowScope.() -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + fixedTitleText: String? = null, + onTitleClick: (() -> Unit)? = null, + onTop: Boolean, + showSearch: Boolean = false, + searchAlwaysVisible: Boolean = false, + searchPlaceholder: String? = null, + onSearchValueChanged: (String) -> Unit = {}, + searchTrailingContent: @Composable (() -> Unit)? = null, + buttons: @Composable RowScope.() -> Unit = {}, +) { + // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier + val modifier = if (!showSearch) { + Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) + } else if (!onTop) Modifier.imePadding() + else Modifier + + val toolbarTint = MaterialTheme.appColors.toolbar + val themeBackgroundMix = if (toolbarTint.alpha > 0f) toolbarTint else panelBackgroundColor() + val prefAlpha = remember { appPrefs.inAppBarsAlpha.state } + val handler = LocalAppBarHandler.current + val connection = LocalAppBarHandler.current?.connection + val titleText = remember(handler?.title?.value, fixedTitleText) { + if (fixedTitleText != null) { + mutableStateOf(fixedTitleText) + } else { + handler?.title ?: mutableStateOf("") + } + } + val keyboardInset = WindowInsets.ime + Box(modifier) { + val density = LocalDensity.current + val blurRadius = remember { appPrefs.appearanceBarsBlurRadius.state } + Box(Modifier + .matchParentSize() + .blurredBackgroundModifier(keyboardInset, handler, blurRadius, prefAlpha, handler?.keyboardCoversBar == true, onTop, density) + .drawWithCache { + // store it as a variable, don't put it inside if without holding it here. Compiler don't see it changes otherwise + val alpha = prefAlpha.value + val backgroundColor = if (title != null || fixedTitleText != null || connection == null || !onTop) { + themeBackgroundMix.copy(alpha) + } else { + themeBackgroundMix.copy(topTitleAlpha(false, connection)) + } + onDrawBehind { + drawRect(backgroundColor) + } + } + ) + Box( + Modifier + .fillMaxWidth() + .then(if (!onTop) Modifier.navigationBarsPadding() else Modifier) + .heightIn(min = AppBarHeight * fontSizeSqrtMultiplier) + ) { + AppBar( + title = { + if (showSearch) { + val placeholder = searchPlaceholder ?: stringResource(MR.strings.search_verb) + SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) + } else if (title != null) { + title() + } else if (titleText.value.isNotEmpty() && connection != null) { + Row( + Modifier + .graphicsLayer { + alpha = if (fixedTitleText != null) 1f else topTitleAlpha(true, connection) + } + ) { + Text( + titleText.value, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + navigationIcon = navigationButton, + buttons = if (!showSearch) buttons else {{}}, + centered = !showSearch && (title != null || !onTop), + onTop = onTop, + ) + AppBarDivider(onTop, title != null || fixedTitleText != null, connection) + } + } +} + + +@Composable +fun CallAppBar( + title: @Composable () -> Unit, + onBack: () -> Unit +) { + AppBar( + title, + navigationIcon = { NavigationButtonBack(tintColor = Color(0xFFFFFFD8), onButtonClicked = onBack) }, + centered = false, + onTop = true + ) +} + +@Composable +fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) { + IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { + Icon( + painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor + ) + } +} + +@Composable +fun NavigationButtonClose(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) { + IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { + Icon( + painterResource(MR.images.ic_close), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor + ) + } +} + +@Composable +fun ShareButton(onButtonClicked: () -> Unit) { + IconButton(onButtonClicked) { + Icon( + painterResource(MR.images.ic_share), stringResource(MR.strings.share_verb), tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +fun NavigationButtonMenu(onButtonClicked: () -> Unit) { + IconButton(onClick = onButtonClicked) { + Icon( + painterResource(MR.images.ic_menu), + stringResource(MR.strings.icon_descr_settings), + tint = MaterialTheme.colors.primary, + ) + } +} + +@Composable +private fun BoxScope.AppBarDivider(onTop: Boolean, fixedAlpha: Boolean, connection: CollapsingAppBarNestedScrollConnection?) { + if (connection != null) { + Divider( + Modifier + .align(if (onTop) Alignment.BottomStart else Alignment.TopStart) + .graphicsLayer { + alpha = if (!onTop || fixedAlpha) 1f else topTitleAlpha(false, connection, 1f) + } + ) + } else { + Divider(Modifier.align(if (onTop) Alignment.BottomStart else Alignment.TopStart)) + } +} + +@Composable +private fun AppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable (RowScope.() -> Unit)? = null, + buttons: @Composable RowScope.() -> Unit = {}, + centered: Boolean, + onTop: Boolean, +) { + val adjustedModifier = modifier + .then(if (onTop) Modifier.statusBarsPadding() else Modifier) + .height(AppBarHeight * fontSizeSqrtMultiplier) + .fillMaxWidth() + .padding(horizontal = AppBarHorizontalPadding) + if (centered) { + AppBarCenterAligned(adjustedModifier, title, navigationIcon, buttons) + } else { + AppBarStartAligned(adjustedModifier, title, navigationIcon, buttons) + } +} + +@Composable +private fun AppBarStartAligned( + modifier: Modifier, + title: @Composable () -> Unit, + navigationIcon: @Composable (RowScope.() -> Unit)? = null, + buttons: @Composable RowScope.() -> Unit +) { + Row( + modifier, + verticalAlignment = Alignment.CenterVertically + ) { + if (navigationIcon != null) { + navigationIcon() + Spacer(Modifier.width(AppBarHorizontalPadding)) + } else { + Spacer(Modifier.width(DEFAULT_PADDING)) + } + Row(Modifier + .weight(1f) + .padding(end = DEFAULT_PADDING_HALF) + ) { + title() + } + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + buttons() + } + } +} + +@Composable +private fun AppBarCenterAligned( + modifier: Modifier, + title: @Composable () -> Unit, + navigationIcon: @Composable (RowScope.() -> Unit)? = null, + buttons: @Composable RowScope.() -> Unit, +) { + CenteredRowLayout(modifier) { + if (navigationIcon != null) { + Row( + Modifier.padding(end = AppBarHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, + content = navigationIcon + ) + } else { + Spacer(Modifier) + } + Row( + Modifier.padding(end = DEFAULT_PADDING_HALF) + ) { + title() + } + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + buttons() + } + } +} + +private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) = + if (!connection.scrollTrackingEnabled) 0f + else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f + else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha) + +val AppBarHeight = 56.dp +val AppBarHorizontalPadding = 2.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index d7cdf0e2e3..7223ef7779 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -284,6 +284,7 @@ fun ModalData.ChatWallpaperEditor( ThemeColor.SECONDARY_VARIANT -> currentTheme.colors.secondaryVariant ThemeColor.BACKGROUND -> currentTheme.colors.background ThemeColor.SURFACE -> currentTheme.colors.surface + ThemeColor.TOOLBAR -> currentTheme.appColors.toolbar ThemeColor.TITLE -> currentTheme.appColors.title ThemeColor.PRIMARY_VARIANT2 -> currentTheme.appColors.primaryVariant2 ThemeColor.SENT_MESSAGE -> currentTheme.appColors.sentMessage diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index e24c09afd0..5de1fe99b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -1,1285 +1,1554 @@ -package chat.simplex.common.views.usersettings - -import SectionBottomSpacer -import SectionDividerSpaced -import SectionItemView -import SectionItemViewSpaceBetween -import SectionItemViewWithoutMinPadding -import SectionSpacer -import SectionView -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.material.MaterialTheme.colors -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.* -import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.* -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex -import chat.simplex.common.ui.theme.ThemeManager.toReadableHex -import chat.simplex.common.views.chat.item.PreviewChatItemView -import chat.simplex.common.views.chat.item.msgTailWidthDp -import chat.simplex.res.MR -import com.godaddy.android.colorpicker.ClassicColorPicker -import com.godaddy.android.colorpicker.HsvColor -import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.datetime.Clock -import kotlinx.serialization.encodeToString -import java.io.File -import java.net.URI -import java.util.* -import kotlin.collections.ArrayList -import kotlin.math.* - -@Composable -expect fun AppearanceView(m: ChatModel) - -object AppearanceScope { - @Composable - fun ProfileImageSection() { - SectionView(stringResource(MR.strings.settings_section_title_profile_images).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - val image = remember { chatModel.currentUser }.value?.image - Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - val size = 60 - Box(Modifier.offset(x = -(size / 12).dp)) { - if (!image.isNullOrEmpty()) { - ProfileImage(size.dp, image, MR.images.ic_simplex_light, color = Color.Unspecified) - } else { - ProfileImage(size.dp, if (isInDarkTheme()) MR.images.ic_simplex_light else MR.images.ic_simplex_dark) - } - } - Spacer(Modifier.width(DEFAULT_PADDING_HALF - (size / 12).dp)) - Slider( - remember { appPreferences.profileImageCornerRadius.state }.value, - valueRange = 0f..50f, - steps = 20, - onValueChange = { - val diff = it % 2.5f - appPreferences.profileImageCornerRadius.set(it + (if (diff >= 1.25f) -diff + 2.5f else -diff)) - saveThemeToDatabase(null) - }, - colors = SliderDefaults.colors( - activeTickColor = Color.Transparent, - inactiveTickColor = Color.Transparent, - ) - ) - } - } - } - - @Composable - fun AppToolbarsSection() { - BoxWithConstraints { - SectionView(stringResource(MR.strings.appearance_app_toolbars).uppercase()) { - SectionItemViewWithoutMinPadding { - Box(Modifier.weight(1f)) { - Text( - stringResource(MR.strings.appearance_in_app_bars_alpha), - Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - appPrefs.inAppBarsAlpha.set(appPrefs.inAppBarsDefaultAlpha) - }, - maxLines = 1 - ) - } - Spacer(Modifier.padding(end = 10.dp)) - Slider( - (1 - remember { appPrefs.inAppBarsAlpha.state }.value).coerceIn(0f, 0.5f), - onValueChange = { - val diff = it % 0.025f - appPrefs.inAppBarsAlpha.set(1f - (String.format(Locale.US, "%.3f", it + (if (diff >= 0.0125f) -diff + 0.025f else -diff)).toFloatOrNull() ?: 1f)) - }, - Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f), - valueRange = 0f..0.5f, - steps = 21, - colors = SliderDefaults.colors( - activeTickColor = Color.Transparent, - inactiveTickColor = Color.Transparent, - ) - ) - } - // In Android in OneHandUI there is a problem with setting initial value of blur if it was 0 before entering the screen. - // So doing in two steps works ok - fun saveBlur(value: Int) { - val oneHandUI = appPrefs.oneHandUI.get() - val pref = appPrefs.appearanceBarsBlurRadius - if (appPlatform.isAndroid && oneHandUI && pref.get() == 0) { - pref.set(if (value > 2) value - 1 else value + 1) - withApi { - delay(50) - pref.set(value) - } - } else { - pref.set(value) - } - } - val blur = remember { appPrefs.appearanceBarsBlurRadius.state } - if (appPrefs.deviceSupportsBlur || blur.value > 0) { - SectionItemViewWithoutMinPadding { - Box(Modifier.weight(1f)) { - Text( - stringResource(MR.strings.appearance_bars_blur_radius), - Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - saveBlur(50) - }, - maxLines = 1 - ) - } - Spacer(Modifier.padding(end = 10.dp)) - Slider( - blur.value.toFloat() / 100f, - onValueChange = { - val diff = it % 0.05f - saveBlur(((String.format(Locale.US, "%.2f", it + (if (diff >= 0.025f) -diff + 0.05f else -diff)).toFloatOrNull() ?: 1f) * 100).toInt()) - }, - Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f), - valueRange = 0f..1f, - steps = 21, - colors = SliderDefaults.colors( - activeTickColor = Color.Transparent, - inactiveTickColor = Color.Transparent, - ) - ) - } - } - } - } - } - - @Composable - fun MessageShapeSection() { - BoxWithConstraints { - SectionView(stringResource(MR.strings.settings_section_title_message_shape).uppercase()) { - SectionItemViewWithoutMinPadding { - Text(stringResource(MR.strings.settings_message_shape_corner), Modifier.weight(1f)) - Spacer(Modifier.width(10.dp)) - Slider( - remember { appPreferences.chatItemRoundness.state }.value, - onValueChange = { - val diff = it % 0.05f - appPreferences.chatItemRoundness.set(it + (if (diff >= 0.025f) -diff + 0.05f else -diff)) - saveThemeToDatabase(null) - }, - Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f), - valueRange = 0f..1f, - steps = 20, - colors = SliderDefaults.colors( - activeTickColor = Color.Transparent, - inactiveTickColor = Color.Transparent, - ) - ) - } - if (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) > 27) { - SettingsPreferenceItem(icon = null, stringResource(MR.strings.settings_message_shape_tail), appPreferences.chatItemTail) - } - } - } - } - - @Composable - fun FontScaleSection() { - val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) } - SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(50.dp) - .background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22)) - .clip(RoundedCornerShape(percent = 22)) - .clickable { - localFontScale.value = 1f - appPrefs.fontScale.set(localFontScale.value) - }, - contentAlignment = Alignment.Center) { - CompositionLocalProvider( - LocalDensity provides Density(LocalDensity.current.density, localFontScale.value) - ) { - Text("Aa", color = if (localFontScale.value == 1f) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground) - } - } - Spacer(Modifier.width(15.dp)) - // Text("${(localFontScale.value * 100).roundToInt()}%", Modifier.width(70.dp), textAlign = TextAlign.Center, fontSize = 12.sp) - if (appPlatform.isAndroid) { - Slider( - localFontScale.value, - valueRange = 0.75f..1.25f, - steps = 11, - onValueChange = { - val diff = it % 0.05f - localFontScale.value = String.format(Locale.US, "%.2f", it + (if (diff >= 0.025f) -diff + 0.05f else -diff)).toFloatOrNull() ?: 1f - }, - onValueChangeFinished = { - appPrefs.fontScale.set(localFontScale.value) - }, - colors = SliderDefaults.colors( - activeTickColor = Color.Transparent, - inactiveTickColor = Color.Transparent, - ) - ) - } else { - Slider( - localFontScale.value, - valueRange = 0.7f..1.5f, - steps = 9, - onValueChange = { - val diff = it % 0.1f - localFontScale.value = String.format(Locale.US, "%.1f", it + (if (diff >= 0.05f) -diff + 0.1f else -diff)).toFloatOrNull() ?: 1f - }, - onValueChangeFinished = { - appPrefs.fontScale.set(localFontScale.value) - }, - colors = SliderDefaults.colors( - activeTickColor = Color.Transparent, - inactiveTickColor = Color.Transparent, - ) - ) - } - } - } - } - - @Composable - fun ChatThemePreview( - theme: DefaultTheme, - wallpaperImage: ImageBitmap?, - wallpaperType: WallpaperType?, - backgroundColor: Color? = MaterialTheme.wallpaper.background, - tintColor: Color? = MaterialTheme.wallpaper.tint, - withMessages: Boolean = true - ) { - val themeBackgroundColor = MaterialTheme.colors.background - val backgroundColor = backgroundColor ?: wallpaperType?.defaultBackgroundColor(theme, MaterialTheme.colors.background) - val tintColor = tintColor ?: wallpaperType?.defaultTintColor(theme) - Column(Modifier - .drawWithCache { - if (wallpaperImage != null && wallpaperType != null && backgroundColor != null && tintColor != null) { - chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, null, null) - } else { - onDrawBehind { - drawRect(themeBackgroundColor) - } - } - } - .padding(DEFAULT_PADDING_HALF) - ) { - if (withMessages) { - val chatItemTail = remember { appPreferences.chatItemTail.state } - - Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = if (chatItemTail.value) Modifier else Modifier.padding(horizontal = msgTailWidthDp)) { - val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) } - PreviewChatItemView(alice) - PreviewChatItemView( - ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice), - quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text)) - ) - ) - } - } else { - Box(Modifier.fillMaxSize()) - } - } - } - - @Composable - fun WallpaperPresetSelector( - selectedWallpaper: WallpaperType?, - baseTheme: DefaultTheme, - activeBackgroundColor: Color? = null, - activeTintColor: Color? = null, - currentColors: (WallpaperType?) -> ThemeManager.ActiveTheme, - onChooseType: (WallpaperType?) -> Unit, - ) { - val cornerRadius = 22 - - @Composable - fun Plus(tint: Color = MaterialTheme.colors.primary) { - Icon(painterResource(MR.images.ic_add), null, Modifier.size(25.dp), tint = tint) - } - - val backgrounds = PresetWallpaper.entries.toList() - - fun LazyGridScope.gridContent(width: Dp, height: Dp) { - @Composable - fun BackgroundItem(background: PresetWallpaper?) { - val checked = (background == null && (selectedWallpaper == null || selectedWallpaper == WallpaperType.Empty)) || selectedWallpaper?.samePreset(background) == true - Box( - Modifier - .size(width, height) - .clip(RoundedCornerShape(percent = cornerRadius)) - .border(1.dp, if (checked) MaterialTheme.colors.primary.copy(0.8f) else MaterialTheme.colors.onBackground.copy(if (isInDarkTheme()) 0.2f else 0.1f), RoundedCornerShape(percent = cornerRadius)) - .clickable { onChooseType(background?.toType(baseTheme)) }, - contentAlignment = Alignment.Center - ) { - if (background != null) { - val type = background.toType(baseTheme, if (checked) selectedWallpaper?.scale else null) - SimpleXThemeOverride(remember(background, selectedWallpaper, CurrentColors.collectAsState().value) { currentColors(type) }) { - ChatThemePreview( - baseTheme, - type.image, - type, - withMessages = false, - backgroundColor = if (checked) activeBackgroundColor ?: MaterialTheme.wallpaper.background else MaterialTheme.wallpaper.background, - tintColor = if (checked) activeTintColor ?: MaterialTheme.wallpaper.tint else MaterialTheme.wallpaper.tint - ) - } - } - } - } - - @Composable - fun OwnBackgroundItem(type: WallpaperType?) { - val overrides = remember(type, baseTheme, CurrentColors.collectAsState().value.wallpaper) { - currentColors(WallpaperType.Image("", null, null)) - } - val appWallpaper = overrides.wallpaper - val backgroundColor = appWallpaper.background - val tintColor = appWallpaper.tint - val wallpaperImage = appWallpaper.type.image - val checked = type is WallpaperType.Image && wallpaperImage != null - val remoteHostConnected = chatModel.remoteHostId != null - Box( - Modifier - .size(width, height) - .clip(RoundedCornerShape(percent = cornerRadius)) - .border(1.dp, if (type is WallpaperType.Image) MaterialTheme.colors.primary.copy(0.8f) else MaterialTheme.colors.onBackground.copy(0.1f), RoundedCornerShape(percent = cornerRadius)) - .clickable { onChooseType(WallpaperType.Image("", null, null)) }, - contentAlignment = Alignment.Center - ) { - - if (checked || wallpaperImage != null) { - ChatThemePreview( - baseTheme, - wallpaperImage, - if (checked) type else appWallpaper.type, - backgroundColor = if (checked) activeBackgroundColor ?: backgroundColor else backgroundColor, - tintColor = if (checked) activeTintColor ?: tintColor else tintColor, - withMessages = false - ) - } else if (remoteHostConnected) { - Plus(MaterialTheme.colors.error) - } else { - Plus() - } - } - } - - item { - BackgroundItem(null) - } - items(items = backgrounds) { background -> - BackgroundItem(background) - } - item { - OwnBackgroundItem(selectedWallpaper) - } - } - - SimpleXThemeOverride(remember(selectedWallpaper, CurrentColors.collectAsState().value) { currentColors(selectedWallpaper) }) { - ChatThemePreview( - baseTheme, - MaterialTheme.wallpaper.type.image, - selectedWallpaper, - backgroundColor = activeBackgroundColor ?: MaterialTheme.wallpaper.background, - tintColor = activeTintColor ?: MaterialTheme.wallpaper.tint, - ) - } - - if (appPlatform.isDesktop) { - val itemWidth = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2 - DEFAULT_PADDING_HALF * 3) / 4 - val itemHeight = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2) / 4 - val rows = ceil((PresetWallpaper.entries.size + 2) / 4f).roundToInt() - LazyVerticalGrid( - columns = GridCells.Fixed(4), - Modifier.height(itemHeight * rows + DEFAULT_PADDING_HALF * (rows - 1) + DEFAULT_PADDING * 2), - contentPadding = PaddingValues(DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), - horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), - ) { - gridContent(itemWidth, itemHeight) - } - } else { - LazyHorizontalGrid( - rows = GridCells.Fixed(1), - Modifier.height(80.dp + DEFAULT_PADDING * 2), - contentPadding = PaddingValues(DEFAULT_PADDING), - horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), - ) { - gridContent(80.dp, 80.dp) - } - } - } - - @Composable - fun ThemesSection(systemDarkTheme: SharedPreference) { - val currentTheme by CurrentColors.collectAsState() - val baseTheme = currentTheme.base - val wallpaperType = MaterialTheme.wallpaper.type - val themeUserDestination: MutableState?> = rememberSaveable(stateSaver = serializableSaver()) { - val currentUser = chatModel.currentUser.value - mutableStateOf( - if (currentUser?.uiThemes?.preferredMode(!currentTheme.colors.isLight) == null) null else currentUser.userId to currentUser.uiThemes - ) - } - val perUserTheme = remember(CurrentColors.collectAsState().value.base, chatModel.currentUser.value) { - mutableStateOf( - chatModel.currentUser.value?.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) ?: ThemeModeOverride() - ) - } - - fun updateThemeUserDestination() { - var (userId, themes) = themeUserDestination.value ?: return - themes = if (perUserTheme.value.mode == DefaultThemeMode.LIGHT) { - (themes ?: ThemeModeOverrides()).copy(light = perUserTheme.value) - } else { - (themes ?: ThemeModeOverrides()).copy(dark = perUserTheme.value) - } - themeUserDestination.value = userId to themes - } - - val onTypeCopyFromSameTheme = { type: WallpaperType? -> - if (themeUserDestination.value == null) { - ThemeManager.saveAndApplyWallpaper(baseTheme, type) - } else { - val wallpaperFiles = setOf(perUserTheme.value.wallpaper?.imageFile) - ThemeManager.copyFromSameThemeOverrides(type, null, perUserTheme) - val wallpaperFilesToDelete = wallpaperFiles - perUserTheme.value.wallpaper?.imageFile - wallpaperFilesToDelete.forEach(::removeWallpaperFile) - updateThemeUserDestination() - } - saveThemeToDatabase(themeUserDestination.value) - true - } - - val onTypeChange = { type: WallpaperType? -> - if (themeUserDestination.value == null) { - ThemeManager.saveAndApplyWallpaper(baseTheme, type) - } else { - ThemeManager.applyWallpaper(type, perUserTheme) - updateThemeUserDestination() - } - saveThemeToDatabase(themeUserDestination.value) - } - - val onImport = { to: URI -> - val filename = saveWallpaperFile(to) - if (filename != null) { - if (themeUserDestination.value == null) { - removeWallpaperFile((currentTheme.wallpaper.type as? WallpaperType.Image)?.filename) - } else { - removeWallpaperFile((perUserTheme.value.type as? WallpaperType.Image)?.filename) - } - onTypeChange(WallpaperType.Image(filename, 1f, WallpaperScaleType.FILL)) - } - } - - val currentColors = { type: WallpaperType? -> - // If applying for : - // - all themes: no overrides needed - // - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected - val perUserOverride = if (themeUserDestination.value == null) null else if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null - ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get()) - } - - val onChooseType: (WallpaperType?, FileChooserLauncher) -> Unit = { type: WallpaperType?, importWallpaperLauncher: FileChooserLauncher -> - when { - // don't have image in parent or already selected wallpaper with custom image - type is WallpaperType.Image && - ((wallpaperType is WallpaperType.Image && themeUserDestination.value?.second != null && chatModel.remoteHostId() == null) || - currentColors(type).wallpaper.type.image == null || - (currentColors(type).wallpaper.type.image != null && CurrentColors.value.wallpaper.type is WallpaperType.Image && themeUserDestination.value == null)) -> - withLongRunningApi { importWallpaperLauncher.launch("image/*") } - type is WallpaperType.Image && themeUserDestination.value == null -> onTypeChange(currentColors(type).wallpaper.type) - type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing when remote host connected */ } - type is WallpaperType.Image -> onTypeCopyFromSameTheme(currentColors(type).wallpaper.type) - (themeUserDestination.value != null && themeUserDestination.value?.second?.preferredMode(!CurrentColors.value.colors.isLight)?.type != type) || CurrentColors.value.wallpaper.type != type -> onTypeCopyFromSameTheme(type) - else -> onTypeChange(type) - } - } - - SectionView(stringResource(MR.strings.settings_section_title_themes)) { - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) - ThemeDestinationPicker(themeUserDestination) - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) - - val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> - if (to != null) onImport(to) - } - - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - currentColors(type) - }, - onChooseType = { onChooseType(it, importWallpaperLauncher) }, - ) - val type = MaterialTheme.wallpaper.type - if (type is WallpaperType.Image && (themeUserDestination.value == null || perUserTheme.value.wallpaper?.imageFile != null)) { - SectionItemView(disabled = chatModel.remoteHostId != null && themeUserDestination.value != null, click = { - if (themeUserDestination.value == null) { - val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) - ThemeManager.saveAndApplyWallpaper(baseTheme, null) - ThemeManager.removeTheme(defaultActiveTheme?.themeId) - removeWallpaperFile(type.filename) - } else { - removeUserThemeModeOverrides(themeUserDestination, perUserTheme) - } - saveThemeToDatabase(themeUserDestination.value) - }) { - Text( - stringResource(MR.strings.theme_remove_image), - color = if (chatModel.remoteHostId != null && themeUserDestination.value != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary - ) - } - SectionSpacer() - } - - val state: State = remember(appPrefs.currentTheme.get()) { - derivedStateOf { - if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME) null else currentTheme.base.mode - } - } - ColorModeSelector(state) { - val newTheme = when (it) { - null -> DefaultTheme.SYSTEM_THEME_NAME - DefaultThemeMode.LIGHT -> DefaultTheme.LIGHT.themeName - DefaultThemeMode.DARK -> appPrefs.systemDarkTheme.get()!! - } - ThemeManager.applyTheme(newTheme) - saveThemeToDatabase(null) - } - - // Doesn't work on desktop when specified like remember { systemDarkTheme.state }, this is workaround - val darkModeState: State = remember(systemDarkTheme.get()) { derivedStateOf { systemDarkTheme.get() } } - DarkModeThemeSelector(darkModeState) { - ThemeManager.changeDarkTheme(it) - if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME) { - ThemeManager.applyTheme(appPrefs.currentTheme.get()!!) - } else if (appPrefs.currentTheme.get() != DefaultTheme.LIGHT.themeName) { - ThemeManager.applyTheme(appPrefs.systemDarkTheme.get()!!) - } - saveThemeToDatabase(null) - } - } - SectionItemView(click = { - val user = themeUserDestination.value - if (user == null) { - ModalManager.start.showModal { - val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> - if (to != null) onImport(to) - } - CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } - } - } else { - ModalManager.start.showModalCloseable { close -> - UserWallpaperEditorModal(chatModel.remoteHostId(), user.first, close) - } - } - }) { - Text(stringResource(MR.strings.customize_theme_title)) - } - } - - @Composable - fun CustomizeThemeView(onChooseType: (WallpaperType?) -> Unit) { - ColumnWithScrollBar { - val currentTheme by CurrentColors.collectAsState() - - AppBarTitle(stringResource(MR.strings.customize_theme_title)) - val wallpaperImage = MaterialTheme.wallpaper.type.image - val wallpaperType = MaterialTheme.wallpaper.type - val baseTheme = CurrentColors.collectAsState().value.base - - val editColor = { name: ThemeColor -> - editColor( - name, - wallpaperType, - wallpaperImage, - onColorChange = { color -> - ThemeManager.saveAndApplyThemeColor(baseTheme, name, color) - saveThemeToDatabase(null) - } - ) - } - - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - ThemeManager.currentColors(type, null, null, appPrefs.themeOverrides.get()) - }, - onChooseType = onChooseType - ) - - val type = MaterialTheme.wallpaper.type - if (type is WallpaperType.Image) { - SectionItemView(disabled = chatModel.remoteHostId != null, click = { - val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) - ThemeManager.saveAndApplyWallpaper(baseTheme, null) - ThemeManager.removeTheme(defaultActiveTheme?.themeId) - removeWallpaperFile(type.filename) - saveThemeToDatabase(null) - }) { - Text( - stringResource(MR.strings.theme_remove_image), - color = if (chatModel.remoteHostId == null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - SectionSpacer() - } - - SectionView(stringResource(MR.strings.settings_section_title_chat_colors).uppercase()) { - WallpaperSetupView( - wallpaperType, - baseTheme, - MaterialTheme.wallpaper, - MaterialTheme.appColors.sentMessage, - MaterialTheme.appColors.sentQuote, - MaterialTheme.appColors.receivedMessage, - MaterialTheme.appColors.receivedQuote, - editColor = { name -> - editColor(name) - }, - onTypeChange = { type -> - ThemeManager.saveAndApplyWallpaper(baseTheme, type) - saveThemeToDatabase(null) - }, - ) - } - SectionDividerSpaced() - - CustomizeThemeColorsSection(currentTheme) { name -> - editColor(name) - } - - SectionDividerSpaced(maxBottomPadding = false) - - val currentOverrides = remember(currentTheme) { ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) } - val canResetColors = currentTheme.base.hasChangedAnyColor(currentOverrides) - if (canResetColors) { - SectionItemView({ - ThemeManager.resetAllThemeColors() - saveThemeToDatabase(null) - }) { - Text(generalGetString(MR.strings.reset_color), color = colors.primary) - } - SectionSpacer() - } - - SectionView { - val theme = remember { mutableStateOf(null as String?) } - val exportThemeLauncher = rememberFileChooserLauncher(false) { to: URI? -> - val themeValue = theme.value - if (themeValue != null && to != null) { - copyBytesToFile(themeValue.byteInputStream(), to) { - theme.value = null - } - } - } - SectionItemView({ - val overrides = ThemeManager.currentThemeOverridesForExport(null, null/*chatModel.currentUser.value?.uiThemes*/) - val lines = yaml.encodeToString(overrides).lines() - // Removing theme id without using custom serializer or data class - theme.value = lines.subList(1, lines.size).joinToString("\n") - withLongRunningApi { exportThemeLauncher.launch("simplex.theme") } - }) { - Text(generalGetString(MR.strings.export_theme), color = colors.primary) - } - val importThemeLauncher = rememberFileChooserLauncher(true) { to: URI? -> - if (to != null) { - val theme = getThemeFromUri(to) - if (theme != null) { - ThemeManager.saveAndApplyThemeOverrides(theme) - saveThemeToDatabase(null) - } - } - } - // Can not limit to YAML mime type since it's unsupported by Android - SectionItemView({ withLongRunningApi { importThemeLauncher.launch("*/*") } }) { - Text(generalGetString(MR.strings.import_theme), color = colors.primary) - } - } - SectionBottomSpacer() - } - } - - @Composable - fun ColorModeSwitcher() { - val currentTheme by CurrentColors.collectAsState() - val themeMode = if (remember { appPrefs.currentTheme.state }.value == DefaultTheme.SYSTEM_THEME_NAME) { - if (systemInDarkThemeCurrently) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT - } else { - currentTheme.base.mode - } - - val onLongClick = { - ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) - showToast(generalGetString(MR.strings.system_mode_toast)) - - saveThemeToDatabase(null) - } - Box( - modifier = Modifier - .clip(CircleShape) - .combinedClickable( - onClick = { - ThemeManager.applyTheme(if (themeMode == DefaultThemeMode.LIGHT) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName) - saveThemeToDatabase(null) - }, - onLongClick = onLongClick - ) - .onRightClick(onLongClick) - .size(44.dp), - contentAlignment = Alignment.Center - ) { - Icon(painterResource(if (themeMode == DefaultThemeMode.LIGHT) MR.images.ic_light_mode else MR.images.ic_bedtime_moon), stringResource(MR.strings.color_mode_light), tint = MaterialTheme.colors.secondary) - } - } - - private var updateBackendJob: Job = Job() - private fun saveThemeToDatabase(themeUserDestination: Pair?) { - val remoteHostId = chatModel.remoteHostId() - val oldThemes = chatModel.currentUser.value?.uiThemes - if (themeUserDestination != null) { - // Update before save to make it work seamless - chatModel.updateCurrentUserUiThemes(remoteHostId, themeUserDestination.second) - } - updateBackendJob.cancel() - updateBackendJob = withBGApi { - delay(300) - if (themeUserDestination == null) { - controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) - } else if (!controller.apiSetUserUIThemes(remoteHostId, themeUserDestination.first, themeUserDestination.second)) { - // If failed to apply for some reason return the old themes - chatModel.updateCurrentUserUiThemes(remoteHostId, oldThemes) - } - } - } - - fun editColor(name: ThemeColor, wallpaperType: WallpaperType, wallpaperImage: ImageBitmap?, onColorChange: (Color?) -> Unit) { - ModalManager.start.showModal { - val baseTheme = CurrentColors.collectAsState().value.base - val wallpaperBackgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(baseTheme, MaterialTheme.colors.background) - val wallpaperTintColor = MaterialTheme.wallpaper.tint ?: wallpaperType.defaultTintColor(baseTheme) - val initialColor: Color = when (name) { - ThemeColor.WALLPAPER_BACKGROUND -> wallpaperBackgroundColor - ThemeColor.WALLPAPER_TINT -> wallpaperTintColor - ThemeColor.PRIMARY -> MaterialTheme.colors.primary - ThemeColor.PRIMARY_VARIANT -> MaterialTheme.colors.primaryVariant - ThemeColor.SECONDARY -> MaterialTheme.colors.secondary - ThemeColor.SECONDARY_VARIANT -> MaterialTheme.colors.secondaryVariant - ThemeColor.BACKGROUND -> MaterialTheme.colors.background - ThemeColor.SURFACE -> MaterialTheme.colors.surface - ThemeColor.TITLE -> MaterialTheme.appColors.title - ThemeColor.PRIMARY_VARIANT2 -> MaterialTheme.appColors.primaryVariant2 - ThemeColor.SENT_MESSAGE -> MaterialTheme.appColors.sentMessage - ThemeColor.SENT_QUOTE -> MaterialTheme.appColors.sentQuote - ThemeColor.RECEIVED_MESSAGE -> MaterialTheme.appColors.receivedMessage - ThemeColor.RECEIVED_QUOTE -> MaterialTheme.appColors.receivedQuote - } - ColorEditor(name, initialColor, baseTheme, MaterialTheme.wallpaper.type, wallpaperImage, currentColors = { CurrentColors.value }, - onColorChange = onColorChange - ) - } - } - - @Composable - fun ModalData.UserWallpaperEditorModal(remoteHostId: Long?, userId: Long, close: () -> Unit) { - val themes = remember(chatModel.currentUser.value) { mutableStateOf(chatModel.currentUser.value?.uiThemes ?: ThemeModeOverrides()) } - val globalThemeUsed = remember { stateGetOrPut("globalThemeUsed") { false } } - val initialTheme = remember(CurrentColors.collectAsState().value.base) { - val preferred = themes.value.preferredMode(!CurrentColors.value.colors.isLight) - globalThemeUsed.value = preferred == null - preferred ?: ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) - } - UserWallpaperEditor( - initialTheme, - applyToMode = if (themes.value.light == themes.value.dark) null else initialTheme.mode, - globalThemeUsed = globalThemeUsed, - save = { applyToMode, newTheme -> - save(applyToMode, newTheme, themes.value, userId, remoteHostId) - }) - KeyChangeEffect(chatModel.currentUser.value?.userId, chatModel.remoteHostId) { - close() - } - } - - suspend fun save( - applyToMode: DefaultThemeMode?, - newTheme: ThemeModeOverride?, - themes: ThemeModeOverrides?, - userId: Long, - remoteHostId: Long? - ) { - val unchangedThemes: ThemeModeOverrides = themes ?: ThemeModeOverrides() - val wallpaperFiles = setOf(unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile) - var changedThemes: ThemeModeOverrides? = unchangedThemes - val changed = newTheme?.copy(wallpaper = newTheme.wallpaper?.withFilledWallpaperPath()) - changedThemes = when (applyToMode) { - null -> changedThemes?.copy(light = changed?.copy(mode = DefaultThemeMode.LIGHT), dark = changed?.copy(mode = DefaultThemeMode.DARK)) - DefaultThemeMode.LIGHT -> changedThemes?.copy(light = changed?.copy(mode = applyToMode)) - DefaultThemeMode.DARK -> changedThemes?.copy(dark = changed?.copy(mode = applyToMode)) - } - changedThemes = if (changedThemes?.light != null || changedThemes?.dark != null) { - val light = changedThemes.light - val dark = changedThemes.dark - val currentMode = CurrentColors.value.base.mode - // same image file for both modes, copy image to make them as different files - if (light?.wallpaper?.imageFile != null && dark?.wallpaper?.imageFile != null && light.wallpaper.imageFile == dark.wallpaper.imageFile) { - val imageFile = if (currentMode == DefaultThemeMode.LIGHT) { - dark.wallpaper.imageFile - } else { - light.wallpaper.imageFile - } - val filePath = saveWallpaperFile(File(getWallpaperFilePath(imageFile)).toURI()) - changedThemes = if (currentMode == DefaultThemeMode.LIGHT) { - changedThemes.copy(dark = dark.copy(wallpaper = dark.wallpaper.copy(imageFile = filePath))) - } else { - changedThemes.copy(light = light.copy(wallpaper = light.wallpaper.copy(imageFile = filePath))) - } - } - changedThemes - } else { - null - } - - val wallpaperFilesToDelete = wallpaperFiles - changedThemes?.light?.wallpaper?.imageFile - changedThemes?.dark?.wallpaper?.imageFile - wallpaperFilesToDelete.forEach(::removeWallpaperFile) - - val oldThemes = chatModel.currentUser.value?.uiThemes - // Update before save to make it work seamless - chatModel.updateCurrentUserUiThemes(remoteHostId, changedThemes) - updateBackendJob.cancel() - updateBackendJob = withBGApi { - delay(300) - if (!controller.apiSetUserUIThemes(remoteHostId, userId, changedThemes)) { - // If failed to apply for some reason return the old themes - chatModel.updateCurrentUserUiThemes(remoteHostId, oldThemes) - } - } - } - - @Composable - fun ThemeDestinationPicker(themeUserDestination: MutableState?>) { - val themeUserDest = remember(themeUserDestination.value?.first) { mutableStateOf(themeUserDestination.value?.first) } - LaunchedEffect(themeUserDestination.value) { - if (themeUserDestination.value == null) { - // Easiest way to hide per-user customization. - // Otherwise, it would be needed to make global variable and to use it everywhere for making a decision to include these overrides into active theme constructing or not - chatModel.currentUser.value = chatModel.currentUser.value?.copy(uiThemes = null) - } else { - chatModel.updateCurrentUserUiThemes(chatModel.remoteHostId(), chatModel.users.firstOrNull { it.user.userId == chatModel.currentUser.value?.userId }?.user?.uiThemes) - } - } - DisposableEffect(Unit) { - onDispose { - // Skip when Appearance screen is not hidden yet - if (ModalManager.start.hasModalsOpen()) return@onDispose - // Restore user overrides from stored list of users - chatModel.updateCurrentUserUiThemes(chatModel.remoteHostId(), chatModel.users.firstOrNull { it.user.userId == chatModel.currentUser.value?.userId }?.user?.uiThemes) - themeUserDestination.value = if (chatModel.currentUser.value?.uiThemes == null) null else chatModel.currentUser.value?.userId!! to chatModel.currentUser.value?.uiThemes - } - } - - val values by remember(chatModel.users.toList()) { mutableStateOf( - listOf(null as Long? to generalGetString(MR.strings.theme_destination_app_theme)) - + - chatModel.users.filter { it.user.activeUser }.map { - it.user.userId to it.user.chatViewName - }, - ) - } - if (values.any { it.first == themeUserDestination.value?.first }) { - ExposedDropDownSettingRow( - generalGetString(MR.strings.chat_theme_apply_to_mode), - values, - themeUserDest, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = { userId -> - themeUserDest.value = userId - if (userId != null) { - themeUserDestination.value = userId to chatModel.users.firstOrNull { it.user.userId == userId }?.user?.uiThemes - } else { - themeUserDestination.value = null - } - if (userId != null && userId != chatModel.currentUser.value?.userId) { - withBGApi { - controller.showProgressIfNeeded { - chatModel.controller.changeActiveUser(chatModel.remoteHostId(), userId, null) - } - } - } - } - ) - } else { - themeUserDestination.value = null - } - } - - @Composable - fun CustomizeThemeColorsSection(currentTheme: ThemeManager.ActiveTheme, editColor: (ThemeColor) -> Unit) { - SectionView(stringResource(MR.strings.theme_colors_section_title)) { - SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY) }) { - val title = generalGetString(MR.strings.color_primary) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.primary) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY_VARIANT) }) { - val title = generalGetString(MR.strings.color_primary_variant) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.primaryVariant) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY_VARIANT2) }) { - val title = generalGetString(MR.strings.color_primary_variant2) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.primaryVariant2) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY) }) { - val title = generalGetString(MR.strings.color_secondary) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.secondary) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY_VARIANT) }) { - val title = generalGetString(MR.strings.color_secondary_variant) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.secondaryVariant) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.BACKGROUND) }) { - val title = generalGetString(MR.strings.color_background) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.background) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SURFACE) }) { - val title = generalGetString(MR.strings.color_surface) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.surface) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.TITLE) }) { - val title = generalGetString(MR.strings.color_title) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.title) - } - } - } - - @Composable - fun ColorEditor( - name: ThemeColor, - initialColor: Color, - theme: DefaultTheme, - wallpaperType: WallpaperType?, - wallpaperImage: ImageBitmap?, - previewBackgroundColor: Color? = MaterialTheme.wallpaper.background, - previewTintColor: Color? = MaterialTheme.wallpaper.tint, - currentColors: () -> ThemeManager.ActiveTheme, - onColorChange: (Color?) -> Unit, - ) { - ColumnWithScrollBar(Modifier.imePadding()) { - AppBarTitle(name.text) - - val supportedLiveChange = name in listOf(ThemeColor.SECONDARY, ThemeColor.BACKGROUND, ThemeColor.SURFACE, ThemeColor.RECEIVED_MESSAGE, ThemeColor.SENT_MESSAGE, ThemeColor.SENT_QUOTE, ThemeColor.WALLPAPER_BACKGROUND, ThemeColor.WALLPAPER_TINT) - if (supportedLiveChange) { - SimpleXThemeOverride(currentColors()) { - ChatThemePreview(theme, wallpaperImage, wallpaperType, previewBackgroundColor, previewTintColor) - } - SectionSpacer() - } - - var currentColor by remember { mutableStateOf(initialColor) } - val togglePicker = remember { mutableStateOf(false) } - Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { - if (togglePicker.value) { - ColorPicker(currentColor, showAlphaBar = wallpaperType is WallpaperType.Image || currentColor.alpha < 1f) { - currentColor = it - onColorChange(currentColor) - } - } else { - ColorPicker(currentColor, showAlphaBar = wallpaperType is WallpaperType.Image || currentColor.alpha < 1f) { - currentColor = it - onColorChange(currentColor) - } - } - } - var allowReloadPicker by remember { mutableStateOf(false) } - KeyChangeEffect(wallpaperType) { - allowReloadPicker = true - } - KeyChangeEffect(initialColor) { - if (initialColor != currentColor && allowReloadPicker) { - currentColor = initialColor - togglePicker.value = !togglePicker.value - } - allowReloadPicker = false - } - val clipboard = LocalClipboardManager.current - val hexTrimmed = currentColor.toReadableHex().replaceFirst("#ff", "#") - val savedColor by remember(wallpaperType) { mutableStateOf(initialColor) } - - Row(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).height(DEFAULT_MIN_SECTION_ITEM_HEIGHT)) { - Box(Modifier.weight(1f).fillMaxHeight().background(savedColor).clickable { - currentColor = savedColor - onColorChange(currentColor) - togglePicker.value = !togglePicker.value - }) - Box(Modifier.weight(1f).fillMaxHeight().background(currentColor).clickable { - clipboard.shareText(hexTrimmed) - }) - } - if (appPrefs.developerTools.get()) { - Row(Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { - val textFieldState = remember { mutableStateOf(TextFieldValue(hexTrimmed)) } - KeyChangeEffect(hexTrimmed) { - textFieldState.value = textFieldState.value.copy(hexTrimmed) - } - DefaultBasicTextField( - Modifier.fillMaxWidth(), - textFieldState, - leadingIcon = { - IconButton(onClick = { clipboard.shareText(hexTrimmed) }) { - Icon(painterResource(MR.images.ic_content_copy), generalGetString(MR.strings.copy_verb), Modifier.size(26.dp), tint = MaterialTheme.colors.primary) - } - }, - onValueChange = { value -> - val color = value.text.trim('#', ' ') - if (color.length == 6 || color.length == 8) { - currentColor = if (color.length == 6) ("ff$color").colorFromReadableHex() else color.colorFromReadableHex() - onColorChange(currentColor) - textFieldState.value = value.copy(currentColor.toReadableHex().replaceFirst("#ff", "#")) - togglePicker.value = !togglePicker.value - } else { - textFieldState.value = value - } - } - ) - } - } - SectionItemView({ - allowReloadPicker = true - onColorChange(null) - }) { - Text(generalGetString(MR.strings.reset_single_color), color = colors.primary) - } - SectionSpacer() - } - } - - - - @Composable - fun LangSelector(state: State, onSelected: (String) -> Unit) { - // Should be the same as in app/build.gradle's `android.defaultConfig.resConfigs` - val supportedLanguages = mapOf( - "system" to generalGetString(MR.strings.language_system), - "en" to "English", - "ar" to "العربية", - "bg" to "Български", - "ca" to "Català", - "cs" to "Čeština", - "de" to "Deutsch", - "es" to "Español", - "fa" to "فارسی", - "fi" to "Suomi", - "fr" to "Français", - "hu" to "Magyar", - "in" to "Indonesia", - "it" to "Italiano", - "iw" to "עִברִית", - "ja" to "日本語", - "lt" to "Lietuvių", - "nl" to "Nederlands", - "pl" to "Polski", - "pt-BR" to "Português, Brasil", - "ro" to "Română", - "ru" to "Русский", - "th" to "ภาษาไทย", - "tr" to "Türkçe", - "uk" to "Українська", - "vi" to "Tiếng Việt", - "zh-CN" to "简体中文" - ) - val values by remember(appPrefs.appLanguage.state.value) { mutableStateOf(supportedLanguages.map { it.key to it.value }) } - ExposedDropDownSettingRow( - generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() }, - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = onSelected - ) - } - - @Composable - private fun ColorModeSelector(state: State, onSelected: (DefaultThemeMode?) -> Unit) { - val values by remember(appPrefs.appLanguage.state.value) { - mutableStateOf( - listOf( - null to generalGetString(MR.strings.color_mode_system), - DefaultThemeMode.LIGHT to generalGetString(MR.strings.color_mode_light), - DefaultThemeMode.DARK to generalGetString(MR.strings.color_mode_dark) - ) - ) - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.color_mode), - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = onSelected - ) - } - - @Composable - private fun DarkModeThemeSelector(state: State, onSelected: (String) -> Unit) { - val values by remember { - val darkThemes = ArrayList>() - darkThemes.add(DefaultTheme.DARK.themeName to generalGetString(MR.strings.theme_dark)) - darkThemes.add(DefaultTheme.SIMPLEX.themeName to generalGetString(MR.strings.theme_simplex)) - darkThemes.add(DefaultTheme.BLACK.themeName to generalGetString(MR.strings.theme_black)) - mutableStateOf(darkThemes.toList()) - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.dark_mode_colors), - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = { if (it != null) onSelected(it) } - ) - } - //private fun openSystemLangPicker(activity: Activity) { - // activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName))) - //} -} - -@Composable -fun WallpaperSetupView( - wallpaperType: WallpaperType?, - theme: DefaultTheme, - initialWallpaper: AppWallpaper?, - initialSentColor: Color, - initialSentQuoteColor: Color, - initialReceivedColor: Color, - initialReceivedQuoteColor: Color, - editColor: (ThemeColor) -> Unit, - onTypeChange: (WallpaperType?) -> Unit, -) { - if (wallpaperType is WallpaperType.Image) { - val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } - val values = remember { - WallpaperScaleType.entries.map { it to generalGetString(it.text) } - } - ExposedDropDownSettingRow( - stringResource(MR.strings.wallpaper_scale), - values, - state, - onSelected = { scaleType -> - onTypeChange(wallpaperType.copy(scaleType = scaleType)) - } - ) - } - - if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { - val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } - Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { - Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) - Slider( - state.value, - valueRange = 0.5f..2f, - onValueChange = { - if (wallpaperType is WallpaperType.Preset) { - onTypeChange(wallpaperType.copy(scale = it)) - } else if (wallpaperType is WallpaperType.Image) { - onTypeChange(wallpaperType.copy(scale = it)) - } - } - ) - } - } - - if (wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image) { - val wallpaperBackgroundColor = initialWallpaper?.background ?: wallpaperType.defaultBackgroundColor(theme, MaterialTheme.colors.background) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_BACKGROUND) }) { - val title = generalGetString(MR.strings.color_wallpaper_background) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) - } - val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { - val title = generalGetString(MR.strings.color_wallpaper_tint) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) - } - SectionSpacer() - } - - SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE) }) { - val title = generalGetString(MR.strings.color_sent_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_QUOTE) }) { - val title = generalGetString(MR.strings.color_sent_quote) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentQuoteColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE) }) { - val title = generalGetString(MR.strings.color_received_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_QUOTE) }) { - val title = generalGetString(MR.strings.color_received_quote) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedQuoteColor) - } -} - -@Composable -private fun ColorPicker(initialColor: Color, showAlphaBar: Boolean, onColorChanged: (Color) -> Unit) { - ClassicColorPicker(modifier = Modifier - .fillMaxWidth() - .height(300.dp), - color = HsvColor.from(color = initialColor), - showAlphaBar = showAlphaBar, - onColorChanged = { color: HsvColor -> - onColorChanged(color.toColor()) - } - ) -} - -private fun removeUserThemeModeOverrides(themeUserDestination: MutableState?>, perUserTheme: MutableState) { - val dest = themeUserDestination.value ?: return - perUserTheme.value = ThemeModeOverride() - themeUserDestination.value = dest.first to null - val wallpaperFilesToDelete = listOf( - (chatModel.currentUser.value?.uiThemes?.light?.type as? WallpaperType.Image)?.filename, - (chatModel.currentUser.value?.uiThemes?.dark?.type as? WallpaperType.Image)?.filename - ) - wallpaperFilesToDelete.forEach(::removeWallpaperFile) -} +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewSpaceBetween +import SectionItemViewWithoutMinPadding +import SectionSpacer +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.* +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex +import chat.simplex.common.ui.theme.ThemeManager.toReadableHex +import chat.simplex.common.views.chat.item.PreviewChatItemView +import chat.simplex.common.views.chat.item.msgTailWidthDp +import chat.simplex.res.MR +import com.godaddy.android.colorpicker.ClassicColorPicker +import com.godaddy.android.colorpicker.HsvColor +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.datetime.Clock +import kotlinx.serialization.encodeToString +import java.io.File +import java.net.URI +import java.util.* +import kotlin.collections.ArrayList +import kotlin.math.* + +@Composable +expect fun AppearanceView(m: ChatModel) + +// Formula slider values — survives navigation, cleared on app restart +private val formulaSavedParams = mutableStateMapOf() + +// Desktop: use fast scaling during slider drag, SCALE_SMOOTH on release +private var patternScaleDragging by mutableStateOf(false) + +object AppearanceScope { + @Composable + fun ProfileImageSection() { + SectionView(stringResource(MR.strings.settings_section_title_profile_images).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + val image = remember { chatModel.currentUser }.value?.image + Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + val size = 60 + Box(Modifier.offset(x = -(size / 12).dp)) { + if (!image.isNullOrEmpty()) { + ProfileImage(size.dp, image, MR.images.ic_simplex_light, color = Color.Unspecified) + } else { + ProfileImage(size.dp, if (isInDarkTheme()) MR.images.ic_simplex_light else MR.images.ic_simplex_dark) + } + } + Spacer(Modifier.width(DEFAULT_PADDING_HALF - (size / 12).dp)) + Slider( + remember { appPreferences.profileImageCornerRadius.state }.value, + valueRange = 0f..50f, + steps = 20, + onValueChange = { + val diff = it % 2.5f + appPreferences.profileImageCornerRadius.set(it + (if (diff >= 1.25f) -diff + 2.5f else -diff)) + saveThemeToDatabase(null) + }, + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + ) + } + } + } + + @Composable + fun AppToolbarsSection() { + BoxWithConstraints { + SectionView(stringResource(MR.strings.appearance_app_toolbars).uppercase()) { + SectionItemViewWithoutMinPadding { + Box(Modifier.weight(1f)) { + Text( + stringResource(MR.strings.appearance_in_app_bars_alpha), + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + appPrefs.inAppBarsAlpha.set(appPrefs.inAppBarsDefaultAlpha) + }, + maxLines = 1 + ) + } + Spacer(Modifier.padding(end = 10.dp)) + Slider( + (1 - remember { appPrefs.inAppBarsAlpha.state }.value).coerceIn(0f, 0.5f), + onValueChange = { + val diff = it % 0.025f + appPrefs.inAppBarsAlpha.set(1f - (String.format(Locale.US, "%.3f", it + (if (diff >= 0.0125f) -diff + 0.025f else -diff)).toFloatOrNull() ?: 1f)) + }, + Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f), + valueRange = 0f..0.5f, + steps = 21, + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + ) + } + // In Android in OneHandUI there is a problem with setting initial value of blur if it was 0 before entering the screen. + // So doing in two steps works ok + fun saveBlur(value: Int) { + val oneHandUI = appPrefs.oneHandUI.get() + val pref = appPrefs.appearanceBarsBlurRadius + if (appPlatform.isAndroid && oneHandUI && pref.get() == 0) { + pref.set(if (value > 2) value - 1 else value + 1) + withApi { + delay(50) + pref.set(value) + } + } else { + pref.set(value) + } + } + val blur = remember { appPrefs.appearanceBarsBlurRadius.state } + if (appPrefs.deviceSupportsBlur || blur.value > 0) { + SectionItemViewWithoutMinPadding { + Box(Modifier.weight(1f)) { + Text( + stringResource(MR.strings.appearance_bars_blur_radius), + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + saveBlur(50) + }, + maxLines = 1 + ) + } + Spacer(Modifier.padding(end = 10.dp)) + Slider( + blur.value.toFloat() / 100f, + onValueChange = { + val diff = it % 0.05f + saveBlur(((String.format(Locale.US, "%.2f", it + (if (diff >= 0.025f) -diff + 0.05f else -diff)).toFloatOrNull() ?: 1f) * 100).toInt()) + }, + Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f), + valueRange = 0f..1f, + steps = 21, + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + ) + } + } + } + } + } + + @Composable + fun MessageShapeSection() { + BoxWithConstraints { + SectionView(stringResource(MR.strings.settings_section_title_message_shape).uppercase()) { + SectionItemViewWithoutMinPadding { + Text(stringResource(MR.strings.settings_message_shape_corner), Modifier.weight(1f)) + Spacer(Modifier.width(10.dp)) + Slider( + remember { appPreferences.chatItemRoundness.state }.value, + onValueChange = { + val diff = it % 0.05f + appPreferences.chatItemRoundness.set(it + (if (diff >= 0.025f) -diff + 0.05f else -diff)) + saveThemeToDatabase(null) + }, + Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f), + valueRange = 0f..1f, + steps = 20, + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + ) + } + if (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) > 27) { + SettingsPreferenceItem(icon = null, stringResource(MR.strings.settings_message_shape_tail), appPreferences.chatItemTail) + } + } + } + } + + @Composable + fun FontScaleSection() { + val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) } + SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(50.dp) + .background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22)) + .clip(RoundedCornerShape(percent = 22)) + .clickable { + localFontScale.value = 1f + appPrefs.fontScale.set(localFontScale.value) + }, + contentAlignment = Alignment.Center) { + CompositionLocalProvider( + LocalDensity provides Density(LocalDensity.current.density, localFontScale.value) + ) { + Text("Aa", color = if (localFontScale.value == 1f) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground) + } + } + Spacer(Modifier.width(15.dp)) + // Text("${(localFontScale.value * 100).roundToInt()}%", Modifier.width(70.dp), textAlign = TextAlign.Center, fontSize = 12.sp) + if (appPlatform.isAndroid) { + Slider( + localFontScale.value, + valueRange = 0.75f..1.25f, + steps = 11, + onValueChange = { + val diff = it % 0.05f + localFontScale.value = String.format(Locale.US, "%.2f", it + (if (diff >= 0.025f) -diff + 0.05f else -diff)).toFloatOrNull() ?: 1f + }, + onValueChangeFinished = { + appPrefs.fontScale.set(localFontScale.value) + }, + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + ) + } else { + Slider( + localFontScale.value, + valueRange = 0.7f..1.5f, + steps = 9, + onValueChange = { + val diff = it % 0.1f + localFontScale.value = String.format(Locale.US, "%.1f", it + (if (diff >= 0.05f) -diff + 0.1f else -diff)).toFloatOrNull() ?: 1f + }, + onValueChangeFinished = { + appPrefs.fontScale.set(localFontScale.value) + }, + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + ) + } + } + } + } + + @Composable + fun ChatThemePreview( + theme: DefaultTheme, + wallpaperImage: ImageBitmap?, + wallpaperType: WallpaperType?, + backgroundColor: Color? = MaterialTheme.wallpaper.background, + tintColor: Color? = MaterialTheme.wallpaper.tint, + withMessages: Boolean = true + ) { + val themeBackgroundColor = MaterialTheme.colors.background + val backgroundColor = backgroundColor ?: wallpaperType?.defaultBackgroundColor(theme, MaterialTheme.colors.background) + val tintColor = tintColor ?: wallpaperType?.defaultTintColor(theme) + Column(Modifier + .drawWithCache { + if (wallpaperImage != null && wallpaperType != null && backgroundColor != null && tintColor != null) { + chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, null, null, highQuality = !patternScaleDragging) + } else { + onDrawBehind { + drawRect(themeBackgroundColor) + } + } + } + .padding(DEFAULT_PADDING_HALF) + ) { + if (withMessages) { + val chatItemTail = remember { appPreferences.chatItemTail.state } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = if (chatItemTail.value) Modifier else Modifier.padding(horizontal = msgTailWidthDp)) { + val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) } + PreviewChatItemView(alice) + PreviewChatItemView( + ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice), + quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text)) + ) + ) + } + } else { + Box(Modifier.fillMaxSize()) + } + } + } + + @Composable + fun WallpaperPresetSelector( + selectedWallpaper: WallpaperType?, + baseTheme: DefaultTheme, + activeBackgroundColor: Color? = null, + activeTintColor: Color? = null, + currentColors: (WallpaperType?) -> ThemeManager.ActiveTheme, + onChooseType: (WallpaperType?) -> Unit, + ) { + val cornerRadius = 22 + + @Composable + fun Plus(tint: Color = MaterialTheme.colors.primary) { + Icon(painterResource(MR.images.ic_add), null, Modifier.size(25.dp), tint = tint) + } + + val backgrounds = PresetWallpaper.entries.toList() + + fun LazyGridScope.gridContent(width: Dp, height: Dp) { + @Composable + fun BackgroundItem(background: PresetWallpaper?) { + val checked = (background == null && (selectedWallpaper == null || selectedWallpaper == WallpaperType.Empty)) || selectedWallpaper?.samePreset(background) == true + Box( + Modifier + .size(width, height) + .clip(RoundedCornerShape(percent = cornerRadius)) + .border(1.dp, if (checked) MaterialTheme.colors.primary.copy(0.8f) else MaterialTheme.colors.onBackground.copy(if (isInDarkTheme()) 0.2f else 0.1f), RoundedCornerShape(percent = cornerRadius)) + .clickable { onChooseType(background?.toType(baseTheme)) }, + contentAlignment = Alignment.Center + ) { + if (background != null) { + val type = background.toType(baseTheme, if (checked) selectedWallpaper?.scale else null) + SimpleXThemeOverride(remember(background, selectedWallpaper, CurrentColors.collectAsState().value) { currentColors(type) }) { + ChatThemePreview( + baseTheme, + type.image, + type, + withMessages = false, + backgroundColor = if (checked) activeBackgroundColor ?: MaterialTheme.wallpaper.background else MaterialTheme.wallpaper.background, + tintColor = if (checked) activeTintColor ?: MaterialTheme.wallpaper.tint else MaterialTheme.wallpaper.tint + ) + } + } + } + } + + @Composable + fun OwnBackgroundItem(type: WallpaperType?) { + val overrides = remember(type, baseTheme, CurrentColors.collectAsState().value.wallpaper) { + currentColors(WallpaperType.Image("", null, null)) + } + val appWallpaper = overrides.wallpaper + val backgroundColor = appWallpaper.background + val tintColor = appWallpaper.tint + val wallpaperImage = appWallpaper.type.image + val checked = type is WallpaperType.Image && wallpaperImage != null + val remoteHostConnected = chatModel.remoteHostId != null + Box( + Modifier + .size(width, height) + .clip(RoundedCornerShape(percent = cornerRadius)) + .border(1.dp, if (type is WallpaperType.Image) MaterialTheme.colors.primary.copy(0.8f) else MaterialTheme.colors.onBackground.copy(0.1f), RoundedCornerShape(percent = cornerRadius)) + .clickable { onChooseType(WallpaperType.Image("", null, null)) }, + contentAlignment = Alignment.Center + ) { + + if (checked || wallpaperImage != null) { + ChatThemePreview( + baseTheme, + wallpaperImage, + if (checked) type else appWallpaper.type, + backgroundColor = if (checked) activeBackgroundColor ?: backgroundColor else backgroundColor, + tintColor = if (checked) activeTintColor ?: tintColor else tintColor, + withMessages = false + ) + } else if (remoteHostConnected) { + Plus(MaterialTheme.colors.error) + } else { + Plus() + } + } + } + + item { + BackgroundItem(null) + } + items(items = backgrounds) { background -> + BackgroundItem(background) + } + item { + OwnBackgroundItem(selectedWallpaper) + } + } + + SimpleXThemeOverride(remember(selectedWallpaper, CurrentColors.collectAsState().value) { currentColors(selectedWallpaper) }) { + ChatThemePreview( + baseTheme, + MaterialTheme.wallpaper.type.image, + selectedWallpaper, + backgroundColor = activeBackgroundColor ?: MaterialTheme.wallpaper.background, + tintColor = activeTintColor ?: MaterialTheme.wallpaper.tint, + ) + } + + if (appPlatform.isDesktop) { + val itemWidth = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2 - DEFAULT_PADDING_HALF * 3) / 4 + val itemHeight = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2) / 4 + val rows = ceil((PresetWallpaper.entries.size + 2) / 4f).roundToInt() + LazyVerticalGrid( + columns = GridCells.Fixed(4), + Modifier.height(itemHeight * rows + DEFAULT_PADDING_HALF * (rows - 1) + DEFAULT_PADDING * 2), + contentPadding = PaddingValues(DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + ) { + gridContent(itemWidth, itemHeight) + } + } else { + LazyHorizontalGrid( + rows = GridCells.Fixed(1), + Modifier.height(80.dp + DEFAULT_PADDING * 2), + contentPadding = PaddingValues(DEFAULT_PADDING), + horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + ) { + gridContent(80.dp, 80.dp) + } + } + } + + @Composable + fun ThemesSection(systemDarkTheme: SharedPreference) { + val currentTheme by CurrentColors.collectAsState() + val baseTheme = currentTheme.base + val wallpaperType = MaterialTheme.wallpaper.type + val themeUserDestination: MutableState?> = rememberSaveable(stateSaver = serializableSaver()) { + val currentUser = chatModel.currentUser.value + mutableStateOf( + if (currentUser?.uiThemes?.preferredMode(!currentTheme.colors.isLight) == null) null else currentUser.userId to currentUser.uiThemes + ) + } + val perUserTheme = remember(CurrentColors.collectAsState().value.base, chatModel.currentUser.value) { + mutableStateOf( + chatModel.currentUser.value?.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) ?: ThemeModeOverride() + ) + } + + fun updateThemeUserDestination() { + var (userId, themes) = themeUserDestination.value ?: return + themes = if (perUserTheme.value.mode == DefaultThemeMode.LIGHT) { + (themes ?: ThemeModeOverrides()).copy(light = perUserTheme.value) + } else { + (themes ?: ThemeModeOverrides()).copy(dark = perUserTheme.value) + } + themeUserDestination.value = userId to themes + } + + val onTypeCopyFromSameTheme = { type: WallpaperType? -> + if (themeUserDestination.value == null) { + ThemeManager.saveAndApplyWallpaper(baseTheme, type) + } else { + val wallpaperFiles = setOf(perUserTheme.value.wallpaper?.imageFile) + ThemeManager.copyFromSameThemeOverrides(type, null, perUserTheme) + val wallpaperFilesToDelete = wallpaperFiles - perUserTheme.value.wallpaper?.imageFile + wallpaperFilesToDelete.forEach(::removeWallpaperFile) + updateThemeUserDestination() + } + saveThemeToDatabase(themeUserDestination.value) + true + } + + val onTypeChange = { type: WallpaperType? -> + if (themeUserDestination.value == null) { + ThemeManager.saveAndApplyWallpaper(baseTheme, type) + } else { + ThemeManager.applyWallpaper(type, perUserTheme) + updateThemeUserDestination() + } + saveThemeToDatabase(themeUserDestination.value) + } + + val onImport = { to: URI -> + val filename = saveWallpaperFile(to) + if (filename != null) { + if (themeUserDestination.value == null) { + removeWallpaperFile((currentTheme.wallpaper.type as? WallpaperType.Image)?.filename) + } else { + removeWallpaperFile((perUserTheme.value.type as? WallpaperType.Image)?.filename) + } + onTypeChange(WallpaperType.Image(filename, 1f, WallpaperScaleType.FILL)) + } + } + + val currentColors = { type: WallpaperType? -> + // If applying for : + // - all themes: no overrides needed + // - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected + val perUserOverride = if (themeUserDestination.value == null) null else if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null + ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get()) + } + + val onChooseType: (WallpaperType?, FileChooserLauncher) -> Unit = { type: WallpaperType?, importWallpaperLauncher: FileChooserLauncher -> + when { + // don't have image in parent or already selected wallpaper with custom image + type is WallpaperType.Image && + ((wallpaperType is WallpaperType.Image && themeUserDestination.value?.second != null && chatModel.remoteHostId() == null) || + currentColors(type).wallpaper.type.image == null || + (currentColors(type).wallpaper.type.image != null && CurrentColors.value.wallpaper.type is WallpaperType.Image && themeUserDestination.value == null)) -> + withLongRunningApi { importWallpaperLauncher.launch("image/*") } + type is WallpaperType.Image && themeUserDestination.value == null -> onTypeChange(currentColors(type).wallpaper.type) + type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing when remote host connected */ } + type is WallpaperType.Image -> onTypeCopyFromSameTheme(currentColors(type).wallpaper.type) + (themeUserDestination.value != null && themeUserDestination.value?.second?.preferredMode(!CurrentColors.value.colors.isLight)?.type != type) || CurrentColors.value.wallpaper.type != type -> onTypeCopyFromSameTheme(type) + else -> onTypeChange(type) + } + } + + SectionView(stringResource(MR.strings.settings_section_title_themes)) { + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + ThemeDestinationPicker(themeUserDestination) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + + val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) onImport(to) + } + + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + currentColors(type) + }, + onChooseType = { onChooseType(it, importWallpaperLauncher) }, + ) + val type = MaterialTheme.wallpaper.type + if (type is WallpaperType.Image && (themeUserDestination.value == null || perUserTheme.value.wallpaper?.imageFile != null)) { + SectionItemView(disabled = chatModel.remoteHostId != null && themeUserDestination.value != null, click = { + if (themeUserDestination.value == null) { + val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) + ThemeManager.saveAndApplyWallpaper(baseTheme, null) + ThemeManager.removeTheme(defaultActiveTheme?.themeId) + removeWallpaperFile(type.filename) + } else { + removeUserThemeModeOverrides(themeUserDestination, perUserTheme) + } + saveThemeToDatabase(themeUserDestination.value) + }) { + Text( + stringResource(MR.strings.theme_remove_image), + color = if (chatModel.remoteHostId != null && themeUserDestination.value != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + SectionSpacer() + } + + val state: State = remember(appPrefs.currentTheme.get()) { + derivedStateOf { + if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME) null else currentTheme.base.mode + } + } + ColorModeSelector(state) { + val newTheme = when (it) { + null -> DefaultTheme.SYSTEM_THEME_NAME + DefaultThemeMode.LIGHT -> DefaultTheme.LIGHT.themeName + DefaultThemeMode.DARK -> appPrefs.systemDarkTheme.get()!! + } + ThemeManager.applyTheme(newTheme) + saveThemeToDatabase(null) + } + + // Doesn't work on desktop when specified like remember { systemDarkTheme.state }, this is workaround + val darkModeState: State = remember(systemDarkTheme.get()) { derivedStateOf { systemDarkTheme.get() } } + DarkModeThemeSelector(darkModeState) { + ThemeManager.changeDarkTheme(it) + if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME) { + ThemeManager.applyTheme(appPrefs.currentTheme.get()!!) + } else if (appPrefs.currentTheme.get() != DefaultTheme.LIGHT.themeName) { + ThemeManager.applyTheme(appPrefs.systemDarkTheme.get()!!) + } + saveThemeToDatabase(null) + } + } + SectionItemView(click = { + val user = themeUserDestination.value + if (user == null) { + ModalManager.start.showModal { + val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) onImport(to) + } + CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } + } + } else { + ModalManager.start.showModalCloseable { close -> + UserWallpaperEditorModal(chatModel.remoteHostId(), user.first, close) + } + } + }) { + Text(stringResource(MR.strings.customize_theme_title)) + } + } + + @Composable + fun CustomizeThemeView(onChooseType: (WallpaperType?) -> Unit) { + ColumnWithScrollBar { + val currentTheme by CurrentColors.collectAsState() + + AppBarTitle(stringResource(MR.strings.customize_theme_title)) + val wallpaperImage = MaterialTheme.wallpaper.type.image + val wallpaperType = MaterialTheme.wallpaper.type + val baseTheme = CurrentColors.collectAsState().value.base + + val editColor = { name: ThemeColor -> + editColor( + name, + wallpaperType, + wallpaperImage, + onColorChange = { color -> + val c = if (name == ThemeColor.TOOLBAR && (color == null || color.alpha < 0.01f)) null else color + ThemeManager.saveAndApplyThemeColor(baseTheme, name, c) + saveThemeToDatabase(null) + } + ) + } + + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + ThemeManager.currentColors(type, null, null, appPrefs.themeOverrides.get()) + }, + onChooseType = onChooseType + ) + + val type = MaterialTheme.wallpaper.type + if (type is WallpaperType.Image) { + SectionItemView(disabled = chatModel.remoteHostId != null, click = { + val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) + ThemeManager.saveAndApplyWallpaper(baseTheme, null) + ThemeManager.removeTheme(defaultActiveTheme?.themeId) + removeWallpaperFile(type.filename) + saveThemeToDatabase(null) + }) { + Text( + stringResource(MR.strings.theme_remove_image), + color = if (chatModel.remoteHostId == null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + SectionSpacer() + } + + SectionView(stringResource(MR.strings.settings_section_title_chat_colors).uppercase()) { + WallpaperSetupView( + wallpaperType, + baseTheme, + MaterialTheme.wallpaper, + MaterialTheme.appColors.sentMessage, + MaterialTheme.appColors.sentQuote, + MaterialTheme.appColors.receivedMessage, + MaterialTheme.appColors.receivedQuote, + editColor = { name -> + editColor(name) + }, + onTypeChange = { type -> + ThemeManager.saveAndApplyWallpaper(baseTheme, type) + saveThemeToDatabase(null) + }, + ) + } + SectionDividerSpaced() + + if (appPrefs.developerTools.get()) { + FormulaDevTools(wallpaperType, baseTheme) + SectionDividerSpaced() + } + + CustomizeThemeColorsSection(currentTheme) { name -> + editColor(name) + } + + SectionDividerSpaced(maxBottomPadding = false) + + val currentOverrides = remember(currentTheme) { ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) } + val canResetColors = currentTheme.base.hasChangedAnyColor(currentOverrides) + if (canResetColors) { + SectionItemView({ + ThemeManager.resetAllThemeColors() + saveThemeToDatabase(null) + }) { + Text(generalGetString(MR.strings.reset_color), color = colors.primary) + } + SectionSpacer() + } + + SectionView { + val theme = remember { mutableStateOf(null as String?) } + val exportThemeLauncher = rememberFileChooserLauncher(false) { to: URI? -> + val themeValue = theme.value + if (themeValue != null && to != null) { + copyBytesToFile(themeValue.byteInputStream(), to) { + theme.value = null + } + } + } + SectionItemView({ + val overrides = ThemeManager.currentThemeOverridesForExport(null, null/*chatModel.currentUser.value?.uiThemes*/) + val lines = yaml.encodeToString(overrides).lines() + // Removing theme id without using custom serializer or data class + theme.value = lines.subList(1, lines.size).joinToString("\n") + withLongRunningApi { exportThemeLauncher.launch("simplex.theme") } + }) { + Text(generalGetString(MR.strings.export_theme), color = colors.primary) + } + val importThemeLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) { + val theme = getThemeFromUri(to) + if (theme != null) { + ThemeManager.saveAndApplyThemeOverrides(theme) + saveThemeToDatabase(null) + } + } + } + // Can not limit to YAML mime type since it's unsupported by Android + SectionItemView({ withLongRunningApi { importThemeLauncher.launch("*/*") } }) { + Text(generalGetString(MR.strings.import_theme), color = colors.primary) + } + } + SectionBottomSpacer() + } + } + + // ===== Dev-only formula tuning UI ===== + + @Composable + fun FormulaDevTools( + wallpaperType: WallpaperType, + baseTheme: DefaultTheme, + ) { + val preset = (wallpaperType as? WallpaperType.Preset)?.let { PresetWallpaper.from(it.filename) } ?: return + val clipboard = LocalClipboardManager.current + val isLight = baseTheme == DefaultTheme.LIGHT + val isBlack = baseTheme == DefaultTheme.BLACK + + // Derive defaults from hardcoded oklch values — always in sync + val defaults = remember(preset, baseTheme) { + val bg = preset.background[baseTheme]!!.toOklch() + val tint = preset.tint[baseTheme]!!.toOklch() + val colors = preset.colors[baseTheme] + val sm = colors?.sentMessage?.toOklch() ?: bg + val sq = colors?.sentQuote?.toOklch() ?: bg + if (isLight) { + val step = bg.L - sq.L + val rm = colors?.receivedMessage?.toOklch() + mapOf( + "hue" to bg.H, "bgL" to bg.L, "bgC" to bg.C, + "step" to step, + "patternDepth" to if (step > 0f) (bg.L - tint.L) / step else 0f, + "patternChroma" to tint.C, + "receivedTint" to if (rm != null && rm.L < 1f) 1f - rm.L else 0.005f, + ) + } else { + val step = if (isBlack) sm.L / 6f else (sm.L - bg.L) / 3.5f + val mutedC = if (isBlack) 0f else bg.C / 0.9f + mapOf( + "hue" to bg.H.let { if (isBlack) sm.H else it }, + "bgL" to bg.L, "step" to step, + "mutedChroma" to mutedC, "colorChroma" to sm.C, + ) + } + } + + val savedParams = formulaSavedParams + val pk = "${preset.name}/${baseTheme.name}/" + fun saved(name: String) = savedParams["$pk$name"] + + // Reset key — increment to force slider recomposition + var resetKey by remember { mutableStateOf(0) } + + // Slider states: saved values (if modified) → derived defaults + val hue = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("hue") ?: defaults["hue"]!!) } + val bgL = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("bgL") ?: defaults["bgL"]!!) } + val bgC = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("bgC") ?: defaults["bgC"] ?: 0f) } + val step = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("step") ?: defaults["step"]!!) } + val patternDepth = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("patternDepth") ?: defaults["patternDepth"] ?: 0f) } + val patternChromaVal = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("patternChroma") ?: defaults["patternChroma"] ?: 0f) } + val receivedTint = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("receivedTint") ?: defaults["receivedTint"] ?: 0.005f) } + val bgLOffset = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("bgLOffset") ?: 0f) } + val mutedChroma = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("mutedChroma") ?: defaults["mutedChroma"] ?: 0f) } + val colorChroma = remember(preset, baseTheme, resetKey) { mutableFloatStateOf(saved("colorChroma") ?: defaults["colorChroma"] ?: 0f) } + + // Compute formula result (O(1) math, no need to memoize) + val result = when { + isLight -> generateSchemeLight( + hue.floatValue, bgL.floatValue, bgC.floatValue, step.floatValue, + patternDepth.floatValue, patternChromaVal.floatValue, receivedTint.floatValue, + bgLOffset.floatValue, + ) + isBlack -> generateSchemeBlack( + hue.floatValue, step.floatValue, colorChroma.floatValue, + ) + else -> generateSchemeDark( + hue.floatValue, bgL.floatValue, step.floatValue, + mutedChroma.floatValue, colorChroma.floatValue, + ) + } + + // Apply colors live + persist slider values + LaunchedEffect(result) { + ThemeManager.saveAndApplyThemeColor(baseTheme, ThemeColor.SENT_MESSAGE, result.sentMessage.toColor()) + ThemeManager.saveAndApplyThemeColor(baseTheme, ThemeColor.SENT_QUOTE, result.sentQuote.toColor()) + ThemeManager.saveAndApplyThemeColor(baseTheme, ThemeColor.RECEIVED_MESSAGE, result.receivedMessage.toColor()) + ThemeManager.saveAndApplyThemeColor(baseTheme, ThemeColor.RECEIVED_QUOTE, result.receivedQuote.toColor()) + ThemeManager.saveAndApplyThemeColor(baseTheme, ThemeColor.WALLPAPER_BACKGROUND, result.background.toColor()) + ThemeManager.saveAndApplyThemeColor(baseTheme, ThemeColor.WALLPAPER_TINT, result.pattern.toColor()) + saveThemeToDatabase(null) + // Save slider values so they survive wallpaper switches + savedParams["${pk}hue"] = hue.floatValue + savedParams["${pk}bgL"] = bgL.floatValue + savedParams["${pk}bgC"] = bgC.floatValue + savedParams["${pk}step"] = step.floatValue + savedParams["${pk}patternDepth"] = patternDepth.floatValue + savedParams["${pk}patternChroma"] = patternChromaVal.floatValue + savedParams["${pk}receivedTint"] = receivedTint.floatValue + savedParams["${pk}bgLOffset"] = bgLOffset.floatValue + savedParams["${pk}mutedChroma"] = mutedChroma.floatValue + savedParams["${pk}colorChroma"] = colorChroma.floatValue + } + + SectionView("FORMULA: ${preset.filename.uppercase()} / ${baseTheme.name}") { + if (isLight) { + FormulaSlider("Hue", hue, 0f..360f) + FormulaSlider("Lightness", bgL, 0.85f..1f) + FormulaSlider("BG Lightness", bgLOffset, -0.05f..0.05f) + FormulaSlider("Chroma", bgC, 0f..0.10f) + FormulaSlider("Contrast", step, 0.01f..0.10f) + FormulaSlider("Received tint", receivedTint, 0f..0.07f) + FormulaSlider("Pattern depth", patternDepth, 0f..10f) + FormulaSlider("Pattern chroma", patternChromaVal, 0f..0.15f) + } else { + FormulaSlider("Hue", hue, 0f..360f) + if (!isBlack) FormulaSlider("Lightness", bgL, 0.05f..0.30f) + FormulaSlider("Contrast", step, 0.01f..0.10f) + FormulaSlider("Accent chroma", colorChroma, 0f..0.20f) + if (!isBlack) FormulaSlider("Secondary chroma", mutedChroma, 0f..0.05f) + } + SectionItemView({ + savedParams.keys.filter { it.startsWith(pk) }.forEach { savedParams.remove(it) } + resetKey++ + }) { + Text("Reset formula", color = colors.primary) + } + } + + SectionSpacer() + + // Color preview squares + Row(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceEvenly) { + val slots = listOf("bg" to result.background, "ti" to result.pattern, "s" to result.sentMessage, "sq" to result.sentQuote, "r" to result.receivedMessage, "rq" to result.receivedQuote) + for ((label, slot) in slots) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(40.dp).background(slot.toColor())) + Text(label, style = MaterialTheme.typography.caption) + } + } + } + + SectionSpacer() + + // Code output for copy-paste + val codeText = buildCodeOutput(baseTheme, result) + val wallpaperLine = wallpaperSourceLine(preset) + SectionView("CODE OUTPUT") { + SectionItemView({ + clipboard.shareText(codeText) + }) { + Text("Copy code to clipboard", color = colors.primary) + } + Text( + "Paste to ChatWallpaper.kt line $wallpaperLine", + Modifier.padding(horizontal = DEFAULT_PADDING), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.5f), + ) + Text( + codeText, + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), + style = MaterialTheme.typography.caption.copy(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 9.sp), + color = MaterialTheme.colors.onBackground.copy(alpha = 0.7f), + ) + } + } + + @Composable + private fun FormulaSlider(label: String, state: MutableFloatState, range: ClosedFloatingPointRange) { + // Separate text state — only reformatted by slider drag, not by typing + var textField by remember(state) { mutableStateOf(String.format(Locale.US, "%.4f", state.floatValue)) } + SectionItemViewWithoutMinPadding { + Text(label, Modifier.width(110.dp), style = MaterialTheme.typography.body2, maxLines = 1) + BasicTextField( + textField, + onValueChange = { raw -> + textField = raw + raw.toFloatOrNull()?.let { v -> if (v in range) state.floatValue = v } + }, + Modifier.width(60.dp), + textStyle = MaterialTheme.typography.caption.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colors.onBackground, + ), + singleLine = true, + ) + Slider( + state.floatValue, + valueRange = range, + onValueChange = { + state.floatValue = it + textField = String.format(Locale.US, "%.4f", it) + }, + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ), + ) + } + } + + private fun wallpaperSourceLine(preset: PresetWallpaper): Int = when (preset) { + PresetWallpaper.CATS -> 42 + PresetWallpaper.FLOWERS -> 81 + PresetWallpaper.HEARTS -> 120 + PresetWallpaper.KIDS -> 159 + PresetWallpaper.SCHOOL -> 198 + PresetWallpaper.TRAVEL -> 237 + } + + private fun buildCodeOutput(theme: DefaultTheme, result: FormulaResult): String { + val t = theme.name + return buildString { + appendLine("// wallpaperBackgrounds( → ${t.lowercase()} =") + appendLine("${t.lowercase()} = ${result.background.toCodeString()},") + appendLine() + appendLine("// _tint = mapOf( → DefaultTheme.$t to") + appendLine("DefaultTheme.$t to ${result.pattern.toCodeString()},") + appendLine() + appendLine("// _colors = mapOf( → DefaultTheme.$t to") + appendLine("DefaultTheme.$t to ResolvedColors(") + appendLine(" sentMessage = ${result.sentMessage.toCodeString()},") + appendLine(" sentQuote = ${result.sentQuote.toCodeString()},") + appendLine(" receivedMessage = ${result.receivedMessage.toCodeString()},") + appendLine(" receivedQuote = ${result.receivedQuote.toCodeString()},") + appendLine("),") + } + } + + @Composable + fun ColorModeSwitcher() { + val currentTheme by CurrentColors.collectAsState() + val themeMode = if (remember { appPrefs.currentTheme.state }.value == DefaultTheme.SYSTEM_THEME_NAME) { + if (systemInDarkThemeCurrently) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT + } else { + currentTheme.base.mode + } + + val onLongClick = { + ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) + showToast(generalGetString(MR.strings.system_mode_toast)) + + saveThemeToDatabase(null) + } + Box( + modifier = Modifier + .clip(CircleShape) + .combinedClickable( + onClick = { + ThemeManager.applyTheme(if (themeMode == DefaultThemeMode.LIGHT) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName) + saveThemeToDatabase(null) + }, + onLongClick = onLongClick + ) + .onRightClick(onLongClick) + .size(44.dp), + contentAlignment = Alignment.Center + ) { + Icon(painterResource(if (themeMode == DefaultThemeMode.LIGHT) MR.images.ic_light_mode else MR.images.ic_bedtime_moon), stringResource(MR.strings.color_mode_light), tint = MaterialTheme.colors.secondary) + } + } + + private var updateBackendJob: Job = Job() + private fun saveThemeToDatabase(themeUserDestination: Pair?) { + val remoteHostId = chatModel.remoteHostId() + val oldThemes = chatModel.currentUser.value?.uiThemes + if (themeUserDestination != null) { + // Update before save to make it work seamless + chatModel.updateCurrentUserUiThemes(remoteHostId, themeUserDestination.second) + } + updateBackendJob.cancel() + updateBackendJob = withBGApi { + delay(300) + if (themeUserDestination == null) { + controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) + } else if (!controller.apiSetUserUIThemes(remoteHostId, themeUserDestination.first, themeUserDestination.second)) { + // If failed to apply for some reason return the old themes + chatModel.updateCurrentUserUiThemes(remoteHostId, oldThemes) + } + } + } + + fun editColor(name: ThemeColor, wallpaperType: WallpaperType, wallpaperImage: ImageBitmap?, onColorChange: (Color?) -> Unit) { + ModalManager.start.showModal { + val baseTheme = CurrentColors.collectAsState().value.base + val wallpaperBackgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(baseTheme, MaterialTheme.colors.background) + val wallpaperTintColor = MaterialTheme.wallpaper.tint ?: wallpaperType.defaultTintColor(baseTheme) + val initialColor: Color = when (name) { + ThemeColor.WALLPAPER_BACKGROUND -> wallpaperBackgroundColor + ThemeColor.WALLPAPER_TINT -> wallpaperTintColor + ThemeColor.PRIMARY -> MaterialTheme.colors.primary + ThemeColor.PRIMARY_VARIANT -> MaterialTheme.colors.primaryVariant + ThemeColor.SECONDARY -> MaterialTheme.colors.secondary + ThemeColor.SECONDARY_VARIANT -> MaterialTheme.colors.secondaryVariant + ThemeColor.BACKGROUND -> MaterialTheme.colors.background + ThemeColor.SURFACE -> MaterialTheme.colors.surface + ThemeColor.TOOLBAR -> panelBackgroundColor() + ThemeColor.TITLE -> MaterialTheme.appColors.title + ThemeColor.PRIMARY_VARIANT2 -> MaterialTheme.appColors.primaryVariant2 + ThemeColor.SENT_MESSAGE -> MaterialTheme.appColors.sentMessage + ThemeColor.SENT_QUOTE -> MaterialTheme.appColors.sentQuote + ThemeColor.RECEIVED_MESSAGE -> MaterialTheme.appColors.receivedMessage + ThemeColor.RECEIVED_QUOTE -> MaterialTheme.appColors.receivedQuote + } + ColorEditor(name, initialColor, baseTheme, MaterialTheme.wallpaper.type, wallpaperImage, currentColors = { CurrentColors.value }, + onColorChange = onColorChange + ) + } + } + + @Composable + fun ModalData.UserWallpaperEditorModal(remoteHostId: Long?, userId: Long, close: () -> Unit) { + val themes = remember(chatModel.currentUser.value) { mutableStateOf(chatModel.currentUser.value?.uiThemes ?: ThemeModeOverrides()) } + val globalThemeUsed = remember { stateGetOrPut("globalThemeUsed") { false } } + val initialTheme = remember(CurrentColors.collectAsState().value.base) { + val preferred = themes.value.preferredMode(!CurrentColors.value.colors.isLight) + globalThemeUsed.value = preferred == null + preferred ?: ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + } + UserWallpaperEditor( + initialTheme, + applyToMode = if (themes.value.light == themes.value.dark) null else initialTheme.mode, + globalThemeUsed = globalThemeUsed, + save = { applyToMode, newTheme -> + save(applyToMode, newTheme, themes.value, userId, remoteHostId) + }) + KeyChangeEffect(chatModel.currentUser.value?.userId, chatModel.remoteHostId) { + close() + } + } + + suspend fun save( + applyToMode: DefaultThemeMode?, + newTheme: ThemeModeOverride?, + themes: ThemeModeOverrides?, + userId: Long, + remoteHostId: Long? + ) { + val unchangedThemes: ThemeModeOverrides = themes ?: ThemeModeOverrides() + val wallpaperFiles = setOf(unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile) + var changedThemes: ThemeModeOverrides? = unchangedThemes + val changed = newTheme?.copy(wallpaper = newTheme.wallpaper?.withFilledWallpaperPath()) + changedThemes = when (applyToMode) { + null -> changedThemes?.copy(light = changed?.copy(mode = DefaultThemeMode.LIGHT), dark = changed?.copy(mode = DefaultThemeMode.DARK)) + DefaultThemeMode.LIGHT -> changedThemes?.copy(light = changed?.copy(mode = applyToMode)) + DefaultThemeMode.DARK -> changedThemes?.copy(dark = changed?.copy(mode = applyToMode)) + } + changedThemes = if (changedThemes?.light != null || changedThemes?.dark != null) { + val light = changedThemes.light + val dark = changedThemes.dark + val currentMode = CurrentColors.value.base.mode + // same image file for both modes, copy image to make them as different files + if (light?.wallpaper?.imageFile != null && dark?.wallpaper?.imageFile != null && light.wallpaper.imageFile == dark.wallpaper.imageFile) { + val imageFile = if (currentMode == DefaultThemeMode.LIGHT) { + dark.wallpaper.imageFile + } else { + light.wallpaper.imageFile + } + val filePath = saveWallpaperFile(File(getWallpaperFilePath(imageFile)).toURI()) + changedThemes = if (currentMode == DefaultThemeMode.LIGHT) { + changedThemes.copy(dark = dark.copy(wallpaper = dark.wallpaper.copy(imageFile = filePath))) + } else { + changedThemes.copy(light = light.copy(wallpaper = light.wallpaper.copy(imageFile = filePath))) + } + } + changedThemes + } else { + null + } + + val wallpaperFilesToDelete = wallpaperFiles - changedThemes?.light?.wallpaper?.imageFile - changedThemes?.dark?.wallpaper?.imageFile + wallpaperFilesToDelete.forEach(::removeWallpaperFile) + + val oldThemes = chatModel.currentUser.value?.uiThemes + // Update before save to make it work seamless + chatModel.updateCurrentUserUiThemes(remoteHostId, changedThemes) + updateBackendJob.cancel() + updateBackendJob = withBGApi { + delay(300) + if (!controller.apiSetUserUIThemes(remoteHostId, userId, changedThemes)) { + // If failed to apply for some reason return the old themes + chatModel.updateCurrentUserUiThemes(remoteHostId, oldThemes) + } + } + } + + @Composable + fun ThemeDestinationPicker(themeUserDestination: MutableState?>) { + val themeUserDest = remember(themeUserDestination.value?.first) { mutableStateOf(themeUserDestination.value?.first) } + LaunchedEffect(themeUserDestination.value) { + if (themeUserDestination.value == null) { + // Easiest way to hide per-user customization. + // Otherwise, it would be needed to make global variable and to use it everywhere for making a decision to include these overrides into active theme constructing or not + chatModel.currentUser.value = chatModel.currentUser.value?.copy(uiThemes = null) + } else { + chatModel.updateCurrentUserUiThemes(chatModel.remoteHostId(), chatModel.users.firstOrNull { it.user.userId == chatModel.currentUser.value?.userId }?.user?.uiThemes) + } + } + DisposableEffect(Unit) { + onDispose { + // Skip when Appearance screen is not hidden yet + if (ModalManager.start.hasModalsOpen()) return@onDispose + // Restore user overrides from stored list of users + chatModel.updateCurrentUserUiThemes(chatModel.remoteHostId(), chatModel.users.firstOrNull { it.user.userId == chatModel.currentUser.value?.userId }?.user?.uiThemes) + themeUserDestination.value = if (chatModel.currentUser.value?.uiThemes == null) null else chatModel.currentUser.value?.userId!! to chatModel.currentUser.value?.uiThemes + } + } + + val values by remember(chatModel.users.toList()) { mutableStateOf( + listOf(null as Long? to generalGetString(MR.strings.theme_destination_app_theme)) + + + chatModel.users.filter { it.user.activeUser }.map { + it.user.userId to it.user.chatViewName + }, + ) + } + if (values.any { it.first == themeUserDestination.value?.first }) { + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_theme_apply_to_mode), + values, + themeUserDest, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = { userId -> + themeUserDest.value = userId + if (userId != null) { + themeUserDestination.value = userId to chatModel.users.firstOrNull { it.user.userId == userId }?.user?.uiThemes + } else { + themeUserDestination.value = null + } + if (userId != null && userId != chatModel.currentUser.value?.userId) { + withBGApi { + controller.showProgressIfNeeded { + chatModel.controller.changeActiveUser(chatModel.remoteHostId(), userId, null) + } + } + } + } + ) + } else { + themeUserDestination.value = null + } + } + + @Composable + fun CustomizeThemeColorsSection(currentTheme: ThemeManager.ActiveTheme, editColor: (ThemeColor) -> Unit) { + SectionView(stringResource(MR.strings.theme_colors_section_title)) { + SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY) }) { + val title = generalGetString(MR.strings.color_primary) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.primary) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY_VARIANT) }) { + val title = generalGetString(MR.strings.color_primary_variant) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.primaryVariant) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY_VARIANT2) }) { + val title = generalGetString(MR.strings.color_primary_variant2) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.primaryVariant2) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY) }) { + val title = generalGetString(MR.strings.color_secondary) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.secondary) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY_VARIANT) }) { + val title = generalGetString(MR.strings.color_secondary_variant) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.secondaryVariant) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.BACKGROUND) }) { + val title = generalGetString(MR.strings.color_background) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.background) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SURFACE) }) { + val title = generalGetString(MR.strings.color_surface) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.surface) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.TOOLBAR) }) { + val title = "Toolbar" + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = panelBackgroundColor()) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.TITLE) }) { + val title = generalGetString(MR.strings.color_title) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.title) + } + } + } + + @Composable + fun ColorEditor( + name: ThemeColor, + initialColor: Color, + theme: DefaultTheme, + wallpaperType: WallpaperType?, + wallpaperImage: ImageBitmap?, + previewBackgroundColor: Color? = MaterialTheme.wallpaper.background, + previewTintColor: Color? = MaterialTheme.wallpaper.tint, + currentColors: () -> ThemeManager.ActiveTheme, + onColorChange: (Color?) -> Unit, + ) { + ColumnWithScrollBar(Modifier.imePadding()) { + AppBarTitle(name.text) + + val supportedLiveChange = name in listOf(ThemeColor.SECONDARY, ThemeColor.BACKGROUND, ThemeColor.SURFACE, ThemeColor.RECEIVED_MESSAGE, ThemeColor.SENT_MESSAGE, ThemeColor.SENT_QUOTE, ThemeColor.WALLPAPER_BACKGROUND, ThemeColor.WALLPAPER_TINT) + if (supportedLiveChange) { + SimpleXThemeOverride(currentColors()) { + ChatThemePreview(theme, wallpaperImage, wallpaperType, previewBackgroundColor, previewTintColor) + } + SectionSpacer() + } + + var currentColor by remember { mutableStateOf(initialColor) } + val togglePicker = remember { mutableStateOf(false) } + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + if (togglePicker.value) { + ColorPicker(currentColor, showAlphaBar = wallpaperType is WallpaperType.Image || currentColor.alpha < 1f) { + currentColor = it + onColorChange(currentColor) + } + } else { + ColorPicker(currentColor, showAlphaBar = wallpaperType is WallpaperType.Image || currentColor.alpha < 1f) { + currentColor = it + onColorChange(currentColor) + } + } + } + var allowReloadPicker by remember { mutableStateOf(false) } + KeyChangeEffect(wallpaperType) { + allowReloadPicker = true + } + KeyChangeEffect(initialColor) { + if (initialColor != currentColor && allowReloadPicker) { + currentColor = initialColor + togglePicker.value = !togglePicker.value + } + allowReloadPicker = false + } + val clipboard = LocalClipboardManager.current + val hexTrimmed = currentColor.toReadableHex().replaceFirst("#ff", "#") + val savedColor by remember(wallpaperType) { mutableStateOf(initialColor) } + + Row(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).height(DEFAULT_MIN_SECTION_ITEM_HEIGHT)) { + Box(Modifier.weight(1f).fillMaxHeight().background(savedColor).clickable { + currentColor = savedColor + onColorChange(currentColor) + togglePicker.value = !togglePicker.value + }) + Box(Modifier.weight(1f).fillMaxHeight().background(currentColor).clickable { + clipboard.shareText(hexTrimmed) + }) + } + if (appPrefs.developerTools.get()) { + Row(Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { + val textFieldState = remember { mutableStateOf(TextFieldValue(hexTrimmed)) } + KeyChangeEffect(hexTrimmed) { + textFieldState.value = textFieldState.value.copy(hexTrimmed) + } + DefaultBasicTextField( + Modifier.fillMaxWidth(), + textFieldState, + leadingIcon = { + IconButton(onClick = { clipboard.shareText(hexTrimmed) }) { + Icon(painterResource(MR.images.ic_content_copy), generalGetString(MR.strings.copy_verb), Modifier.size(26.dp), tint = MaterialTheme.colors.primary) + } + }, + onValueChange = { value -> + val color = value.text.trim('#', ' ') + if (color.length == 6 || color.length == 8) { + currentColor = if (color.length == 6) ("ff$color").colorFromReadableHex() else color.colorFromReadableHex() + onColorChange(currentColor) + textFieldState.value = value.copy(currentColor.toReadableHex().replaceFirst("#ff", "#")) + togglePicker.value = !togglePicker.value + } else { + textFieldState.value = value + } + } + ) + } + } + if (name == ThemeColor.TOOLBAR && appPrefs.developerTools.get()) { + SectionSpacer() + val toolbarCode = if (currentColor.alpha >= 0.01f) { + val oklab = currentColor.convert(androidx.compose.ui.graphics.colorspace.ColorSpaces.Oklab) + val L = oklab.component1(); val a = oklab.component2(); val b = oklab.component3() + val C = kotlin.math.sqrt(a * a + b * b) + val H = Math.toDegrees(kotlin.math.atan2(b.toDouble(), a.toDouble())).toFloat().let { if (it < 0) it + 360f else it } + "toolbar = oklch(${L}f, ${C}f, ${H}f, ${currentColor.alpha}f)," + } else "// toolbar: no tint (transparent)" + val preset = (wallpaperType as? WallpaperType.Preset)?.let { PresetWallpaper.from(it.filename) } + val pasteHint = if (preset != null) "Paste to ChatWallpaper.kt line ${wallpaperSourceLine(preset)}" else "Paste to ChatWallpaper.kt → ResolvedColors" + SectionItemView({ clipboard.shareText(toolbarCode) }) { + Text("Copy toolbar code", color = colors.primary) + } + Text( + "$pasteHint\n$toolbarCode", + Modifier.padding(horizontal = DEFAULT_PADDING), + style = MaterialTheme.typography.caption.copy(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 9.sp), + color = MaterialTheme.colors.onBackground.copy(alpha = 0.5f), + ) + } + SectionItemView({ + allowReloadPicker = true + onColorChange(null) + }) { + Text(generalGetString(MR.strings.reset_single_color), color = colors.primary) + } + SectionSpacer() + } + } + + + + @Composable + fun LangSelector(state: State, onSelected: (String) -> Unit) { + // Should be the same as in app/build.gradle's `android.defaultConfig.resConfigs` + val supportedLanguages = mapOf( + "system" to generalGetString(MR.strings.language_system), + "en" to "English", + "ar" to "العربية", + "bg" to "Български", + "ca" to "Català", + "cs" to "Čeština", + "de" to "Deutsch", + "es" to "Español", + "fa" to "فارسی", + "fi" to "Suomi", + "fr" to "Français", + "hu" to "Magyar", + "in" to "Indonesia", + "it" to "Italiano", + "iw" to "עִברִית", + "ja" to "日本語", + "lt" to "Lietuvių", + "nl" to "Nederlands", + "pl" to "Polski", + "pt-BR" to "Português, Brasil", + "ro" to "Română", + "ru" to "Русский", + "th" to "ภาษาไทย", + "tr" to "Türkçe", + "uk" to "Українська", + "vi" to "Tiếng Việt", + "zh-CN" to "简体中文" + ) + val values by remember(appPrefs.appLanguage.state.value) { mutableStateOf(supportedLanguages.map { it.key to it.value }) } + ExposedDropDownSettingRow( + generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() }, + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) + } + + @Composable + private fun ColorModeSelector(state: State, onSelected: (DefaultThemeMode?) -> Unit) { + val values by remember(appPrefs.appLanguage.state.value) { + mutableStateOf( + listOf( + null to generalGetString(MR.strings.color_mode_system), + DefaultThemeMode.LIGHT to generalGetString(MR.strings.color_mode_light), + DefaultThemeMode.DARK to generalGetString(MR.strings.color_mode_dark) + ) + ) + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.color_mode), + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) + } + + @Composable + private fun DarkModeThemeSelector(state: State, onSelected: (String) -> Unit) { + val values by remember { + val darkThemes = ArrayList>() + darkThemes.add(DefaultTheme.DARK.themeName to generalGetString(MR.strings.theme_dark)) + darkThemes.add(DefaultTheme.SIMPLEX.themeName to generalGetString(MR.strings.theme_simplex)) + darkThemes.add(DefaultTheme.BLACK.themeName to generalGetString(MR.strings.theme_black)) + mutableStateOf(darkThemes.toList()) + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.dark_mode_colors), + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = { if (it != null) onSelected(it) } + ) + } + //private fun openSystemLangPicker(activity: Activity) { + // activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName))) + //} +} + +@Composable +fun WallpaperSetupView( + wallpaperType: WallpaperType?, + theme: DefaultTheme, + initialWallpaper: AppWallpaper?, + initialSentColor: Color, + initialSentQuoteColor: Color, + initialReceivedColor: Color, + initialReceivedQuoteColor: Color, + editColor: (ThemeColor) -> Unit, + onTypeChange: (WallpaperType?) -> Unit, +) { + if (wallpaperType is WallpaperType.Image) { + val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } + val values = remember { + WallpaperScaleType.entries.map { it to generalGetString(it.text) } + } + ExposedDropDownSettingRow( + stringResource(MR.strings.wallpaper_scale), + values, + state, + onSelected = { scaleType -> + onTypeChange(wallpaperType.copy(scaleType = scaleType)) + } + ) + } + + if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { + val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } + DisposableEffect(Unit) { onDispose { patternScaleDragging = false } } + Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { + Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) + Slider( + state.value, + valueRange = 0.5f..2f, + onValueChange = { + patternScaleDragging = true + if (wallpaperType is WallpaperType.Preset) { + onTypeChange(wallpaperType.copy(scale = it)) + } else if (wallpaperType is WallpaperType.Image) { + onTypeChange(wallpaperType.copy(scale = it)) + } + }, + onValueChangeFinished = { + patternScaleDragging = false + } + ) + } + } + + if (wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image) { + val wallpaperBackgroundColor = initialWallpaper?.background ?: wallpaperType.defaultBackgroundColor(theme, MaterialTheme.colors.background) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_BACKGROUND) }) { + val title = generalGetString(MR.strings.color_wallpaper_background) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) + } + val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { + val title = generalGetString(MR.strings.color_wallpaper_tint) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + } + SectionSpacer() + } + + SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE) }) { + val title = generalGetString(MR.strings.color_sent_message) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentColor) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_QUOTE) }) { + val title = generalGetString(MR.strings.color_sent_quote) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentQuoteColor) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE) }) { + val title = generalGetString(MR.strings.color_received_message) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedColor) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_QUOTE) }) { + val title = generalGetString(MR.strings.color_received_quote) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedQuoteColor) + } +} + +@Composable +private fun ColorPicker(initialColor: Color, showAlphaBar: Boolean, onColorChanged: (Color) -> Unit) { + ClassicColorPicker(modifier = Modifier + .fillMaxWidth() + .height(300.dp), + color = HsvColor.from(color = initialColor), + showAlphaBar = showAlphaBar, + onColorChanged = { color: HsvColor -> + onColorChanged(color.toColor()) + } + ) +} + +private fun removeUserThemeModeOverrides(themeUserDestination: MutableState?>, perUserTheme: MutableState) { + val dest = themeUserDestination.value ?: return + perUserTheme.value = ThemeModeOverride() + themeUserDestination.value = dest.first to null + val wallpaperFilesToDelete = listOf( + (chatModel.currentUser.value?.uiThemes?.light?.type as? WallpaperType.Image)?.filename, + (chatModel.currentUser.value?.uiThemes?.dark?.type as? WallpaperType.Image)?.filename + ) + wallpaperFilesToDelete.forEach(::removeWallpaperFile) +} diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png index 9bff3eb3d0..f9ede857da 100644 Binary files a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png and b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png index e0ee4b057d..d9457a99a3 100644 Binary files a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png and b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png index 35da7c7aed..f945410849 100644 Binary files a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png and b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png index f5f15d3643..aa269c4a5b 100644 Binary files a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png and b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png index f6e1cce383..ec0e303ce6 100644 Binary files a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png and b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png index 64ec137331..2836a79ac2 100644 Binary files a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png and b/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png differ diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 0f830e7b60..38feef579c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -173,20 +173,36 @@ actual fun ImageBitmap.addLogo(size: Float): ImageBitmap { return withLogo.toComposeImageBitmap() } -actual fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap { - val original = toAwtImage() - val resized = BufferedImage(width, height, original.type) - val g = resized.createGraphics() - g.setRenderingHint( - RenderingHints.KEY_INTERPOLATION, - RenderingHints.VALUE_INTERPOLATION_BILINEAR - ) - g.drawImage(original, 0, 0, width, height, 0, 0, original.width, original.height, null) - g.dispose() - return resized.toComposeImageBitmap() +actual fun ImageBitmap.scale(width: Int, height: Int, highQuality: Boolean): ImageBitmap { + val src = toAwtImage() + if (highQuality) { + val smooth = src.getScaledInstance(width, height, java.awt.Image.SCALE_SMOOTH) + val dst = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g = dst.createGraphics() + g.drawImage(smooth, 0, 0, null) + g.dispose() + return dst.toComposeImageBitmap() + } + // Progressive downscale: halve until within 2× of target, then final bilinear. + var current = src + while (current.width > width * 2 || current.height > height * 2) { + val halfW = (current.width / 2).coerceAtLeast(width) + val halfH = (current.height / 2).coerceAtLeast(height) + current = scaleStep(current, halfW, halfH) + } + if (current.width == width && current.height == height) return current.toComposeImageBitmap() + return scaleStep(current, width, height).toComposeImageBitmap() +} + +private fun scaleStep(src: BufferedImage, w: Int, h: Int): BufferedImage { + val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + val g = dst.createGraphics() + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.drawImage(src, 0, 0, w, h, 0, 0, src.width, src.height, null) + g.dispose() + return dst } -// LALAL actual fun isImage(uri: URI): Boolean { val path = uri.toFile().path.lowercase() return path.endsWith(".gif") ||