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>
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 341 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 382 KiB |
|
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") ||
|
||||
|
||||