Ae/wallpaper light n dark v4 (#6899)

* light wallpapers: v4 color formula

all 6 light wallpapers recalculated with the new formula: received
messages are white (read more often, max readability), sent messages
muted in the hue — same pattern as Telegram. tint/chroma tuned per
wallpaper on device.

the formula lives outside the app (node script), only the resulting
oklch constants are in kotlin. LIGHT is served from _background/_tint/
_colors; DARK/SIMPLEX/BLACK keep the generator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wallpapers: tune dark theme + refine light pattern intensity

Calibrated full dark theme palette for all 6 preset wallpapers using the
shared chroma formula (bg-anchored, two-cluster muted/color, per-hue P3
saturation). Each wallpaper now has its own dark bg, tint, and bubble
colors instead of a shared neutral gray. Hearts hue shifted from 15° to
5° (slightly cooler), school from 239° to 249° (slightly warmer); hearts,
school and travel had per-wallpaper saturation reduced for visual
balance. wallpaperBackgrounds() now accepts a dark color override.

Light pattern tint also refined for flowers/hearts/kids/school/travel
using a coverage+sharpness regression on the pattern textures, so dense
patterns like hearts read calmer and sparse ones like flowers read
clearer. Light bubbles and backgrounds unchanged from the v4 formula.

Cats remains the calibration anchor for both themes.

* wallpapers: tune black theme (#6912)

* wallpapers: tune black theme

Replaced the legacy hex-derived BLACK palette with calibrated values for
all 6 preset wallpapers. Pure black bg (#000000) replaces the near-black
gray (#070707) — with the chroma formula's "muted side at zero" rule the
BLACK theme now reads as truly hyper-contrast. Each wallpaper's tint and
bubble colors are tuned to its own hue (cats 70°, flowers 130°, hearts 5°,
kids 192°, school 249°, travel 315°) with chroma pulled to per-hue P3
boundary, capped to keep pattern visibility consistent across textures.

* fix color space

* wallpapers: recalibrate all presets + hue-tinted panels

Recalculated bg/tint/bubble colors for all 6 wallpapers across LIGHT/DARK/BLACK
using unified oklch formula. Added subtle hue tint to panel backgrounds (status bar,
top app bar, nav bar) so they pick up the wallpaper's color. Hearts and school use
different hues in dark themes, so added PresetWallpaper.hue(theme) to handle that.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* wallpapers: formula dev tools, normalized patterns, desktop fixes

- Formula dev UI (developer tools only): sliders for all color parameters
  per theme (LIGHT/DARK/BLACK), live preview, copyable Kotlin code output
- oklch color formula ported to Kotlin (generateSchemeLight/Dark/Black)
- sRGB gamut boundary for Desktop (maxChromaSRGB), P3 for Android
- Normalized pattern PNGs (consistent element size across wallpapers)
- Desktop pattern scale 0.55 + draft/final rendering (fast during drag,
  SCALE_SMOOTH on release)
- Hue-tinted toolbar via ThemeColor.TOOLBAR + AppColors.toolbar
- Received tint slider for light themes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* wallpapers: fix toolbar color picker + received tint default

Toolbar color: direct replacement instead of alpha-mixing, picker
opens with actual visible toolbar color (panelBackgroundColor),
copy-code hint shows file and line number.

Received tint: default derived from hardcoded colors (fallback 0.005).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* wallpapers: extend pattern depth slider range to 10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* wallpapers: add BG Lightness slider for light themes

Offset ±0.05 to formula background L, independent of other slots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* wallpapers: denser hearts pattern + recalibrated LIGHT colors

Tuned LIGHT theme colors for all 6 presets via the formula sliders.
Hearts pattern PNG replaced with a denser variant (all four density
variants regenerated).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* wallpapers: revert iOS hearts variants to old format

The denser hearts PNG is shipped only as the multiplatform @4x file
(used by Android and Desktop). iOS still uses the old pattern renderer
which expects the old non-normalized layout, so the @1x/@2x/@3x
variants are reverted to their previous state to avoid visual breakage
on iOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
another-simple-pixel
2026-05-13 18:10:01 +03:00
committed by GitHub
parent a53b8b05cb
commit 6c6f1aef69
16 changed files with 2792 additions and 2123 deletions
@@ -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
@@ -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
@@ -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<FloatArray>): 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<FloatArray>, 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<String, FormulaSlot>()
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<String, DarkSlotDef>,
patternPinsToP3: Boolean,
saturationScale: Float, contrastScale: Float, patternIntensity: Float,
): FormulaResult {
val effStep = step * contrastScale
// Baseline chroma per slot
val baselineC = mutableMapOf<String, Float>()
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<String, FormulaSlot>()
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"]!!,
)
}
@@ -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) {
@@ -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()
@@ -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
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 390 KiB

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