From b97dfdefe3d54db6e3d5b2687404bb62747998df Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sat, 12 Sep 2026 23:45:47 +0100 Subject: [PATCH 1/9] feat(theme): generate harmonious palettes with contrast safeguards --- .../kairo/reader/core/model/CustomTheme.kt | 44 +++++++ .../reader/ui/theme/CustomColorScheme.kt | 108 ++++++++++++++++++ .../kairo/reader/ui/theme/ReadingContrast.kt | 22 ++++ .../reader/core/model/CustomThemeTest.kt | 38 ++++++ .../reader/ui/theme/CustomColorSchemeTest.kt | 88 ++++++++++++++ .../reader/ui/theme/ReadingContrastTest.kt | 47 ++++++++ 6 files changed, 347 insertions(+) create mode 100644 app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/theme/ReadingContrast.kt create mode 100644 app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt create mode 100644 app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt create mode 100644 app/src/test/java/com/kairo/reader/ui/theme/ReadingContrastTest.kt diff --git a/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt b/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt new file mode 100644 index 00000000..dacd4902 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt @@ -0,0 +1,44 @@ +@file:Suppress("MagicNumber") + +package com.kairo.reader.core.model + +/** Null roles follow the background and harmony; explicit roles remain pinned when either changes. */ +data class CustomTheme( + val background: Int = 0xFFF4EFE4.toInt(), + val harmony: ColorHarmony = ColorHarmony.ANALOGOUS, + val surface: Int? = null, + val text: Int? = null, + val primary: Int? = null, + val secondary: Int? = null, + val tertiary: Int? = null, +) + +enum class ColorHarmony { TONAL, ANALOGOUS, COMPLEMENTARY, TRIADIC } + +fun parseThemeColor(value: String): Int? = + value.trim().removePrefix("#").takeIf { it.matches(Regex("[0-9a-fA-F]{6}")) } + ?.toLongOrNull(16)?.toInt()?.or(0xFF000000.toInt()) + +fun themeColorHex(value: Int): String = "#" + (value.toLong() and 0xFFFFFF).toString(16).padStart(6, '0').uppercase() + +/** Versioned, delimiter-safe encoding shared by persistence and the saveable editor draft. */ +fun CustomTheme.encode(): String = + listOf("1", themeColorHex(background), harmony.name, surface, text, primary, secondary, tertiary) + .joinToString("|") { if (it is Int) themeColorHex(it) else it?.toString().orEmpty() } + +fun decodeCustomTheme(value: String?): CustomTheme { + val parts = value?.split('|') ?: return CustomTheme() + if (parts.size != THEME_FIELD_COUNT || parts[0] != "1") return CustomTheme() + val defaults = CustomTheme() + return CustomTheme( + background = parseThemeColor(parts[1]) ?: defaults.background, + harmony = ColorHarmony.entries.find { it.name == parts[2] } ?: defaults.harmony, + surface = parseThemeColor(parts[3]), + text = parseThemeColor(parts[4]), + primary = parseThemeColor(parts[5]), + secondary = parseThemeColor(parts[6]), + tertiary = parseThemeColor(parts[7]), + ) +} + +private const val THEME_FIELD_COUNT = 8 diff --git a/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt b/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt new file mode 100644 index 00000000..220e352c --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt @@ -0,0 +1,108 @@ +@file:Suppress("MagicNumber") + +package com.kairo.reader.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import com.kairo.reader.core.model.ColorHarmony +import com.kairo.reader.core.model.CustomTheme +import kotlin.math.max +import kotlin.math.min + +internal fun colorContrast(first: Color, second: Color): Float = + (max(first.luminance(), second.luminance()) + 0.05f) / (min(first.luminance(), second.luminance()) + 0.05f) + +/** Move only as far toward black or white as needed, retaining the requested hue where possible. */ +internal fun contrastingColor(requested: Color, backgrounds: List, minimum: Float = 4.5f): Color { + val opaque = Color(requested.copy(alpha = 1f).toArgb()) + val checks = backgrounds + backgrounds.map { Color(it.toArgb()) } + fun passes(color: Color) = checks.all { colorContrast(color, it) >= minimum } + if (passes(opaque)) return opaque + val end = listOf(Color.Black, Color.White).maxBy { candidate -> checks.minOf { colorContrast(candidate, it) } } + var low = 0f + var high = 1f + repeat(16) { + val middle = (low + high) / 2f + if (passes(Color(lerp(opaque, end, middle).toArgb()))) high = middle else low = middle + } + return Color(lerp(opaque, end, high).toArgb()) +} + +private fun foreground(background: Color): Color = + if (colorContrast(Color.Black, background) >= colorContrast(Color.White, background)) Color.Black else Color.White + +/** All surfaces stay on the same side of the text contrast boundary, even with a pinned surface. */ +@Suppress("LongMethod") +internal fun CustomTheme.materialColorScheme(): ColorScheme { + val bg = Color(background).copy(alpha = 1f) + val ink = foreground(bg) + val dark = ink == Color.White + val end = if (dark) Color.Black else Color.White + val baseSurface = contrastingColor(surface?.let(::Color) ?: lerp(bg, end, 0.12f), listOf(ink)) + val containers = listOf(0f, 0.12f, 0.24f, 0.36f, 0.48f).map { lerp(baseSurface, end, it) } + val surfaces = containers + bg + val onSurface = contrastingColor(text?.let(::Color) ?: ink, surfaces) + val hue = backgroundHue(bg) + val offsets = when (harmony) { + ColorHarmony.TONAL -> listOf(0f, 0f, 0f) + ColorHarmony.ANALOGOUS -> listOf(0f, 30f, 330f) + ColorHarmony.COMPLEMENTARY -> listOf(180f, 0f, 150f) + ColorHarmony.TRIADIC -> listOf(0f, 120f, 240f) + } + val accents = listOf(primary, secondary, tertiary).mapIndexed { index, override -> + val seed = override?.let(::Color) ?: Color.hsl((hue + offsets[index]) % 360f, 0.48f - index * 0.08f, 0.5f) + contrastingColor(seed, surfaces) + } + val primaryContainer = lerp(bg, accents[0], 0.18f) + val secondaryContainer = lerp(bg, accents[1], 0.14f) + val tertiaryContainer = lerp(bg, accents[2], 0.14f) + val error = contrastingColor(Color(0xFFBA1A1A), surfaces) + val errorContainer = lerp(bg, error, 0.14f) + val inverse = if (dark) Color(0xFFF1EEE8) else Color(0xFF262521) + val scheme = if (dark) darkColorScheme() else lightColorScheme() + return scheme.copy( + background = bg, onBackground = onSurface, + surface = baseSurface, onSurface = onSurface, + surfaceVariant = containers[2], onSurfaceVariant = onSurface, + surfaceContainerLowest = containers[0], surfaceContainerLow = containers[1], + surfaceContainer = containers[2], surfaceContainerHigh = containers[3], surfaceContainerHighest = containers[4], + surfaceBright = if (dark) baseSurface else containers[4], surfaceDim = if (dark) containers[4] else baseSurface, + primary = accents[0], onPrimary = foreground(accents[0]), + primaryContainer = primaryContainer, onPrimaryContainer = foreground(primaryContainer), + secondary = accents[1], onSecondary = foreground(accents[1]), + secondaryContainer = secondaryContainer, onSecondaryContainer = foreground(secondaryContainer), + tertiary = accents[2], onTertiary = foreground(accents[2]), + tertiaryContainer = tertiaryContainer, onTertiaryContainer = foreground(tertiaryContainer), + outline = contrastingColor(lerp(onSurface, bg, 0.4f), surfaces, 3f), + outlineVariant = lerp(onSurface, baseSurface, 0.8f), + inverseSurface = inverse, inverseOnSurface = foreground(inverse), + inversePrimary = contrastingColor(accents[0], listOf(inverse)), + error = error, onError = foreground(error), errorContainer = errorContainer, onErrorContainer = foreground(errorContainer), + primaryFixed = primaryContainer, primaryFixedDim = primaryContainer, + onPrimaryFixed = foreground(primaryContainer), onPrimaryFixedVariant = foreground(primaryContainer), + secondaryFixed = secondaryContainer, secondaryFixedDim = secondaryContainer, + onSecondaryFixed = foreground(secondaryContainer), onSecondaryFixedVariant = foreground(secondaryContainer), + tertiaryFixed = tertiaryContainer, tertiaryFixedDim = tertiaryContainer, + onTertiaryFixed = foreground(tertiaryContainer), onTertiaryFixedVariant = foreground(tertiaryContainer), + surfaceTint = Color.Transparent, scrim = Color.Black, + ) +} + +/** Neutral backgrounds get a stable warm hue instead of an arbitrary red cast. */ +private fun backgroundHue(color: Color): Float { + val high = max(color.red, max(color.green, color.blue)) + val low = min(color.red, min(color.green, color.blue)) + val delta = high - low + if (delta < 0.02f) return 40f + val sector = when (high) { + color.red -> (color.green - color.blue) / delta + color.green -> (color.blue - color.red) / delta + 2f + else -> (color.red - color.green) / delta + 4f + } + return (sector * 60f + 360f) % 360f +} diff --git a/app/src/main/java/com/kairo/reader/ui/theme/ReadingContrast.kt b/app/src/main/java/com/kairo/reader/ui/theme/ReadingContrast.kt new file mode 100644 index 00000000..7d892d45 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/theme/ReadingContrast.kt @@ -0,0 +1,22 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver + +internal val LocalProtectReadingContrast = staticCompositionLocalOf { false } + +/** Resolve intentional opacity against its actual background before enforcing normal-text AA contrast. */ +internal fun contrastSafeReadingColor(requested: Color, background: Color): Color = + contrastingColor(requested.compositeOver(background), listOf(background)) + +@Composable +internal fun readingColor(requested: Color, background: Color = MaterialTheme.colorScheme.background): Color { + val protect = LocalProtectReadingContrast.current + return remember(requested, background, protect) { + if (protect) contrastSafeReadingColor(requested, background) else requested + } +} diff --git a/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt b/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt new file mode 100644 index 00000000..fe42b780 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt @@ -0,0 +1,38 @@ +package com.kairo.reader.core.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CustomThemeTest { + @Test + fun pinnedAndAutomaticRolesRoundTrip() { + val theme = CustomTheme( + background = 0xFF000000.toInt(), + harmony = ColorHarmony.COMPLEMENTARY, + surface = 0xFF123456.toInt(), + text = 0xFFFEDCBA.toInt(), + tertiary = 0xFF789ABC.toInt() + ) + assertEquals(theme, decodeCustomTheme(theme.encode())) + assertEquals(CustomTheme(), decodeCustomTheme(CustomTheme().encode())) + } + + @Test + fun unsupportedOrMalformedDataFallsBackWithoutLosingValidRoles() { + listOf(null, "", "2|#ffffff|TONAL|||||", "1|broken").forEach { assertEquals(CustomTheme(), decodeCustomTheme(it)) } + val recovered = decodeCustomTheme("1|#001122|UNKNOWN|invalid||#445566||") + assertEquals(0xFF001122.toInt(), recovered.background) + assertEquals(ColorHarmony.ANALOGOUS, recovered.harmony) + assertNull(recovered.surface) + assertEquals(0xFF445566.toInt(), recovered.primary) + } + + @Test + fun hexInputAcceptsSixDigitsOnlyAndIsAlwaysOpaque() { + assertEquals(0xFFAABBCC.toInt(), parseThemeColor(" #aabbcc ")) + assertEquals(0xFF000000.toInt(), parseThemeColor("000000")) + listOf("#abc", "#12345678", "12345g", "-12345", "##123456", "").forEach { assertNull(parseThemeColor(it)) } + assertEquals("#AABBCC", themeColorHex(0xFFAABBCC.toInt())) + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt new file mode 100644 index 00000000..58fb24b9 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt @@ -0,0 +1,88 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import com.kairo.reader.core.model.ColorHarmony +import com.kairo.reader.core.model.CustomTheme +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomColorSchemeTest { + @Test + fun generatedAndPinnedPalettesKeepTextAndControlsReadable() { + val random = Random(42) + ColorHarmony.entries.forEach { harmony -> + repeat(250) { + val theme = CustomTheme( + background = random.nextInt(), + harmony = harmony, + surface = random.nextInt(), + text = random.nextInt(), + primary = random.nextInt(), + secondary = random.nextInt(), + tertiary = random.nextInt(), + ) + assertScheme(theme.materialColorScheme()) + assertScheme( + theme.copy(surface = null, text = null, primary = null, secondary = null, tertiary = null).materialColorScheme() + ) + } + } + } + + @Test + fun backgroundsArePreservedIncludingExtremeAndMidToneColors() { + listOf(0xFF000000, 0xFFFFFFFF, 0xFF777777, 0xFFFF0000, 0xFF00FF00, 0xFF0000FF, 0xFF807040).forEach { background -> + val scheme = CustomTheme(background = background.toInt()).materialColorScheme() + assertEquals(Color(background), scheme.background) + assertScheme(scheme) + } + } + + @Test + fun changingHarmonyChangesAccentsAndRetainsPinnedRoles() { + val theme = CustomTheme(background = 0xFFF4EFE4.toInt(), primary = 0xFF203060.toInt()) + val first = theme.materialColorScheme() + val second = theme.copy(harmony = ColorHarmony.TRIADIC).materialColorScheme() + assertEquals(first.primary, second.primary) + assertTrue(first.secondary != second.secondary) + assertTrue(first.tertiary != second.tertiary) + assertEquals(theme, theme.copy(harmony = ColorHarmony.TRIADIC).copy(harmony = theme.harmony)) + } + + private fun assertScheme(scheme: ColorScheme) { + val surfaces = listOf( + scheme.background, scheme.surface, scheme.surfaceVariant, scheme.surfaceContainerLowest, + scheme.surfaceContainerLow, scheme.surfaceContainer, scheme.surfaceContainerHigh, scheme.surfaceContainerHighest, + scheme.surfaceBright, scheme.surfaceDim + ) + listOf( + scheme.onBackground, + scheme.onSurface, + scheme.onSurfaceVariant, + scheme.primary, + scheme.secondary, + scheme.tertiary, + scheme.error + ) + .forEach { text -> surfaces.forEach { surface -> assertContrast(text, surface) } } + surfaces.forEach { assertTrue(colorContrast(scheme.outline, it) >= 2.9999f) } + listOf( + scheme.onPrimary to scheme.primary, scheme.onSecondary to scheme.secondary, scheme.onTertiary to scheme.tertiary, + scheme.onPrimaryContainer to scheme.primaryContainer, scheme.onSecondaryContainer to scheme.secondaryContainer, + scheme.onTertiaryContainer to scheme.tertiaryContainer, scheme.onError to scheme.error, + scheme.onErrorContainer to scheme.errorContainer, scheme.inverseOnSurface to scheme.inverseSurface, + scheme.inversePrimary to scheme.inverseSurface, scheme.onPrimaryFixed to scheme.primaryFixed, + scheme.onSecondaryFixed to scheme.secondaryFixed, scheme.onTertiaryFixed to scheme.tertiaryFixed + ) + .forEach { (text, surface) -> assertContrast(text, surface) } + } + + private fun assertContrast(text: Color, surface: Color) { + assertTrue("Contrast ${colorContrast(text, surface)} for $text over $surface", colorContrast(text, surface) >= 4.4999f) + assertEquals(1f, text.alpha) + assertEquals(1f, surface.alpha) + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/theme/ReadingContrastTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/ReadingContrastTest.kt new file mode 100644 index 00000000..c70357e3 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/theme/ReadingContrastTest.kt @@ -0,0 +1,47 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.kairo.reader.core.model.ColorHarmony +import com.kairo.reader.core.model.CustomTheme +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReadingContrastTest { + @Test + fun contrastMatchesReferenceRatios() { + assertEquals(21f, colorContrast(Color.Black, Color.White), 0.0001f) + assertEquals(1f, colorContrast(Color.Gray, Color.Gray), 0.0001f) + } + + @Test + fun readingBrightnessCannotReduceCustomTextBelowAAAfterRgbConversion() { + val random = Random(87) + repeat(150) { + val scheme = CustomTheme(background = random.nextInt(), text = random.nextInt()).materialColorScheme() + listOf(0.55f, 0.7f, 0.88f, 1f).forEach { brightness -> + val text = contrastSafeReadingColor(scheme.onBackground.copy(alpha = brightness), scheme.background) + assertTrue(colorContrast(Color(text.toArgb()), Color(scheme.background.toArgb())) >= 4.5f) + } + } + } + + @Test + fun generatedAccentsAndReadingRolesMeetThresholdsInEightBitSrgb() { + val random = Random(214) + ColorHarmony.entries.forEach { harmony -> + repeat(150) { + val scheme = CustomTheme(background = random.nextInt(), harmony = harmony, surface = random.nextInt()).materialColorScheme() + val surfaces = listOf(scheme.background, scheme.surface, scheme.surfaceContainer, scheme.surfaceContainerHighest) + surfaces.forEach { background -> + listOf(scheme.onBackground, scheme.primary, scheme.secondary, scheme.tertiary).forEach { text -> + assertTrue(colorContrast(Color(text.toArgb()), Color(background.toArgb())) >= 4.5f) + } + assertTrue(colorContrast(Color(scheme.outline.toArgb()), Color(background.toArgb())) >= 3f) + } + } + } + } +} From 850ff57940837434cdac56aa457bad4e6d7fbf7d Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sat, 12 Sep 2026 23:46:27 +0100 Subject: [PATCH 2/9] feat(theme): bundle font choices and localise studio copy --- app/src/main/assets/licenses/lexend-OFL.txt | 93 ++++++++++++++++++ app/src/main/assets/licenses/lora-OFL.txt | 93 ++++++++++++++++++ app/src/main/res/font/lexend.ttf | Bin 0 -> 175756 bytes app/src/main/res/font/lora.ttf | Bin 0 -> 212196 bytes app/src/main/res/values-de/theme_strings.xml | 58 +++++++++++ app/src/main/res/values-es/theme_strings.xml | 58 +++++++++++ app/src/main/res/values-fr/theme_strings.xml | 58 +++++++++++ app/src/main/res/values-ja/theme_strings.xml | 58 +++++++++++ .../main/res/values-pt-rBR/theme_strings.xml | 58 +++++++++++ .../main/res/values-zh-rCN/theme_strings.xml | 58 +++++++++++ app/src/main/res/values/theme_strings.xml | 58 +++++++++++ 11 files changed, 592 insertions(+) create mode 100644 app/src/main/assets/licenses/lexend-OFL.txt create mode 100644 app/src/main/assets/licenses/lora-OFL.txt create mode 100644 app/src/main/res/font/lexend.ttf create mode 100644 app/src/main/res/font/lora.ttf create mode 100644 app/src/main/res/values-de/theme_strings.xml create mode 100644 app/src/main/res/values-es/theme_strings.xml create mode 100644 app/src/main/res/values-fr/theme_strings.xml create mode 100644 app/src/main/res/values-ja/theme_strings.xml create mode 100644 app/src/main/res/values-pt-rBR/theme_strings.xml create mode 100644 app/src/main/res/values-zh-rCN/theme_strings.xml create mode 100644 app/src/main/res/values/theme_strings.xml diff --git a/app/src/main/assets/licenses/lexend-OFL.txt b/app/src/main/assets/licenses/lexend-OFL.txt new file mode 100644 index 00000000..ef5fbbc7 --- /dev/null +++ b/app/src/main/assets/licenses/lexend-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Lexend Project Authors (https://github.com/googlefonts/lexend), with Reserved Font Name “RevReading Lexend”. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/licenses/lora-OFL.txt b/app/src/main/assets/licenses/lora-OFL.txt new file mode 100644 index 00000000..0d1f1e19 --- /dev/null +++ b/app/src/main/assets/licenses/lora-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/res/font/lexend.ttf b/app/src/main/res/font/lexend.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f70999106bfa76d48051b73ad7c888573663879c GIT binary patch literal 175756 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�*TEs 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/lora.ttf b/app/src/main/res/font/lora.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ee1914c3746140523de2940d267ec2215bd85d5b GIT binary patch literal 212196 zcmcG11%O<|{r}9omGfKQyLI2aJ=Z;Vmt5T4Nr(^ul9CXjKtiEFX`z$?1xj1otrRK6 z-JwXZBEKK*kiGwB=B-_Ffu#TbXeYPt`{vDj=R04YdGiP*gy`{)f|T`ic6BfPThY~o zE&z}{&@+B`(hI?N?|_YpR3Cm~sjd#AQd{O7|zRuMMyeKb%v zJgKx|Ps{A-gwWg2!2C&*+a|yNtLLvIY@QF->lUqAu(s!((z^)Jq!E&~bLsI5)}8jl zF=ylcZ*jkK*@E?J@mE9W{BQ9$ZP~G#mR@+0>rq0SXA$a{x(xNp=Ul5m{eKbiTH5j@ z3l;}%`PXCUuMzcYmIIKsP_Z7@8*uGgzG}mX*Wa>zKgNm(AFhq=IOGRADf?+y5!S|X&LjaB|k%GowzI;4C`ob10@&KUVodYh;ZF6=at z&NhQjf2u4bg)Frwh==_j6)7EUcat<`AO&NVsU*zer4)EMaQ)vY#q@3B5M?6CX9dJN zW@$lx(})EnQ~o>hJZz)*+-QlSH=s=N1L%iHIR+e zavrWrh*{c7d~6=^k68u*?*q;L4~w)O?N1?g)&`zwC1H9!#*kW`Bvo{b!u|~WK~uVe z7!NLJ&+^|a^gE)G+=mvXgN$jQ^?#z!5fVx*Yz_Ee3-CT4I_Zz3Oz85s{^YuEbg{6F zfZa%}sXA7=pD4MmluXbI@sbA}71w2VK_;QMgpPzBfi8^*J;L=m*9!|re&9u_#5I@I zyNQc^jJEz41^Qi*NE=&*zdP{U6T~Flg7&_}Z?0>28@Gd}W&LX9dK5UA6qUGt3ECb( zxgECj@{z9$3-6ZD7sYSE?+hJ&jN3PEZ$ydFaTG06bUGhroL1s5`W7_t zfxmWuZk%@OAiJF2(9c{Cf^HU0YbkBy9~{okfgW5n^1U<*HwKL0d6i zl`?UU^KS{!0;eLW8KoO_QJ?;Rzwe>+;Qpr=n-{heG8ogH#W5YE=6Z%r8~H>kL0uQx zX(MLvh>7DUeGY!){aYb76)~Kff!_d%4Kz9tZJ&VWqA0*s3ghoNs2{_V%N#GvjP{hM z8$tUVS6NS5*h{Q#e@LC|{j$W_2o&2mULy&1Sbzl;8!Cx%ga z3A!0$lz?-P>tCUZE78wVVv#nXoQC@!gI0?{1Mr;S5kB{EJ64YIaGtt@*tm>K*`WDS zGM9Bg$E+f?^jM+;Pn57?;NT!x%#As02mEp1Ujba(p?^*%-6*G_tU#HIQirkxWd_Oy zltm~r>Ayxkmb9SBSD>8@y30z6=_#a%uETH48&$(ojTLySgm$5Z01oapE{@ZC6)MVFCblq`+|y&B~#QbOmDeDWqKA=iLb zI`D}OJbp59qLk2?q?tSg+@_7}K|k$We_+fN>{hh<6>u|>fV2uc3tm$ckrw)EQcAuB zJ${L?{+kSAT$AaYBVQx5urNJrek&*-_x%JC@6QPNO)Q4m>>aVWDx&WPqMI^0nY6@Rno+k8L4NX@D?#06GagCM_jCWhM#IG}4d0m&VGu_`5pg zdKrH6vNHCU*W=gxua^Fs@EEirR(QNvf>MZ5jM5wX%YXC2?|WmvHxe>}7?XT}(vLDX z2IuuUP}))A?M7n1d9*bfh1YLJ;dOab=8j!Y#$SG)*Xu-Chr;XlQQ~bG@q2L$&hPQU z>rRFb(n$UZ{C@=gKY|x_kzVjwG3F#w{9TFP&7_f@2boxe-#(NEJpT_o4Zh@hp6g)F z6N*PLcd(+@ zIr;(pgpM#PbFv7V!OmuPu?N}1>@`UtWl1{8DtV=lR3n`$T`c`sx=Xr8VNrM$^@{h*Gb#D+`pRwo`1o9J?GBIlgmgTn3lL<#2gi0aux;(pB&3bWL<^a9!oP z#&whHHrE}lzq$2pliTACxC`AS9_CSbay>ea(PQyAJcXWK&wS4k|0BTxSzv!PR=PnZ6CN2~IM4yeG6P0US76X=(0^-5g4bjh||Bk*9 zeLVUI7-0>{6N$$XKi>QLy)Ova|38Ghb=zAv%HX&5z4hf=H^23Vw`RR{>su9X^}RLp zRy+RJ^j5?Em-fHB|Ni}75HdldgvJf?VoS5-WR@-O~NdAjnK%Cq^epZ-tDGe+V+D7#(CWY@9>*fs1PZ{vA`{7E zGL6iE23btDkdq--JIP*h7P*XEPX0pvN^WFNKysdx^4Y`EG2~YABrL-V`ILM^ zexT_zi{?@zHBmdQp|#-QCfY^&>0COGE~ZQ9D)t!rfITjm*_G@j_Bs2GeZhWYAF{Wk zW$bObp1mODv3I0(>^;fIE@PKVD=e>_v77yO>?dZXr78f(&9tCeB3i zh@F%}FNL6QhDZ-AX+JdMLb8S|A=O~@YoikwS+O|BwaX$N_T+y<@jICRuw zh`t^e(9)ACe$>jWod;HjvLq3;7CK_e*HY z@1dQ)B@?I`R#^!xA0^|d3R?F^SgtfOiDr_iG>1$_v^taO$PB6_v#6fTrUo*Pn#lrc zA@gZI%_EDbjhqNOzmz)33ABuyM61auw4Urh=5af8>n`ZqQ)wI7LtDvi+CqLwd&wEJ zhwP)>dKtZ(UPS*uub`*V-SixK z0o_QCrzg-8=~ig??Q{p-Nq5mx>ACdR^f&Z8`dfNF{XM;q{*GQs|3t5(U(&DW*Yq2D z0Gg9B#w4a-YNlrfW@1KWhBmdqCRmu81y~UFfv^z$mbqXhJj}~{%nohqXK75y;5*X4 zumbv57N%FRLV7hTqSvrux`(Og2W&b0kR3xe)9>g>^ar|y{z$K7CGS^afT= zZ)6qpCRRyrW>xeSR!whZ5qdkTrFXD8dMB%=cd-WgH`Yk+W=-_(teM`!TIjv3mEOnN z=s#EoeSme+f6@n8H+_ip(1%$seT4PVM_E68j1ADo*&yqpPp~2SBpXMcV#D-lHl9Ai zCeUZuMEV??M4xAq=?iQMeUVM2FCjDgGMi3cVKeBf4Bjl8MPFmH>FaC`eS^)VZ?bvx z7xa6&pUtOlu?6&PwvfKV7SVUvV)`CiLjTQ{()ZaidK;^u_p^5T5nDn3hpi-khAqE> z&L>yWg+xmpBzp2N$t3r}s@+d=VB2%a1MoZVLni+Y*lWatIRZov?cE!LN9q zRFjWj3*La=upj=&Tkr|qCSmxdR9_gNkPrZzw)ee4~_H+m>>T}h9Q2#}Ji~3&mW9paG@2Edf|BzOh)|_^J+GT0irM;f+ zNMDe?Dt%M>wHf}5wHZ%jypr)=#^;&yGcU+|FZ1)vXjVp+B`c6sk=2~lpEWgWVbPwdv#CiUekS``$|vs z+4`yae;7Iq;|;S7%MH&ML&md=zcc>Hc%$*}#z&1Wnj}-3Y0xy?berjZ(-WqbP4An& zFh$MjW`o&j4x8)Do#tWlO!E@+apsfEd(3B>FEanxd{bUw-rBsY@@~t!Kkv!BSMuJ= zZ^*ygLM@Xmdo91QTxz-6a+~FT%QKeOEbm*sutcpHR+H6ZEwUX4wm4ThPjKGmyx;kxi@CPDUUt3f`posCI}P68X7|c+U3x-gBkrZO$w z4Bj1lIQV>UfAD`ozR;%7uF$VSzYqO6^i4rm!Gwa@1ls1?4 zm!4aCVd-V1SCxKI`a_vimRYu-?AWr~%I+!Gl+P^xTZO-3Q^kFi<&~Q&->e$0npSmj z)rVD|R~@KUR!^(Gtwvw7u;y=(s>pM-)wSE|bahMW9_WY>)o#Z?fR;_qx*N= zU-Y#0Z0h+_&kwy)@2cK=dmrz8zAxCfrSHnVoBH1A*Y|hzU)cYC|EK*w4yXrq4?H_q zGFUg*HTd#S`B2l)uAvvk`NkEETQzRuxGm$Z9CzKgJBKTV>xZ`tKQP`ke#iKyC+H_^ zo^bC(-^6_r&z<=Eq`XPqNhOo&CY?3u-N|*6&zb!1DYK`1J9Wa;^QXC{ZJ74b^qT4C zP5*F4=Zte_{A*_K%s~m-TYxX;HYUi9j=j}Nk&G}~T)VY_> z{cc|0ymRKgG4I29U(Gu(KW%>d{Gs_X<}aRq?fg6E|8xG+3o;j&7PuD_Er={=Uof;_ z#)3;0e7kUH;cphcxA5bIUoUE1bort$7Pl?lxA@5=!6koPs$81AG;gVIY0=U}ORrga z%hJ1-sg_M$_So{S<=dCvb&UC#zGH4!k-oyX;@A}%R-CrtS1bOt(z$ZW%G*}He5~i# zoyXp|imaNkYS*gQR@ba($XUsx^i+zBN^AI@c^(bNQO+ zT7Sy_>eddfUAT7V+OyVPxb~{GPp$pxxbeqbb==LzJ-x1R-RgB))}68L{B@VE`^&l; z*4@4Ck##?=Pg`$X?^$2EKC-@Z{fhNxt$$^McSFsF{tXj1%-!(&4S(72_=XoYe6!KK zv1nt<#)%u}ZM=2kJsTf8e(3l|kN@C=4JW*M;))aB*`(Rz*c9GWyJ_5}UvB#M=53p= z-+b@phc`d{3+*o!{Nm=56ele_>4q)YTTa|^?Us+W4sM;ib@tZ9TW{QY=hpkTKDzbU zt*>r~!r6 z?JV2*+nu-Ue1Dg9SKF>>yVmU5v+J5&_wIUb*Jrz3ySsN!+P!D@MZ2%x{owA`_o()S z_eAz|?pe3z;yoXp8aTE8)YYe6bLtzXDNeJUR(0B}(-xh!=d{0`_OH`zr3nk zuRQ&oy(N3+?A^Whn!We#eRl7wd*9ys(cUli{;*Hlm$^^B&$`dMuW(=GzJ`4r`v&)| z*>~R=_A~syeEm$GjbO0Tgg!;Tga?cqSEbgi^;c@W74)L$&EI}Yzl>%DC|1-*zDL!q z>`C~=1xQs?SJc!*^p$#(NsS*yZ*@&gAQ)7tRVr~URR#lrpchvvhs&7rMBW=|2B!6B z-wouc)ZXZiUUjD05Y$Wm?#VP~vJ7LU+g0ml*zq?@Z+nP=1TODg@{ z+2pj<+KD9NAzdu+s5%-<^ub6?jmc~>9Whe*rmY6MmP*P@^AX}li|i+AS*D~gT5?84 zVz^$&0wNYwNprT6I8-q;231S*$Y$~z4j<`dTcvpIpwtC_9IN=Fo*GO%H5X4klxS^c z0=@uIM-m=Lz~>|WOTeYS#o#Jwc7FmV>5mEM(iG^Ii8f}CRpjppOyjkan5I0X&?lan zj?9yIigS=cnSjqC-ATCgV*)-C_3w#~OxhEJtE5RM3QReb*prFcf=Y?n7bj{@-kcoQ zO^Mp=h{cjr*qMO0ksUHTdSF!Ry%f@sX#L7W{e_4bulM?!2nPKFS^_Z1Q+i2Y$5Kv%VD*Wd4BnxKD{vM;~NqJUf>c!t~~i^zJgy5vOU-2xrJ@z7S) zM1TkcyRyQhHsGhR!USYh-k`};QCU$_jlp<502@^LNHYtTFkGpOk$_ENROi!0)--O| z!Tf!Z)oYtqbnev@2USD!%EpyA%JV4gt82GYTH5J$yE5F=UFWBlS%cX^*6G9q@)D7;NP>yc&3$dWI$Pi$pqXY<{V&C z!m6v98PMm{X7e-FOsk&L9qQfO-?lYmDO|j$U|`Fv^1`V*rqwPkQP$Ac<}NRue0=}h zv&J{n8XH>8H49D|=-GX2tHW7?Z8l8q4Vnj8>?7!pCe_W)b{%~^scC)^4Qfr*FENa` zEL)%{)Y5En0q06m68(|WB>`WI6%7IB^b%4o*Iyd1FW_8uWOyy2v4=2X&KGyGzab}s zTrlQ1!4#1S2S^PXyCYyvr;@9A28@QP0tKCL=#2D-8@zHDIjQdkq)8TM9`pl7`&N-Ch-Udy;IYtOS7k5s{ zn6$^G>|otM&yl74??tUXF6D9D=>AkyKqVpE?{eX$*3O3eM-z3l9*E6n8#uTW`N5;Z zM@n+@k)d{03P&Femh`Jp94_c5Y$9YF96krxOFsKDoO3>h!-~yLLb+X%p-U6cr4qMd zavL*X31N>&1eT;tNCSs+EB2;nEBRELuwwF4)5&W2DL$jZiplU zFy&TEhEF1=r;LEhoD8o;W(1Z=#tK>4I9y2vgwYrYd18Y9iHDRn^?oRT??e z$c>sxMRzQ|U~1vC9n*aMT{cAoOD}WltLOBDIyVhl8yYNHpY|=ABa2xzwLa%W&E$(# z_3T;QmYuIHd$(m;N$Hdg-E+^KoMF~(&V#w*9<99~m~nnqP8R$^88^YzkVC=M&kJe_ zoQ0;7;j_slDR9o$GJI(q4sQixOu)O5TjsMOx5Ifwu3t+-LZ5T^r=W?1{4ViBL;h!! zd5+qm=NtufZZ8ZzeiQ_XbI|kPAox$CIH=OdXUJV0b56vi7AjQ^LZDZxm0ZK}%Y-Ac zAJ&F06+Vgh$>5yqheC~BK>5$nOohm!#&^XtF|#DDqSIooiKuyt;>Fk{1PtpGguMqm zCGIx~o0IB09R;?2cJF}+aV`Hd7&ECbc;LJL9B*(4*PqV|PHPh!IGg-VaE=V;oF>DU za=0wfG!Ns1A3-q#qevdNVU;wGQ#wvwnc(bqoS>sgn6Uane}N~jK+6HFS3wrYMC^Ft zlm#ZV0k+DDJ3rB9FvT#9(S3>;`bkV0M_HYG^w=!qljDR4>`RWR0nkoiFJ$P_dpML+ zq(MX}GIY|%VpM?UNn8TOsH9q~WW}^Qr(+DRlDbNGZQH2YDrxek99kowT(iqgfI;34{3E2sw(0h%twg0E~T`0O7Xb;y!a2}~j?57*2&ObyJ; zW(N#~7x)Hwf`P{#hsyfEmh6Bb*O~ibxV_P{(C*W%4U`9sn={-2!-@KUuKaLPf7t_u zG7Y5#ZpjphT&OKJ|GJ_y%WW&pzcjx%lfD+0deD?h$rVC+CX2DmUdt&Rfi}iiFkc+1 zmb$S*7$0LX#%Lc)mvKqr^sZEo)>rJ#mNt)S3>lA365igp=}*R_gcU{u@yLsKSO zPYFlH>L|K0>974{r|jFLq{KYRV)g*mg^(4ZL7oLdhJ~BYL-b$@E`P9?v{G7Envy9> zq9=~hdh)pG`-X5=lRaCTuL}(x`1U6;icuaE2IZ&4D1T`}o29_nHRKDdtum}m5yodk z;`?JF)qdkoVOvJ0Mz0?O$9HOfj6|J3i5j2%BJ4HVfWF4T4*|=Tu%JO@%#q@mGB4;9 z(Q`KE4v5~t%$2yn|0|6FmCCF)8H~6)v!a)Xac|Q!1&Y{TYiNT+08zcT;0oL9|YboCM`5I$ISYa*fNS zQESo5s(MHdV?Wmnt6spANR8;B)L+bYQrTqGkRRQHl0e`eBA1^ zrcsSYdjdTnn~zeMlH2??#xyK1%bXRRoL?b(RaS(L`?Veo$2^*W3?qFc)pQ?t3bQS+ zf{c`LZ{le2N|Bo*L@_m(KWcQPv=s3IXUn+rIA^Zl?AhdE!P#}tHK23?-pyl|M1AhX z%k>wL-{U#ZAo`h@nS8!OjC$Y>%j({s7U>d94j9g$iX82%sY0b@L%yEoq-W-;wOO>Z z$9zQd82!v+(%C3=W??a2m7&(Qgm1lR?%a{1o?eWNXMsM3SF6XK4EXx-tdE?b2v+dK zMBL$bFnad9Q>TQ&({@asy?a_=c-r=9&9j@Fp7sUJ&2yTZ&enyR)|K1aT248(y?xcT zmgZAdwHH*+SQTmBytt`m#;V%LiOXAg#!U1Hp5lEXL-=#OrsSS~76ENi6aPFe?2Xa< zBI9)-=6^BfoyW^h2)>Zv+^>`2shOXTMQkj?XJ8GV$HpayjXxIFn!^zrW0wkVZ`5-l zI+mZCj-5F2bC?Se9n0`pF3~z_u%Ka^Q-02B;CwAN<;Sa{(K}o=M5y5csipq(i8i^ThgLYGKSLzh(YUAD=Lo!KN@G&i!}!dHgPMpijBolyyMS80r8p}Ch@ z5t01J;SErBPf;sU3emPuxBZykP7F`oIm_PZpcf_*d3R$MQ*rd4whnvs+-@YiyEYA1 z)yt_p#p`hPRMg(naDQ6b%nKLw>{-*v($&et-X*56=^1)zc1H2k%|mm~7}7u=a__Vo z*?d2bAX2loB9(74n|N;A6Xd=##$ohg5f3SI+*IY7vukoNZerZ+i#ziROYGht>UA)b7J3)~0c<*F!X_695C;>(#e#;GFBN$-(DgAL8? zWkZF5!DfGC`9Se}zp_ddRIVMyhN7~;!a#qMUspWb6qr|_sHE?gO|9uJ4c8SKw84t5 zvdGkOOMc<=(`VUhL&c3n25q3cv&_;M@fsb4*y_w=+%R)BJXdto*rl#0937(HBxmQS zaHg8%L~xo5+KQRjDbIvohURjLVaM{eB0`CZ6?%ILEnh7;laFz*EcnmP)xu+7Q_DV5b%OlNdz1pKPdLwQ#wyK&U zRa4NjvB3CIz$(#YEmeLM>%lhJhH*i9;Eh;X#LZk04>rqAp7HFn;Ej0e5^>3S(a7QZ z_$^)BbJ$**3xj_;d`Fl?(UX|Pz49zh;j?HO$>pQNY;h=`#cRbZnlN9f@NVpd=W^!6 zd_5yau6k|*jRtPQF;S^es#?vLJh@wy$Ul#`?W)}Hq>kQMg$P}#&a1lv)`>D(%d&y- ztJ?4r;q062cQ${{kG024uIM~*9L-OSP~UGmes*rvqo zNv1=O47G(xVLBQtN9Rjre0kJ?)j3dm4))^nS&-p8dX?d`$@wX8E=w|e=_oi~E0f`q zuE3)PlXR%DP20Lss z(sNY$fZ@@iQaw}USc2vJ4OqrRLA=x6~G*`MO;LgZ#W=rYDOpDu?o2JUmFoX<`dV&sP$+S|(Z;`D^)25w? z5mYzv5xAw2#`YCW#e=2VfX|sR<4-3$+)LLkb`(!9wYPiv7q^$2wLz^&oheL>&8J4c z+fHw=ROIJZU+tg|H9}}{Gk8NKrc^fivP}-B{td~%j4%-;c}rxmQv_; zNUaR#T0w?SqMa#l&b2apGVK<+JRQV53tWqL@?hm=5-svy@Gc~afKRbSMYO9 z1y8h^ki(Wa1Wba*jag+8&-SiyCcQo_+m?gC%$KXp)O7^sTS_&?u<-#`ZJF^nUsr7| zc46f>a=r@YrDvJPmE@P_>w@_(o9>LJ@;er+a@svm@I`J$^L={9yqdkfb`gWP6-7;s zi_2#98O^0;kytf*HO@t?zP^C9-VUc4tJz{F;ZBSR>$ze*G!hp_zCI(CMT9hR=1<5X zei&2+LwU@wzsRlkHTn4pjRWb@5?{Jue7eC1(KKaDQReD0^dZAzvlZ5!z#cDSfdo&4GslQb7##+G2`HC zsOZAlYfs8?>PH$HJSzHW^n8;;^F{j%kE&$&hX>5XCIuTe^-1r6H(dT41s$FYW6k7t zF>5$;;HO$n$%h}VljvK?2{=rLsU(Jki!liw?nXi3+1;Q_93xPML)BPAeH;{E#DkT~ zkC+0xypW@D3sFTV#6xs5-+~ql@Zc8#Qc$G15BKKv@FX+?9kLlYkC1%g|m>;O-cd?_4@nwA9@7TB4=diDw2|lPzsYK!;*2ftFHt z0y+*=;~07q(5A{{3xx@2V;ssX(oh0AT=}7Bfn)e(44TBSF?N^tx+d|=cu%tZSqbQb zsw6bo{={Tm_Cum>q)x8OdsW1sYMCyH@!+k%7{%*SD4v~wwrdmp;mxji-L^g%%0{)^ zi#J61t|@sgEs5GKvy*Lnmw>jGxsmBWUA)Dsz}vaKJkxxT zTg8r6?zjX^H3+7Hf{kfqY|rIQo#Sd}uBgZ=C~-$J)hd0S-dsGnuA*CKn^;{rTpE2+ z?p>IfsnbP@_e=3HdNBOtg-+2PR|5ao_D&Z z#!=QC^mJCa+sy1Zja`UXT9!Kcg3Z~yZrqeDo%z1v-&fa}YUZ_cE^BaFxX)MseepTy zjumpA(Tnc{gF^7C#6nmivBI?i=uBN5$8`j{=GWIwD#<@qN@^0j6bnXjIhKjqJ^b{9MD2`3?Z#MbKBkuvwQI>e=!20ks9p=I2a@~V1ht_V zWiMTxfq1MhlxI-Zg?h1_TZQ}~Wlam~+Bf#Q+pr?cXQB)Qo2{8qjo1f+Mmj`TFRtWEPvJM3g`JNj{mePl;D2WhnP z=%;}Nio?S8Lqfih^08uojC@>1upP);2}Pq0atAsB|2SfXqs)`b;24pRhpB~KHBqT? zVZA*vz9KL=k7b10ikp1(rb?T_*XpRT7xgZ#p_RvW`J01Tj*38WvwKiex3a%_!B8<{ zjzDPoMEj7VD>H|MnI^l{S{vH)mpQ{b#)V2|b$W+7ix{h4Sij=97;n>WAjcNGkDo$Y zprni`5H7q@OeIoB*P7OrbpzqPT37U9cYnRJtyY_79UOnvh_NhKKcs0pVM6!j0c&JZ zt!i+6iz#5{I*rbP0_);(2mVBFTzX%LN$&+h9%cCc7+fVaFHF=HGR zV;ssNQc(grJpN(PUoYk$UYA=;9@VI%#51@%F7eFxmRS2NO{z~oCrn88nrwe!ye`J` zN}_ILYND>tBl0L~$xX3Q0vn-?WVpa4P8Es7T2V`Nu{Q{0s{Bu)^>(qZO@?w`SRQ}d zQn^nys_kB6RJkpcYjd3@*KQS2aWUrMOU!yc->YNh$v1zE{NhOvDO0S)@KiWgVO}u7 z4~6}8E;^sWLX^y~d#qu}hswL!2 zR!&%CD)$);d3q+P^D~{EoIQK6fk)%iJjRo{6DP9tHQRFC7QKZ!GgVpY^fX_M{g|K> zz1ZO7X@dfh$}MN;SR!8R=cr??$h=P=^MO%JV?t87q8I;i$JZ=K#3=+18NP$;Wbg}k z96aQem$X1W*>mP~Akcat-(oa;y@A?iuCBnlMJ|7BQAwnEL6y7Rmf@w7Q|NXIk;p`4v-ZtyOte$DCGwdA_AGSkqvuj(!$ub~{Tw zX_= z2Ttrr)D|+rYx6kdGW>-F!yHtAGbOxFf&Gih#D3};sLt3{N_Z62*jo;Q^NHb8!hMWN za0gC~tBGi1?wg>pk=mp0_s%ZLGwF#BOtapBckZ z;`_F^O%!Z-^q6MS8RQ|2*Vep!usq{Gb=NrK%P2?2vXe^Silb%n&8%G}J zaPgkSw}P*J6dYU2w@}JZ8s)7;kSvp8-BhSomrWoCAB~NCK+9ZwL30&cG?=7D9@H)*3WHaLKd}y=Q zmeYBQr=eUo&S1;wQRaJt+F7;=o29^=T>yr1VY3I84pT)mxJ_$KmqHH7U!tFwZqv?E zc|tZvz0cQBl*LX<;WjP_I=-unW(i8pu8LsZELeNPC=$@_wR~)R4nD@%;`}~J=_IVE zhnvU*F{!L_4o2|62`M;;cBO~WTdtVpdYmxz2(${|be@Pi0{9iIN$yxaMCB!Vrb(k2r>zO6vZI^C-t_G3XG(7kibj6?>JF?N(hsX!fL$D&3!6^F+OfFzT^NQZ>H)7`?T?ikn6?l(@z}w{P5z~XF zO1@#>5K_o4#Moqno1vn8F-@74tG3zF=_twwk9Z6^mdjc+zHwN=6u~Ah-8^+V9ZjQy zI3z*Y=*wKsLJJ@_!(13mE^(#Cl8I;{zTb>{!zSwMUf0pSuBUrlXUDph>Sn9fR~M>k zcY3-tZR`7oxAt|c>l-?`Kh!wCx_DYoxN&}@d5y?m%f0YNsK5W39B;Cwc_u#cOx+Ju~rE zc*HGgV-;NNrQzHiZw+HkJeRybHWtbIV`CG)uaZ)+;?|#!EqS+9ya#*m+%$-H(B=6> zd>b2I+d3W>6pY*qJge~wXW=4d4N@~ahq(VAk5@-=*hW9yUi#YMxNN}6mN*Z*@^ie# zd*#%_essZWe@dZhd|Z&`88VKaN`v74AhOYIqa=W@BUbX)`(sHJ`6vZ(n2Xp;Um}T3 zqFgMH9r6ZL)Y!eEv0+6|<1DOy%xw&JRobF=IJ#;}ry$$h)9=AI3m z-Uf?M)eU7`erHF-(*hHe-!Q`U3w=Aq1X;CKcjKC&DcgFqnk!;Gd4bFfc6#jrke2h;BtN zGG-_%LPmOUIJ*QWS_TgaUj=T_1MVh+DHg&CTAt@{Cqvd%ag1D}!&VU+;nYS2KTTm& zY$Rf8OnEZf5eP((n^Y&F)r8~WEOck`MfKdOHlHn=VbYl_Qhs%bPPWL8CkzMjE!qrC zsV~3G<#4B&wbX5*wsQB^ai0PD3bGAsxWv`PB<>U;aRSbB4gwBq8{yi`ho}X(C!pPM z<|(N{cIyg^sD|gZ?m z;Vf|EXaaS`741%ruZ$TVU6ZgZ^~(}aF*X_6WS612d=#;|e7D_BK?%`@uu^jEM!7a9 zk*p2>cN3>XJ}mMX$g2$tZIs;D3PK=`fxls{0Riytal+db9LY0lBC#U&#Hs^wMp{m6 z!O3{r)M1f8BSl-ir=~6J?Jk)+ojS`Z4J~c(!s7lWl5aID#~1e2g^Ut2wKRp?)ediG zQQ@FQqshCfdAJ}HEH!9D4aJ?qtaoA{opx{RIV3GNu(8t=s_P16Td{(a>GoI)T)DbX zZMdS{CB}z;c-|19upGm2%rUGnGl=mqmUDl3;>1HThe`ht+v|&shvp5V*>Ogt#0VZc znQi0ogUFxi)!5>R*S(OXl%U4B@R)O?_swMmd2Gm1Q<&zt@=BKLvD(V@R#Rcfqsb2R zHBt{Pr2f{xz95T!Slr@r*-Y+$Udi=DynU>2%6S@zXKBao#Yr319kwHCFAPSXJd)mU z%6=x!5gir3{Y0;{FeQFlblCn`b?Oq(?|uRi$cGW@b)YJEzDvkQ&q^){M(}njw0l0E zdkbHcLk^)*NO>T+eU#?{wFVQ9-{Wzw&}E6uxMG2Bq^8eLCG0M876vns`V4d#nS_0% z;*xJr@ag!%oO8M+G=Jg^T+IvZ6@79lHtrT9H<*X*`!Z;^re3U>Y8~0mV&E$<6FRIP zqu{>QUxeh!aPFaUI6TyKd~^aTq+f);yQ?b|~ z#j!}YGY(uZei zbZT07n?swGHH46iDzla2({zf~i%bryZihs>12w)(35yVw_Up8ML#{{jYMw7007qkS z^cv3P=udK?Kdd1p`oqplzRZB$#9@K_N4(n{yoBuKcwt+n^wJJ>rixWRSDdaXNz(gfxEWr<8(yq=)ufN9PHsz?!3e8hxWvO_XE75I6hbavR(1x;oIAz5evC;JCYo-#j zsnB${K_E-B3^rZ#c&gQTy;`l;tBZcZ=_oK2Tt83d52_P%6mVg$WOyU?8zt-WXh?=f zVsBN-^*O)D@H*ZOD1o?{sX+?U4Dk5z>$_;;GX^0T$>&QGF!HV$?LXId7Z#47i zf~k=4R!cc-ld~vKpNAt6)3R)t*~toy80hlw=|nd{YoI3(^eV%~EJnU6HgfrNCjl24m&4&pwkK{1N# zS->2gG;-U*DH4WIJL&)dt#EGV#k*J);;bKe_ZB=j$V=dm-CIZqB6B2M ztN7Y0-?xRV88`+q|J(9%Uqh!`oo~|z)JjFJE?4Vm@Ygh89WPsvuFOme=qz~(U0I&F z1iNpY4St3L>rX0lISOsrUV|6Dt4XWON=r9WV~#m&t!uW`+T2#9LZ4-{Y7F^>l0lzU zn5Xer^;&pnWx#wRFwf;@kU;8_xd^_Q1L`W$Es|Jc&XO+q4O8zzWw#~KCW5m&dXOY?MLfWE}4X z&X616&9C{XWw4i{&YVhl&6lr_BFIdhEd^ZsF+_j*h_dLc*ej56>SRe5qo?AZ?<-{| zbmE1^L%QurZn+)(#$xPMVGt&PLF(q)#JCZ&9Wrhn@mGrTN)H`(7zebum7m~CHrf(YMuq2d7;;y(VWpC3OH3+-dzL}zQfI%QUVMtuhoQ{KU5dqqL6A<~e) zy#RyzH*J7@& ztY}t5yg0&^AHT+DOq^cpb>l6;h?}#CmisO_5`2_upD9yC^ZMwBI1FrB*j*9bR_>Wg z8BqPb4cV!zyzmXL)Gge)cW;vd$H>Hw2YY4e?AcRau~mEV*$rkh^(kTmffqL<|rvJF3a#17#8Q)%nbvyyoR^wIy2PPXV#;r$x{&!& zSCI)+k6z+Ok$RrD0mw8R&1G zGw<~pqcuA`Sluu;S5hwQu9*C;!QbL9*)i`rv ztdr>IgMuOXY{Z3T{|;NU#`%*6!zK9>X<@v)lX_@vx%I$5L`F+sEp3NCwo~wA4Y@bL zt8Z{#jln15{XDM!>mXfUOWP4CO$Gh21RYa-+zh4+hx^@pm=x?O``|+MB5Ep|@JNkV zuY%>|j)pMma4oofWvA;DI_(ym4IMp;vflmp!D#P(t0RjZNAaTo7I02?^o?+L*lfUi zO-f~s*JANyXQml-26MRkhM~cHH(sYGre{a1^tMdMskq|D^>Ufbt15}^q7P;2)A^n1 z&ge8tFk6A4nv3iPydj}6re&DYb22k?^rkc;4pnm)>_z67^j;yP84h*)THipw7fNSg|FKD7U z3EXi;C9d1VwK!{rpOq@k6p{Uj`!EJ*(9!-xT!|)82RA47UL}k?+nb7b^mB(DtE60; zjON0zIP^OOHTv?Rweh#IVwA?IAi!=*0$YAYw2IqF+~+YXe=}Mo-k4@1Ka70B)&Tnu z_sI`FArPmVG%HiWScHa&z%ia;xXb9zvzg6ig;JTOFy*UlfpE~3mXV(^{J1}C)m~~2 zR}4OE&?=fg3%k{tWjVHNZKl}5+PGxko z&kR-TS&=T4ZP2(9Gr0*Xws@WW++ z;v1SFFMRzzVhn194~(c6+Al~0MR|1TFZ(+8tzxWiKQ4~>)j-!-YjJVmisRPaw|e7- zHRWzr=~uU{9h!FPM0HFy3cc#VZBvIgV@pj51m`mPQO34)>yOJ;AG`dN-5H>CIw+0z z6G3U@xS)$&xZVRwV?9W?#qxeZ97>xsN(LiV1%Uo2L&fbdXCZn8^qF^F^{R#Pk(&;@ zoagF{6b)JA>o^w)w)vgbxO{$BuQyZhI5k&JX!O*3sb2F=s#!^#*U9nYe%oWfuL3mW zZrUi{UDiN8_r;X>u{YMEI$UnG_G=*dwFT}U5_O#;W0+iM=3I{3;a_c0C=m{#&U{6TRkKV&u; zlOq0+nYk+#kjB1nAo~=j;q0WE{-W-Ah(-B|F*lza%>#FcP;}yq)^6A!uB$jd4+@Mb z_(_qY?1kXzB!+NV`R5lz(9T^neu{`PHHt_GpkVF z8UwumR8BamGMkS=SWr1R8ocr78*C8BFWA)KsrV^_#Ocj#%QYCCOU{VmC`%sY#m*Sv z>~YBGxFgC3XJe|X{C32ctcRaFLXY#?q=DgM+wBcY+jnciepS!x(t!edv6+FwHGY}G z6J|VnD8eyfFZ)HS9T>sCkj?`}5iWZv9tNgc&^F;>Br%g&S5Q1h@~3_RLwo~ct`7(C z-kVYv+Ith&K*S!Tc8a1lu)q-*8!cl`7gCkD8DK4ZuTzD%_jDCD7Z+(#KmIZInBiY7OnerE>WlnoR0>gk^cu&=0gTQ7 zMn2(Xz%@@sQzBm}f~eT08bth3btN1QzmeyTjkLUA{`{~dctYE>U$px>=hjV~orxf- zVqVphEfWhW=A3*_G1k@T(l5f*c^Y$TgR!<@_^f$-$4@DqJNNY(uccw}FWQFpt!Sj0 z=Yb9lNh)tq%&!*bOsn`i_@JT{dhU=@s^e!sgSHceC&K0QYj6@gKB0m1JSoVJ0pfcP zyz+aS*!vvM7&kM#K`n3mi*K*Ffgdu^7ds*d+Y=*I>m5VkV;9QG1=`!)8FAO)8&B~A z25@8$orE)k8pZ{p-(=@5pWoRR_f7IDCN_HNe3WKqz8_cK;P#PE6>5=%h3)6(Zl-K; zK12%|E1M;;nF(}1_#{yF*U6j4huE)V)Z%7_`v7Mviig(^9DF`#^@NDcUN=?Kv}{YO z6h8xg3_ic2@x(<9hd(@2)`lxUa^`+%FH$mUF3^_r7Z&xqjfKt) zR)<=aWM9HaKi?~KS1X`QJoKf`MZ*Gg>sfUDC&S^YDq=Sc}>nPRM} zwe-v?D(u11pEG0Hs;PrHnIB>DIflstruczS(QneT)Rl8uCND38{p70Q*X(U!(VLFe zrt`C@V#df!mmZOQe}S56tNtbUT%%(5agBD}LB{^YxUoMc)!0ugkKkzZ^8QOM!ATik z!|GoPn)Y(}lfS73*RHanLVP}rr`x=kcy%R?C&8Ydu?{{Tor0oxI-*pm%o!pVl@}_s z;&hKw^iFjrJ4de%7TcDD@(aV}pG`c`Gfqy+&drEEU`=z2Bvc@feYvGnPg|>*zPRur zmgDmuL86Incsko?$WSt0k!+_FdokLj1RMXy@NoQNjvK0cj04|Ck2^*>_&esr=N}TZ z7;VLNXJl)(=^bfZ>=zoXH)NUXGZzGn6GIXAk~DWfHzzyaRLz*yt9j95QPybexf3)2 zSNdeF-Deq>X~{N6FPF{T6YG`PnvCcRmQ2rBI)EKQtO@ht_oH?Y5D;JP2-DpNA?1u!#&K!p3vm&F=0Un3$@o3p>(5u&gDOI8Z5P&9cEpAdYU2RG0{9{ zp4OVHigZSwJ%aOL<@2Q8#t3*c9zVdQ5-5YaB{Nu;D{DEWCGRkYb^-rO$rGhA>dl6E zS$RgCDlZf=WqXTiJ!Y>V&)_hq)v62)j@ih(BzdHiqNd^lTcn~;+jqio&A!35kj`l| zxv3*3L$Amv?HRO3x%-cIPsd3wf-jegn1!c?#bSNjq#cgc{(~Gcvbfje`3(idmct}` zR~~&1InN(<+HdGsn6A_W!#|Pq{f~0V!4e~Vo%=YUk#2nVhkrQ+Sqf!!#EipLQv4kJ z{`stNUUjDPC=$na+)nTK%TXkc<#?Lih?#`X!=q0ehdIVn#uu36uu8l;bx^WjiWu4H znJ$mcX0>UPpNvSRjbS1V{m6vX;|w@%C?8iQv7CIbY1Bs}#!wN5er>{ApjXJ$=JDx2 zFpd^3=VNHfv5+3qJOq(J9;N?|Io!7~f_gsCVWHfNfg{3~e;vbGY#BAscfS8#*7WQN z;4g9f5A#)=BOXJ9mPfxyb#XDmRAO-b!JbNh1jQN2QIb-(`Fo%CBvM{m=OPEG+Ia&-x$B6QpEV~cs zq^-a`_MxOv{6Ff0N^=uQyyVxBQt;;sC)`82;-Jqe<>PGYx4CxV*^h^TuUIb~gWvI? zs<<{Db$5*Q(ox(_mqpJP9G$v%;HTE>Wi&&HPdtqt#Nk(ar7r~bvAxSjy4stX7?bBD zbsa!b{nX0ukKe{c;;_pEN4f$m2A#cz&j&u-(XxAByiNu1bkw*q`$Q zrz3fnvVf7`BpG))?Yho|8Dn-ldIDmY=J~Uff^T>(;5AsKMluLaPxw71vs!kIYVUkg#G;4;UOP4c^5iSI)cO!F8_B?a34|Hs^W2R3$9`Qy6psaUck ztM}fki)2Z*+( zWeH0QED%Tm63EEk&$;iZc*au#zdwF~8IPWx-#ho-bImhL0_qk>j2$8;tg{#y+0IS}2jE1BHAA#>g~+7I2(w&ddwDKCt) zEw9Z3KUvGM!OPILJRfXtsq>MuG&m)ND%^XfCZ}ZOt>aaJg8)Wk^-FbuVcbETNMnI3tg zTrHB9oBUxi#cPuCVv`?VS!(jvt{xZp>-lPnhcNS0^@kRiyExlTcvOY9*-sPP5v!n> z2Y2L}&}?pLJjsiMb;Bmdc_6Zu5;Wc94C9Ii zvb=)EW53^V%jQ(imTP8r9$yn=d$fHAr{f!E^YNMUM!L5z`h2+~=hr_mv%5!NsgT8` z4^x*t^}=TMLf7!2myNG~&C%h(eOFgC#xAz&ja$QOZ`?C?Y+cIP7k5|3ob1xTKs;PF zIE-$brq}44Ii@5}0_dON^rLSKMNlPs?(Z4JS4y3pzjYv_mOlJ94hEiWaP`@C*FzKV4)D6eJW@k%MIK(Uz|#Jtjx-41_e2zt%Ugc{8WDCx8#q z1o#lIXrkq2+7OHqN_+uZk$?C!i88{-z71)Mq0@GATNX@y)m)j3`llNfoH8D3B*2_D zU&fQ*v+L#FVfbF#@*PX&CT?G4*Pdc?sM(YDKA+CyHRSs2XE8d$^S|im2q;zkI|kX> z)8KVJqbrS$6Hm=uIwDIN!tBu7E9SBl4m%fug}V+*b9(zZSV?WO`jV_9d4AV%*-bd3 zM3D8Rsu&(KV6Q5L{hG6BL#Uazha-ju9~T9Cck%--En%c*Bho~u>)|@_cFga@%K1%- z(vctRBDn{m6gS`<5CMo9{SU3k&E(Aw#q2I=hL2P4LeEoyf0p_lG7;St06iC)Z4c)f zO%MH@pL$a~rt}E%g!4XyA3E_b>~89I*bxmV6Z?)_kVm^i3bZzw9g3}1hwR_Otar3N zXbLsZ>Ww5%Fe;u~MCbgZe0QF0DqY=tJzf0`gzKv~vUTyO8)@7c|6R&SMPAZPxX|dS zjR+>y)#p}Axe}Mo)i)C^TfW4gtb5tkJjPoTQnLrDgZqXxh!RFNZiJ1!>h?|7U%|(-`Dz-EluJrEn{>d4t2iS((~z#RAq@Kp z?m?a|CdCsqVv^OCE*nI8Xd$&QGvbwVNsnCQezPs<{|hf${Zn>ghW%q@ZvHZJjr<;dy@vcfsvj=p#J#(Dy`p$o+x2%fuWyw*ql7S1cZhWVoB4Ap ziu)QfD6Zw#-zHyQMuw({v!Gs{U2lX(fKL4Jm>>lOab`fRPPT_)S8>xY7(;i-!am#Q z*d{D~tl`f%CGo!gb2cXl`@*)$1vma;rB7fLrwnE4W4CId@vndkLd6==_y|@eyOpr` z&uE;{al94gSwro!Z$axbA>vg=x(iuDv%3E(X4)ye0T!m5q2$6-K~p&rT1aW~qmx7C zR(bzZ4YpId1N0o&5y@}Ppk^obo18z0HNZJj$zCQalMv<#J9G7+oUY5k7WQu^{`_}# zI*^Y@{Qdn7cy-_fAZ+CS@)&;tBa|>SfoLa^Iiy?<@`%v+NyLyi+NP82Y^wLbrhb7H z_YP033o%wcI9}M5Rh47<3)c59#OM0AOzl3x*wD_t+O<)yYiMG@WXaBz`?d_Jd)LH^ zTl@W?@;5i`nV*l1rv;W~TjmF*{6qb=NPpBU;V>`HO(v_kjqCXlh*M!0_Cqt72!2&H zyO*=QPY;Z9SHvb5{Q2=r6NWWXhx}p#;1qWZ3U6RyOiDv;Lt#h0e}5xsnWGvmJ9zgx z!E=7!hrNqE3-+PBH#(`0nI+!A>D#vKtCi__r)Ix-*x@7R6dxBIEUC7>XbLi&!@rEopmkx#x{n+;753k7U zM-z^-bunbik3#-^8*>T7+b;Xg=2izEOXn3zD&mGj3-Gp*Vh^|e$q$ckaM|Do0A69H zJ(FrBz+LIb{e-gqZv=QD4as_-6FuB|X!GZp&(V6=Mozx#&T2d4RIRgE&E)wuu3edH z?~arI50u>}d{bek3cmn>m?C~yoN`KV3WXmsio@DTib;*AR;!iilKbnf9~cd6QzntG zFXa;(*>u37R$tMbI+g3*#zx9KS{XMnh(88)2Ue>4h_WV@%9NP&(zCV z*k|i~E}xm()<6)~ialZ#m6dv>SSg0n&D-+P=}&U{;o$+^=B<%+E;)7)04JcthLCB# z@rG@=?A9AM$%*HiZ{FUW+jh$qNX#dS?r*Fh1o`HV9aGQ3+tM=}C^-@>I##b)=)4L4~#DvpvkQK=G;wY}bBfK`A zjm%9+E^6V;ARLEEI)6jRx9m@wyhfctEx41m-tKNVFFkd6Yfc%$m(Sv7Kb36Z!)NfiliaSqNOS^?f+K;$ z4>9EH;kw-E1Z(t3zDAklr`d}*C7qD7u=s#r4H9BO>2t~wgCgVwytt!DF4PXsuADK( z%;Oi1#y3v5`%6^4IjS)Rquzmj*V<{Oaqe3)cBndb(MVs_;~y*Lhy0{mzccF54qP(pzcfkOpHzkYxmi-YYjl0Qr zOZ5+$x%h=LGMD*DGKsus(t(^GUVK{4BdcIENpeNwWMMAuqm8Z&sZBom3&58^yOW?MQh-#A|Z#7Y^DqyQ^+)Q-Yw6dVa)*C`XsIaD6O zdG|3oG5(BPWar*YpADzkgJzK`QS+zrv;2SNt2re1gp{ryM3nQSxADxKqJ}S$Bomsi z{6=}m$r8#B`912BUuy5o-0LZD8l=h&u(|nY0^oHiTti@-hJ?^V`+%4RqWm>{RAX&p z60RXPcw<~*S{jwr*TtTIe1I2?KV2mHD-_F+DuUth5NIo3U}KiQtU77m%O-!&cmXX_ z+r*I!iZ6TF|6n}=gaF#Gm3(ei174C`sB~_&F}PFS&b)f3LEB-}Bzj$NHj$+Veo75zWKk?b zq`5|w71Su;I;5$%D8l;B%Na+)X zgp(3Z{9jH~Mq)ZEwrxt+VDrHS&pRqu+FKH#XE`0k@L%TqKnx z9`3cw8vI6U+~i2c%-daspedfTMraS9HmE9WO~y>YqATFc2E5gN<2Gw|TnwZ{t=D|> zWqcpfG)=VCE8eE_TO0*vAnOd2(~eTf!Cu|GZ#Sb0=`u#|N3E=nq8=Mi5>Oq<*CP2- z=@f`8r*I;cIFBEXmCna_wdhaJMu1v$F9*Dr^C6a`9eOO$lJTK>4BgS$_T;P z(APFNODz}<+3VB za+SJ*o>GWc+KNRdoq*-b(+uqp$&Fg9MeI$Kf^HP>kYwA}9}U&N9$^n-5m2=n!tU#| zPo#U;f40fG2LI9a?RC{}^0C*S=3CNWka@5;zKk9UB4nXjEWNyWl{k`LC64?ozEzRe zP}nDY89W#TewtJq#6>{&W3>)AI`|DTsQGxcYO zHrcFZiw1`rvo>2stx{?%W{Z6j9iUAspAcSknz=di%q{k$EMAlXv37LWI&@mC4nI1q z=zZX|q8<9{uD&aIyK;mFntKv>!H$yJjRmHimpoHJa2)t)6kcxV zypuQBX$yiAjv;zUBMOi%{FLt(@sn(lKMFC711@9iHj<816hS_NbFw5QfzK1*2^$#2 zO)-cg7s_#}MO!Ye*~=cFQ3#TJ0J+;EZlyg{>-4)EKIYPkTI8L7>TNCgNu{hL4W5n7 zzV?b~q9+78zaY_5EID9?;EfP&R!BAc3r=Y?&9FLnuDhemec9x3!9L`&J`{2&0*no@v_%7#J&#j3_`X^j`1S6M4AW&k|2j#ulL|&`WBD4?@D3Ythf}%3lc%YE? z*i!S3T*!aGqW=IC`r0WZ_Tpz=fI_2ECfpl{0&zAKPbk@^RS6dD8O1C~{NpuukKKFk zSU0Z+$?;#K0+pI7N`VTg!W3knTw$uTNGdjs>Z(6Q9asmvr%RA?oW1$6IQ}n`1tjcA zHW&5GJ zcI!>EPHT_di-UNnbz||AEANY2Z9Q%~ir%Id$KqY4u+uJ;#Kjz7rl}$tR^kxf`EaD6 z2!Kjwfa>}bx#tkiI|0XP(k_VQvORrPc{J#t-?tcWkQ6QF&3`x zlDEv)gOEN<{E)<}5|}}}X2(iM-4Hn~Hf>x{{s{61J4Gb9UTfz^nH1@i4Zs+Q$unN# z!bsjkGFh)^kp-DAT;KZ0fxs$T3W-Av+oD0@Dwi;gQmiivswtD8HH&8@lV4pVzYtVCQM3`J4uUsfX=B*n^~%hWngp9F>aQ${CMT5*=M$l@D4MSug0` z>-`1kA+Y0#XTTaLD?Rpgeu0S)V5D%zh}Ix`T)4k6v1buvpbc{B+n~)S)r$g_Yq=S* zGsuB^I)fTUv;^Wn#OTI7$^3loiq5cK)m^LhJK|=>t!5{71mU@WFKyi|2+7G*??fcc zdWVXGjQx`p;*s0+!lyY6IqJT>!mgK$ffZE9Xc-$BvnLggUI9{?*tMO&qD0^Y=8(Ck zRxQ~*psGijHLB-EY6=sIf38Bcn4CI^>Oy0F`Ow;c7*?C)Q4*sGP<0NDm0Q$@8!ih( z6-X0sH+Z|Bf~{{&X1z`mJjhi;smcU>+uZ(V`*g}A`%El9c5nR>D_R~%^9yRI+R3*- zO_}yk8xoSc%v2CPbwaxUQZWa~UKeZ-7!i{&^vQfh43h>~q*|3JbqE6wlk ziR3YePNg$5$`;KLO;4>0kltKrpK?raid70U zjRRmH^_kHf-EN1+rq*`mdb&opZJf!CghiO6v08fK*kpKY&t1=%B7>nuH&drebLj?wv$1a&*yE#&kd6sy1Z^zW(>Wa=H-6Mf; z%~ZQ$jd1fxn1zU(o4I&&)qW$Ol){tGVFzZUKJhe{ zELMY7aIV=Ij{9Pr4#V1!?uG0Lmq&*U_E^$3Z3uX^?ji4I9AT7nG8M4fssVRzG;cI0 z4QuQZbY)2C8~X!W;@z0c;=3J@24+|F_$cBTOK9pm%aND~1KJkhg7 zA=m=4LiR9n&ah+G+2uLLmQ%A0j=WE6%;A(KccbHm&6+hW#(3;8VvUhCA>Uf$ z5d@X$fg^LDIdTM9ncn)Jn^VjlVA)nju3MABd(n}uaK-+Iwj8fvZb@SpKyy~=!bL1g zvlXp0tH;R%Iw>+8ZHc{8eB4LkPA}<;SGOp9m>U)Ir&Z9~GJ|*B&?0X6uEER)R@Kwc zC9?l+iYn<#r^~?tuplTqfb@t_#ED{L>70wNjuHD%Oab-ZY~8&Md)#Es_@-QLZBa1c zcwE>y+-dQ-JKcTuc0XN=m>d#X6_fpRtaPExr2maq6U;;dv4NnkXfm4@{V@ltBJ;$_nxUD)BizWL3h^UXYLi`Q5-3kBwKE*|{9&a}+ zWu<~*3o71eLkg9gIuks{gC8^IlLW@%Ha-@##LN4^HfhcT+9PWuOxiS{_p`g|S01;f z(GK1d(Ramsqb`T0oBhV*^#pDG2D8_#(+7eEf1i8F(c^cNJrQ)Z{jAri47vN=zFsGb z?C-R9e&4C`c><Lb2De0nJKbKC zdL~h^ONeZUr7D;=laJ;t6-zs~RZ~Z%9Gm52)1#sSe+2SFc zK*_`_cu#hBb0zUNIMH!dSc@=?GZb6h^}YS2xzU4t#V{Ej#&n8y(<2%6xgR%8qvgiEqY-jA8o#*$zkKO;~b9bO))grL)MzLyBSTzD^ZHopb zk{xI(jFeO38(dQ0X2B)s<7sOo=5S}i2H8Ezp=i_EI@zO|>Wg$4oDtuHIFLfEfqF_LTG%$|3Z?3|GdN3zflwS~1#@w$JVJX=cc&Dae(ov;+jMD1BU#ZC#$ z$__VCc7U*RbQtU5`Uvi~`pa?4&Z19}3Ag_Ss3sxsYj1-D1b&%ysL96Ce%{KUH@+vT+0`LjmSS2Mp`gT=Vg zs62W#w0(h}Ux%26F5wPvO~U~$A(ZA9Qp|`$mS`lYhVYXNVVE*(bj!=9=3l-hH4!oR zhJuyN`M#Z_I-7QIqIap(v%Y8TaC|&$Hunen`-|07Hkr+&a^}gGZCbi&L~n@cbk&2y zqep9kkho}gcw;)fwls1@N~g3&bS9f^%6njBY;j$!wu@FH4K4T#sOFxsMuiNyHW?8Z zUEMptOxe!F+57`s*PYct0O^Db5Q=9YkM5)l5Xn@*)*;(OGO*Z1%`A|WkqzPM79X~v z8&Vz!VKK(L%3!xj-RmkMK_5~kYShw(T~Xvlr*uWFL8cp_>9>%rd%V5Jj8Vf}tqwC6 zGhuX@cHF(p+SX~tmbT5c^_ltg*X7+PH0cnj4qOrEc4Q~xIZ?BnP1aAG14Z4+%()ZA z=U?zi60AA@$s}3X1TUbqP-#js!%o)-hW!bGbPw;Hn*vdk+j_jVhvBqbq>%tTo)a4&}}(H;e<^&rh+BR2Lin6cp=oB$So z>vM1?I8W+W83&PAYP6g2h?yXG`l3=qrXi6f`pl5FG?$2SHs=!N*g1!ZXd)qRDO9v| z&*?%%&A0)=MLRbnx)F_CHCzOexOYX4Ss`Bi5~EfGw$34P^&-bsXN_H5_{(3y<3?Bt zUNk|?`^{6Av30@{uobj@9Q;NayrGQ}6rrYV-vYX)ak#O+>`cfr*K(h6V0)^c4Z2%S z7(3Z>MoYN@U^Sg>lzj_t_Y@>0^t@F~8-j#8Nk|x?+*4Kz6Ybm;MW!eOyOG0noGIbO z{2s_np?fk(@rk2D#`|?YnxZ#y$b#TLrJnGE-Ks@t?!C$KpONuYyCq?VbXeIL;;cxjI zESdkwW;~{aYQQpFs;>fSrDzko#~9CbB_i<+(X(4Xk?HRKLUQEy0dpnk9-PS+7Ol;m z@r|9|2-LJEZf!CaO4Y0TvonBQQWiT!>5!i#*UcyXAU_ZBOY;ouY12qF?3by z0Hs3F95$eIV?HIiIPjK9l%^4@OtPP{18_zK{sd^Ba~AvH0=515kyGTpy|6Mx;0@uJ z@xiZ)LZBQwi|7~AbfguzL7?eJpoz|GN^|CrQh=~cNV<}Sca$q=d&%>KTFF8h;WC=3 zqf!pg+ZqW#Z$-9HPD&Mehny$0Q<|$r?$3?Q#2=n4bO~Zqlqtk+;b}r%4_FTSUdj=!QNCCB3F^tJ<^FY2rYQaH5BXiC{e)yG%JdxMv!D9O z^Nyi$$)adWcZ9q)qM|Ccoz3BRKDA~r%=U?~+5Rj}p(3i3m`qPyJ{6nXUFw@N{l#7| zxywsK>K)e%%K@eFt2g!^Sj;7-7o(HBUaj;_&?{_|=pB?}O2`tVtC9?>$mdg~EKRyD zNOvP8nV}A^f<1&4f_9dBTuEW^X6|M}1FNwlyR>=RHd;73)u~p07x3QY=(2Y@%!Bd4 zg;*`GwP^1&hIJ42A}Oxiuj=4fhHFQyB@h^j!XAVZ;Z0^=WXch z#ujQH4?U~asWd~otJ9a(us&qf;>;Cjr3^@oSW~=;%V+62jX}zctXxMUmU5_YmFG}l z-S(0@U(T$;zj#M+D~xhE`bVmhbmu9)**nM*fBWg2u*{ZJzHkPgYda;v4rz`L5`9tx zlBkA_L43uzP0DbhE|$o6!qpn@nJ!(Wu}D`dYv>95YFjVA>Q=gn$dw@Qm9|UI<@eGY zeVSkO;;KfhDBg>o;_|PNCr)|$U5LhzPqQ={hG1*IeErY!Si> zd$~ai$5z8rz|;GG&McB>?cO>(cR{%+O-V(y`3R8E4$gBxr$QhS@>0Qx4(s!rQNP4} z>}F#kqVpGSUOGGKe{DE!COvA6hD^@cvHQ*q_f?P(1>M9fKf83 z8(6VO5gZe~SWkS~PP+FahQLHjfo*qlKQb z(W-}g2dll)@!(`7GKNHAcXi+Q?ce`b!YAx!)J0ifl=GwBWmkO7%lBDr;Em;v$oNk>JBbZC+1K;lGpUu>}&S&iCp3!sR#VdnTu6p^{Gj09fmzzSt2O?T!EwV&O0utrG%B@-f0(6xAyqc?%PP$E2Rvlp_xGl}?Y{pswu--&iiT@J0zri%o&EM6W6ja|98 z^zvy3TBm+%&3OgZ8}l)Bs^l#nsEr>%aT@R$0Ty^609+6uq0sC}=Z9809#+J90^*9( zLya9So1}6;Rb%k_0;W?A5H;Ns-EBvRx(j9nkX*%qggMH**_46c~-Sk zTPxe5-X0y=))~#@Y^^*69!Mon5C~Ladm0CcFb_6Dqgc1`lh7yuPOOn6u>eO4h?6$t z*GXK}fTm+xvo43(rt9qN%;dYK=F;>12DbtKpB#4Nqp^Vyohu5>1uN^b(<8<8xs@2z z;OMkCbO*LhZHOkAuzt3CEFvE;$}Sei_8QgYt(DmeIF92z%E^a7-N>Om_-FDV$lb|H zN1Ipa^bWt;)~g-5#X|llriZR!U;_nvRxr}(4=E1?r>qe_l_IS`Nbow)sWA>HL`TqP zdPNFN$h9o}P|&2;uhn#E$gLMb+h#D^td_w4w9o5>)4Vr{FyoYJzfd;qd%4_0GY!FtgCZ_F8y3o*v z`(DifLolD(zv;o&q}IQ=bqiy82%Z*wx0{A+@WB#&e-L+rKIAq*Pw?Xb&3Te(LRhE~ z4hI_$U7XYvV1iP52|FMh!iNV&WQr}DFgq=J`vX%B5pIa)6_)Q0t+CUEm^s0j1f7y% zme*sjzAfp)No%=kI(p4V+D7vD793kn`wd>hf1i96{wm)C`Rn%b#H+KR_tMW3lsnh zK!8I1WAi$gdCP&;Dw#5-joZTc4ddOF!Ll`FE}9m${-!n4KN1*Pm;?1CD?+yn|cKk0--l26FmR>wC}xx-n*8kej8SM?Vs>kf%S~t`}is^MJ@#V;D3dt z%s1kkNiK>wU!ID8&1Xn0Wy_RWx`*qw$5!S<>^n<(DOX*Dcz@8Q@WN-vF@;Uueh$1S z$K4t$wZhy!@`w6&SGBI6$R>Z*@B~M;n86$Wseentv(_-v-R}R;+~TSaJJC#sdS$$jtni8Uhd-OAtH4q6aGMwP_G4lo%Irij#36GuV9}+g6c#yO-{U$oM;x}K%wKO7CsP*$0 z#Y&6GBGhrQQBs^6CqOZFf^0;lk2@H?TP8^#^Z@O`5`;F@p*_o}` zrLkeZy8h?Ph7vkmSGQFMw+#n|wrB6idYN_SEeq9a4-O9>ytX=UXydSnd9!ycbk1MB zX7s8(mEzW;1AAUEr^dEmioK{BvKBH6I}-lRk`+e=rNjs`={!ek86*v!jiE5;%9VwNss=$`YlMu3!h4x?qJSQN%9&<^Fz5WD>4q zjat3N>JM}}Ojf4>DN#9($v)B3e_)f(ll7T9GXdtXYb;v5q2KQfnbj;bRQ58ZPS-VL zLLR5JOFg#hf;}Ov!Qsj1v^s;`nCM)%ccDM6R-4RWM>vZ9v`f1;y6_$$edOpae-=ia zpcnw{Wvr`J(l_lDaLKz(p1j6dq6TAS?V$mufAqYWH3tS=zTpGo*@3Xp7#YfCt1(lD zJytf=F1mSa?AA*rC-8F=Ka=I1msCcsKRjCAdFeoA@2+_go(c>8T7$RZC+}t;YLeVy zbq`|Lzm-vc`;n2su0fF$nexJOQ5WB5J@(Rw-A9 z@-Z;ll5((Anp}<30Z3fbbfW}Dyf$Pn_ol}?(IVMlakxCCb^WP@;i$c5zC5)}^Ur+? z*?_vVX}T60J=&L;?M?@D&Q7hv#6k{xG4Ho#ri<>O$%Wj&!6;5&_>->%MPw`g6%@@h zZDq;#-V)AmzLSS(Me9nLS}qQ`=pY#laZW#$=k}tEf@H1Erf0YK)~@NWbavWwrD2b~ zzdJwPq19+@X1gP|Yr4F9AUHd^?~>UoO()r`>*jOo3#b&D@15|aYw;ep&W0Y>#(>>7 z*%w(H3yzhoy2I_-IrPBBZZYm zAuj3sE_RI==AuN!VTUAR>*G6n<+)I1D$c%Pzcx5pGWYeFeSo8bS+BbOpldNVx~<}w zzHG%5Xk9@tP2XB1?T@57Or0J!9*o?k`H{|&tgTPapI?-Bff?iG{pg^Zh?22OJwqOy zw?7jc&g(>TxNKVbr=zih7w#I!R^4{kSpkF2p|hlV*X`KkcO+ctiS>!m3rCYZi#gx) zSUOnn_wJs@z9oPA@7bqN%M2M1POg1;4mcX0AqK|}IH=WMu$r79gSCGwTz@`=Y=QU0 zN|}U1?KTN6i(_VexaanNm_`@V^qZy@UUlc2cenuikiQIhpukLoFc*;da&1=W08LXh zHzGz!2VqdiR*l+0g#u7GEWy1%%obf+OXsGOYe(aw3yI>6N_-@qU)w!#@o;T+&F)H{ zpj_CW>tVupDOvVyHklLSJ^6(ZSNE=L&ved?0)@$mqSZ6GE<3g{kzx}={Rfk&-Do|* zt_`e>*QOYYR)Up{Sh-%%OT7@=Rt|JDG0~{V0np=@X_vv08YIkyi3f zh+!)}n@*O675n|wF}L`Swx5$Eyj_-qNBtk=auB7~nBpjYH;dnW=~D10Nx|8gyV}1} z3TAMS-O;yx08;QXFIfs=bbB$nJRjZbK$Ji{By7ZII-$~(C}9btwnPe3iR@=gbfg^h z=QOc|vHo%P>d>W^?yvuaY1t)?sFB1<%v(vHELHHv^YO+wq>5U2Lm$oW4ty?f3hoZm z=c|xQ>BLS^3-|1$&j;~+ihut`z8*XAxsQK-QwHm?{1oK)@9-JE8)uR^Z3*_BoXI0R z62$K!_9y%b-z9|i02L*L`xJNMr$%@$dw>0#^7mg`<@@{DtN8b2{Qfii`}fJe{}BKE z``H8aZ(=?3EU$PH-@_+jh5ZB14A+Y`O}S99uMJwpO~>3!fZL1Zec9mCao@)J2RdyX zjKRUg6eFy_YWQtJLja;pPpza*U@$b`Dc3RBwax;Uvm@aWj;@3Q?yyp&G?)!tzJXM* z5-}Buk)(RbpLN0SUdW7B+|glsDCIIK^_mU`vl?ut!RL z0RkU|tmCk*ajZOtdrX;3M6TTszc`wn)qZ%^RA%fgJ1TrfdOdW^R8!oeirxX0?bx_ zmc`H#7z^AiDf{HX_;-z}G6VWOa&G=KA9w=+cl+ws)Q{19dE7TE z-RDeN#fsIwDf;+#J-v zU!at5&rO4WC-FY=*d~OJg8enZM-?J_Bd*Wj*^lz;4N^qzAFLw;lwF!r0xP1Vu&S(2BGr$c@)X z?~sreoMMf=qA@C4puL^)pZbfQtTQn#Fh{w^*B21cUczQ_*!y;lt=kb7#GZ-qoYmQ3 z)9Ot*y)6>4IfKE^2I7uH!WP;%6d#V^2x$k0VuIh{ujTtUWYUuZy;+;yE-Is1?PEco zD;jm7TBo20E~glcpHpp>7L>M*T28H!$aTuZ1HV*Ye@NaTm>})*^c<+pxZ*WVwZAtg z$W-_48e81iq*`NdQtb!@*#MCpw}&=VV{COyUTwPGyB@jh%UYXY`5 zMg_-ZH-Ntb!v+LOqCPTM%0bh@Wgo6nObW6DH`p|4x`ksEqi}=KtaBFJ!TAt!9@N^R zBUp25bxJ{Lu~(xV$89#+QQVS16NbmCRCE1daWJrq35PqK|TcE{Aw#3*QF*i!cmOIXQZgwSa z?7E@%madyp*cBG)W0T+pXhyPmAN6BFuMPiMwgOCmx33Zfp7iD zKRw9j3~+jq9c2EQ2`*)sU(mL`nLv_i>l^rzo4+$5{5$`K&lP`~pccLesmK(A%XbU! zT>b_}7|7Zm6fQV6q6AQ4iWnlDA*>ZN5p$B@K{!w&mCj`vcd#$!)a#wGzMwn4VJefE z+8{jdOz%2y5&a+NAI;B*e8 zbfI-qzTyBjYN+T(xEDKgpjdypcmIrkX3A46^miANTXN`t9T7sagO0ILSGp2Z3##y7 z#<_OhIk+ytpj?(P>}LnD$}S7nj9nhQ$|OD-ILgxXUHo#l&!_ zVojEq3%$H?oAB(5#$i#sN*o5)$od>y^8k;V;WN7o`zb-6u}>aQe2nYZZuS$l8}neJ z-V;{ji8~t}Ur95TDR=zjYu5n{z?#*oZJv-b61RrGbn*q^dEkT5U;`g`W@j8HU!Yy> z#@lHXlEMSa|5tb;r|S;`SS&2p}xTWONe2X zp+i)x#GD$tvyw!DI)1W3{VCQJ`ob5Fe(BNBwa0P3M#;K@PYEYVkBATZ-%GF^y*BZL zXBtm%CM%_6h1rpFgP%_OPl8|kLWp(M&kr5HHuR__ctUt8SbtjisQ*2}d;JeXh%#v7 z7uW_NPV3xwKN_@3@6|AX<*v~0V35!p;Ld&0osIV_O3&AXP6)pX;lZG1l>LTzA?ZYV zqBRMTk3Zk*Sf_mqf7oyS2mj$&xJz*lzl+~9t6k#X!D6`sJK-+O5%Tm!NvDG8q4$af z6l2Bzp*)(VI{_y&6T{jUH5xb_jmn%oYPUOV4pC!P`Jw%uHeVKG;jQ zpv%Kf%(YtbPFBj?j2EE&DQAmwIUgSy9}psPI>;Z$2T2Xc$n8hYc<#*PUK}_99PHh@ zU%k6Kv*i^#cf4{(zI$rc9f@c)s^Hq5%y?jU`=E2!t<-jC1VKbC%T(CL#C3OU9z8G> z>)mu@Ai8-e>gs*vKY2@8k3I(DKuPcIEWCj!8CKD)F|+&gDNFm2d|%MXhRCjZbe3s zP)&B1osog5Xkt3kleQvGZq2PJl{e(=Hj||TKzks88=}?OXrh+F4GwE|&79dBA6_%( z$B&V@xt}Mi0c(gE?VT#C#+{5H5wka)vbzQsikTfzM{w3<%7IR2d_$ZRo`SbONu5(9ZlaOP zJ8GrBUyu;Gw6~i5M!1gWK@4OJd}7DlM+T}#@7}uY?hA)TE_uU-h3mF;_iVp@e$DZ% zJ^8KIZ@rC*8wsz;YTz`4Ild*z(a>{*RyQ9CS!){1qea3BFF_+Qn(&^&x(ew&}YaoTv zFL9m0Rj3}lXWQm`j*X5UyJz#}H(WAW95`^p^vq2M2L}({G&6m}fq^?BEEi=eqf4jC zAXkJ5>-rs~9)r?m87Ox}l$nSvMeg?V%x@&dossxt3WrxmTQ0sNd^RMBmAbwM9Iw5k7eRs4F7NB4Y~B|?vP`gB6n zCkB(Z>s?NL{VPge)XNO4H-Fo0eLbxHvrb0`^tqtuVQ)fL?#Cc8;BAyoKya&&#AGvL z0z&UAJ=2NoT!#LhO{a^+4F0e;W#+QknM`ITi?8~}GU-|^o#By-JbOm*HPJwb-3m{| zhUgZ=vb&g`EF^r#4&b|l0CNjCYac`XP9byy71hFtQyd4ri zjU(=oBv;!J`;=pg$m~D@RPQ>oVb^4FC}v(9`iG>~7PXhs(Q%j0y1o8I`;~&J)E(N) zwqJI%+B+~2IatfTcUK|p9v!If+b}x1Ry}v#1GX!vhm;5C;+J?ol0+bc$(?|=!kQ+i zP25AmbEWAm0c^6i@ij2UGb){4kMch8`IsrSN-hG8xUDq~0f#)@=j<;Hx65#N}s7BhXLsciCYf9TVD zKyefllkA5?AZrCX`6QB88wRd5h5?c>N=aF$co7^OmktzO?tadXV<&?`dp1m2{r18u@0E-u1HsW z%Z30SYI7123*#2wB?B57Kffp{jU9mvTNN1WUeZh-1FNKZJDX?_aJ7qH*rWEC=`oy4-JWm4og z2hxEQ8wJCq3FDo^D5g&chLj(bCqNe2A6aCrPgRbp4z91xBVN_LwPMSy=`S;X#j7l! zx4>jIQ}$%0l8O1Tcy{Z=Ki(<0%&G6}-aJ#ukMufR1M91^Hy%QkHun3EL#mPfjlhWx(x4ZAYk>h)SbU+ng#5X10Mg zKDTtsCeuOVI-IFA?y_#WX{o#;%gVNxCbed$yl<+DZSdB=0Ig&5J>|M}d+n-}C%bR2 zY3jN~Hf+z?hi9*y^ZEO}+PgMaJa1MQVD@f18(;@6uMF7HDwyyQQRf%F3T~{De^`nH z!=XUx5>v<#VJoSM{0JI58Bt_WG>C*pCyqZXHh5$r7D=z|Emv$4<%vycWo}6H9~v3m zUF?mTL)mC?)W3Foev?1$8{1m{H}*okgS}u1P4>rXK4o&)9~~@tt9?WJ2cKD+%BH7t zxdoqSaQGdeQYAVvx^98ZY)LY2LWo?5KbTn+*x)bF_i!ZfoC;|73jYJVgXx5205a_r z6c5B7ItY&_C^!XToa+*!5S>Ant=sWmv-cK!h>tL#c3wrW`@QZ^IJx%9wPETL_8+~y zy(#?J-`<7GKGQp$p6Q*b|G}FuR4<$qJT{v@m*_jRgh(K-TqXq2-7LvJEArJ_LhBx>VewG+?8ohm;=5Al8?Cpg#LngLxA$W!Romsb_ z;YBXM<$#7*6)O@|IOL<+N5Uk7a}4>&f<0~jO~Nb6z3yk#Cq6FBp7<1dS#@2K)feS9 z>tP{OiLkra(vI(Zht)Ea1K)TX`8SaLZ7rb~A^sp=?sV^cBimDd58Jc(bZ=%O*+ZEp z@#ib_?h{X}c=uoVyRmkZ?FOBqn3Q*frsTwi;Nnq@6Eswt2<2L!$VZjbbV=SNSVD5W zYn*nC1G|JK<%OWXauFBDcfUxW6^7l z-^yO`3Td{&L_h0cWBBunRM`r)3;X&)x0#%7ht^^?n>*gF^ZIp=Sh=t`I{ri!cV-zS zR#TP}wBmu~>!AfNg%*5fgghtLC{ltAT1YKC%TEU%S-wDsKaaWx^K2X7l3J>v}e&Sjie!#V7jvwvHND$y)5l9HN@O+Ry z@5cQ-7>!zZAy1!aJ&*DG|Ds;``LFyl0EO^s{{4$Tr|)+%mExQ1c3|?4Dm=^%PMkq5 z!29t;Iz=2MP05_H$Bb`UQiKivHf>`1d|5{5&oBB>xGwAqh7I~qy%#?WVSfOBhs;so z)2S1?16hkD?!w>3xKFSIGxRf%v07qotjh8qmcPob5Ppfb@;AEEY-3;9Dr#NWi5bP6 z>{r4pz9UN@Y!6p(S|Gz7u1`E)V7C{ZtxvFrX=O8PiTw&=!87PCeh0qC6Lu;-#Xc)| z(p~X5i3=5b z0yllGeg`|gMZSyXP5}z4pg>1EJZ@*LCr&k<+OHfCs`9V!`Bvcl&EHYkOVaZtD)L`Uzrg2;-Z3jAM2r!hNz73FBX~|$aW8ukOG8~q6pTxGvc+nC2|_qhbo>l||g@U)oX?d+#)J7J1=QblOyqhkFyd-&})p+VxOh#!X$ zkEU!#tRsu7qDUnc-c$cm{g0bi{|=;~{OfanVAlGt2~#a7?hwO5A9in^&}keNtfUvo zfW$77AT-}=ah{l^l%Xk-0A<@2`GLamz5^xSwu$^CDo3s>Rd;3s=Z_E0N0STnh2UVn zT{QVp{h`2MY=Qktyc($vrS|6R*`YhCn+7%}#@5D%c9mzRMmLQgERFB&U+e6Sh>_l& zV4#{AnTIoOaATpCTm*kE{|UC^TZF&RiATyPZp&YR>P{kqq3o0d15X7YS33MANJvmG z#RqLxhglFc-VRS#(6jKuhzol<3r%xQ8xL}y(8EEeRcX`(Wm&E6gM-KWc=<)J%$YgG}wE;flC<^dc zNcxq74l{Lw4`c(DTIhCISFD6`ohU_$&DW z&0!EUtOE@RvK@)VtRie84gs80BZ`!u(`py7WmO0kz^qId+|G`#epBBmbhoQqwzv;X=f&vlPS8seIK`IB#zE9rFUi>FoV$b3(YIdevKZod4P_i9~8{ z|FL15(YD7iFZTd{waXe*R$}Xga zo|O(a%Qzq3zmN3pi4)b0>CDEV(M_qs=AKyEtaZly(TqiFPqJ^0Zb}!Hio+YynGFNc zlo{6o(F_thl3Xd*KV06@+p~R$3FWQb#hnAO{A9>APzd!*g^|=1uHV3Ly?;lgw6(W; z$56CqI_RhrLb+PVJy;Au(kqh7zgPY*#?=LFMBPT`6x%7^QgsU5WEr;TR{ei4c4et; zY(u`dy#r2xK#T^Lc=2_A`^1=5j@h5@BmY4tUklQp80B#pqdd-j zO!rSozpusbe;c2>q~|HF!}Bl0XA_s37bv#ib34wspn(V)I0IBP?-DK3^wf*6!O}nn zYSCJw&llixjDNOq{C${zo>c%1;4Op(hU*{U_uVT*fdVvsjG&0fCjnnnhNnZ%qG~f*z zHRb@@aJp||Vd279{a3w<+4RP8cA=+dQz4cf@%yLxGGhU2U*2EedHU}lgRtk>Bk;~? z6b9^ephLt6R?B{99Gl1`hPt#gZsGX%dU_p3_9Tsm@U?W>4pKasmve%lBJp#uhJNHhZ)3I2d8EIWrUk$T-DYn91Iub7+m7okpvp!;X#*0eCc=&Ya&Cz$^?42a)^m zLu6-CzK(h7Y=XIv7dj#>gXQP66fci`E1 z-2W|MS-PL!>_C8cjc=*TXG5}^$|6iT?{H{31kvWVVq9}LQ+2VZQ)o$JS(Eq;a&>Do z(!cPlk64cz;{YlK-S7Pa`WR~1s|>+@DIZ5C5Qd@y@%g5g1dO{2D}jkRGpef{=qBVHu9d(CCB_9;CJn2 zqr(3vkY!5Ion%G!4XV}u;wfbf{;uIjw2DN%X<@g!KiTEWe2J&ueDj3YWgDm6;B^e8Q z{M7#|DjZ^0V)byMok@%ZfS|!+iMPoW#I7_AdxH6x)si*_3?sf!1}6@Zdf^Lw-CnQN zX^s|f$j|F>@G;)Z%0MevU@LqSd8QR^9!BtmDXtdE*g3zYY65hWyNQmup~}-BIG_e^ z(@=isHt4iIw*^Nwl~%pp?&=Ab2AmeXMJQWbogGe{EoAK!J5+k3#byrKiwMmbwOt*+ zz>f(PBc6D`)%aKFzM3@0{#s$a8*+3i9Nxw%r#dJ$Xe z*NN=f)C_yL*zXGu19maR{PLXQ!^;nzlF5t{Z&sTx@KDg<2+|+AcW?cK;w#JFm-w}e zLnRfm4zBY!jP(9&{Y3Nq>v4B}xeL`lI}`|HAnzG%<`bBC_>twgFbYMrCmXhSW5$So zz-x?~gR%73#M5Dw(dz25p}39d;!PdeSRjz`O8i&J zleGODPTIPkTFLAQ-;&X17nDdE5FBlMe~t4Fr$&oi9kB3c+u5X8#jMh^TXKUOnV4bD zrdaJyx5S8lk#ND79RUA&H`|Pax@X8m}PRqHjw%5Myj9g4} zSz@>1+^&)P99Iq!u0E3fCvDPiuO#Ev)wgV^jfN8^Z(q#Cg0P1@DISDnj8hye$psz) zMvb*&{TE$mzlB}zYFo5D)6=f2uj*@CE<9&9s*7AEu3Pb(WNX>jwDcThYrTX7f6nIC zGLht)$8$a@4&gb7A2;YZ``&XU+#*oUZ zPrUTL@1H#7sMaWUuto6>Ne@eV7KjNVqu8d04?03&2Q=wB_|HH{?1+23@wnF$Z~Vlb z%>mQ=QGBN)d77H_iMt(ol_;3~)*FTAcNjf(rPwK$ufzRe-2cCg`G%NP=3eEaO;wO&+u4jTkZ zr_-Vuw_arHG-~V)9ih`D;dyqO_#XJ}fJ-^Y^ssBhr4<}gIhA9CHje4x9J661$1Dgv z;Na3p&uI>CH4c`Za}ozn3Ek{X$ay{Yl^fuK=Zwi!*pbi?@^Kz}ved z-qxR%x25sSotd|xdqh3^p!hkwpKua|fq(Ik;{zYEJtFGwifrE*x*I$a7t@gGZv*mJ zDH)BzCj$fdE{|yR(FaWiJxL#Qw=Hlx z4!XE)nMpq01-d?enrAmx`=n>L>zcdTB;iV3V+Y;m!{c%8viBtXb{g5+KAPX1%nsNA zhw$F3#e4AHzex}MW~BnCo|Zk@>A3lfOakd7e2rPf51bu&zRTAiMrnwW&)XjbvMjl5 z!HCD{4+~#&^u(h5en)pKR`NwzESSv-EE$T^3D7*Us~!+vPv{5P{!+M6MxMxOBjovj zF`0AtbFq-c5w=FmwO}BF!${s_mv}tg=YTUgn|o$U3Pbu-JsT)uh^p1pc+^{Xuh(N2$wQ#u&pj0}Py@?S8w;&kA-56swV~ksjD=I2( z2#8CfqA}hqUSo`)A}TS&xFX_)i0if9-#OjQ4C;OV-}gS>`+PIc{HD5=Q>RXyI(53c zx|+-D=Ok7OP9gYMKl`v+aQ7O=<~fDcg0%uTHqQdAmJUXK-IE7B{n+qSt?){ip5bX) z;qlS2(4xf@j2c?&z6a+jQbcHt#a>uqJU#cMmHjgxU9@ird2Ee7Mujh~f7-E2Ml-sa zT(1^sMSH9Y=Nq6{-G~VYH=XDb*3-0SM{T6*TZfT*IS1~x= z%VOqo#Gucxv_ay%wb)OZK5p23^c9y>$oeZ$D(m7YqGxb31lpzF^?;yNRXuBplQ-d>)jCg-IWeoKV#sYr*atA8Mk`~H=+b&d!2(T-L6mp0$cZP=1C1k6c zwK=vB!^xDsVw@-uZICvqBhsAC=OnZjc34wdGzv!Q5sS0|S!HITPA%s%Q=^#nQ zhi8o2GgAxHhg?vyN83wpT+-rx#tUF3n%ZKCM=44EREQtE;=a2Cc@0 zo?33+wQ!Tv%hUUXfTvtMeB5R2mzzbp*KBBYZ|U8Di~-*Vk&+ zb@h0vW7Dqlf<4-GXwf^oM&|(UHviD1i`BK^%K`Q3csJbl{Ij+Ey*#~eQN_orc39_D zF4Y?Q)_uO+JArKy2R`jm`yT=ITh?;3T3kI`8isjzcIeZp?GWE;fwla?8rkb<)oakE zcU_*=uwME3I)Qx~X?4B)BcAR!abVc5vgOd&XCWr98qYThXi%+@n^wcC<}*H?_3)0> z7i%`DQ!A)u?S_N3;084txwRWw6B~S#xi$1^*cO{OTD`hI@7?&5T4C)Q)okGL!f2W~ zd~t2{7_OJ#*)%9O&Sc5HVMl)$FS?;rbwLXjmthSvuQlT_qP6k0)%SSDw`FUK*7H^W z$Ockd}M!pKe~&A6~&7CVjDszTkvc!Q8Iq=@$IX zE4_!=*d8>NgTNs^0J0Ezn05%Y8ezP}2iH*bcnfCT+D6y~E2bJ6VOQjN$_QI=HThXw z8u-!KJiGc|9Mob+3yCGMS zh(V|){u6P(Cl1e#P~LDc2LHo2eKd|+FpXr;9E}zBFXB5Clqts;F-WvRsYEdnv4gqJ zIM9qo>ICGEL#$cPKfssHQ#{g#Blq)|2mQr3gO)#bK8!;Rao}bwFcxXipq#)wbqCdv zi1o)6L6c%r)NO{T>`?F%k9l`@Z={bue za5S8`@OPqWmiX_KhNGN6OE(!bV$hm6@K3zGhxkM>1gOhHmshJQM?KJvpic;7>ymj9Er>=-{?AIih4z`_@il~FaWn=x@dm;p5spAD@rX75yMPB8NhF_MNF(h@ zfQQQ;l|tUw;~&dp#5EJYVojYzx?p*f6^o?Y#lNx2$wOAhvWyyHkn|Fncwog_)&k`$ zF$Xrbx%iJzB3AlHJS`>bVEx!7=ue#VmG#7aJh59}HjoYRDDnY#c}>J`61N2Kj-{sX z_?k(2R->i3Bb#F>Rtx#OY$;yA!oC3P?t-UY#6kI@_#N*bxhHK{pB4xYF<9F197zal zb*K!(Q!K4z8`)OAgy-jBp=3MxvTTn#rmvuVd9s5zEW>4l>?k8;lo%?b#ZlQwc9vaa zSBWz%`I_u5dx*KRr=TZi7?q$_LKePKVcK{ z@mltG#4#~U4iE+MT{#dd8wTOA_gEPxhsdFFm>e!g;QXPjd{4&9kum|cqn)@c6U8n$ zO1>|XvzJ-=ekl%?(ay#CuvQy^BU1GA_ zEhftEao6JqxmW&6{wROKj;+6lm2#i>Kx~HYUWL7Q6B`5Um%oZW@_;xm9?0M1L9F!q zUA!d^$y}T<9L8#bDe{OsDyE8$WWM-N9+L&~xIBTk7Z&14gH!TPStS3${M!%f82`p< zth4f*_@{Uq=N0Egj=X@?Ud8f~ct@6C<=17gSNuyX6U$|(ydtm4Yx26hf#;HL%5r%N z?<%}4@5sCIp1dy~$p6TPQfUItG9GTlKFqFI$>FBCYt=LltvYs#tf6^nH8pRo7Iw0( zt<}N1YJIVPlAl&zYoImM8ex5W6YVLiOMY5wsy&0dR>rRT9y^#$Cs|A*EJ zt2S-eQz=Lb*6dmc_7@D(TH`%!ZMB!Qc39!rUVBCBfIXojw2oS&7NtdFeO+g*i`G?p zRqLj`hP669w4T^Cs+aZ#-sbkE)<=6w>#OzC`fLBx-qzmH259eU1GN}!kTzJ0)#9`v z+E8s6)-#XL-qYf>ky-*)iH_3V*OIi+S~A|FIaV8|jmQ1>iP|J>vi5;CMf*^jigmKn zw2!qd8((p{UwgzjA*JP2uL^Quvy6aF@#$cdz|$PpAR>%|;e!(JU5= z3-(#E;#|w!QVmybc8hXxOH^2@TRbf_EMCH5scG>R*l*b4W2ue195wOOP!q8c9_wm& zBCEtt;&b@4R_yNhilvUFuEp0<&*EpPZ)sp@h+WRMh^>}Ju;|sX+u#5(P_!2vu(#?j zVjFfk`VQ}E+91-!9`OVGTo19<(%8}jD|M^kjX|G@FU1ToQ+#ZB%Hof=-4|P)7SqI! zmZp|xEX^#>iciG%Sdr1(@|>jwUI6^5rKRNsvA_~w`G=*IWxGr{J&YLZshKK!)m@mE->oBLJVNRSUQY>w8rD%y*EY^rcxJKkI_E}!G zw70xs>0k-BL|8gnA}vvtXiFzcXG<4LSIetnr=^?aHA{C(4@*zW>v-hv4NGs!o0dL! zwy&?HpQXR$pO&{R?^p&{-n9(0#9&R>U`wne&N9R@)H2L6+%m%Qo+aKg(vo0Fw2ZR6 zZ%ML@#$FI(EMqO>EaNQ`EE6r0ER!uCV9o9+_%UCJzr+d46!EwC1N*L=#QUp?#A(Zi zmZ_GHEYmC>TRyRTY7HNh6gM``ZA`-Oz^L#j{XZhq__rDVLB_wG{%wIBBlY8OK1R8Y z#<5W@ytQ#0Zu}c`!h_txN5%|JN=$GIPaK+<5cgj7j)R9M4IVRcNPOIQkB+g4$uWZm z$0a0NBL~MI7ygnGW0I{=%%e4mBXyDy2CE&7DmxlgbTnA)X!nS!Bxe*cxQYmOi!!)~ z(z!6wxfQm+*3ooq3ycWU|D&R;ow(-eogB>SNkKs#ovPGg3yiY6bRHCw#OXnSQPn#; z%4l7>TDv%jyQ3kVj;-Cg7z}mM8M1as9v&YXSG}vFu)zzr!4?=CYV8&?cuaDfwOgf7 z5F6pr4eVIE>q*_6lI$UtsDz=`9!}Lq8X}7{>%?{Fl2(3qq#BjaPnB)j#{n^?Wa==hk?!*mXN8GlZUhJ;$* z;MnSKJkt1}K!edXQ7*k9Vry@wVxi22EhsSD`etPtZGn-&I=4|qo1#Kp-*mJg${-kR zNFvH?lhKx_s2XopX~UaFH@&HMlk1yF!y(i+xwEWq>1@B{V7uB|vBTq%;zkc2ZGCHK zQq0)6>iryvI%CmxYd=mh4h@+{x3>0YavuFF^~)Ml*)R+_R?@&IT}kmDs1t}V8XOTq z#~7+ccZBmX%9%r#l^e%x^m5@5#=k))JlHM9Xl#rzR0lhF_ZaNpDwcb}8pq68<0|_E zJQ+N8H0tVT(C%pP*fGQ-u9A+yzQImJgj<|ZeO#3>%Iw>MLO7o-sEyw3fl<-cpQ==6@!u89uqk2Km8WpqcRkt@>Z zwy3}wiB+^8S4qfrS5I^r!AW|1lAIU~3A2vYn=<+lK?DW1ag9$*7&_V|nZ|FjQ=w31 z0^>KrI;OIP7{BJYHChxE<~qjF0$r}?d_xXVW{ZrLL`ByaQ>6uCjJ_LVjNdUle#hwJ zcbv}jI0w_!##N5rar*e3;7HUJBRa%7!Fl`|5|3_Uoyg=oCRWuwdm!5yn>~4;fJis~ZTggG4`jh2-aIz)2eNfRygp^x zgN%BEjCzBN#s!&;Gx7%+{015P1{r(>8GHsA`~{i(ne>hNgA9IxOg_zg=CLUUvmS$A zn?c`Z)Mqp5u|>G_8#8!56f9dr^80_folg+NXP53uDOgGIqHUy}fZX6o|*6kpU4T0)Dgw3w|4){0t(0vCS z8+_>A0*;OPbuYnYk22b!dl5J`%10Ue>N7Or4LL*^<)aKa=rgy?9&N}W+9)4w)EjM- zi#Ezd>*Yd>Q5a&3z7Rv3>Z*cdRuhFpUUeF!lPhcO_74S9zc@(VWO&0`~fu+e|P#=y24 z_1cYk?MCD5X5)I6)DsfnGJy=ogi6B^VsLIuX!cN}#!!P2mYuQ&;lX?UZ(~qwV{p>O$lu1u-^OS` zxFMPFU@P1J({O~C7Bt)-6K;^v{W%y6gN*Jk!Y~+QbUzWt1{=o7jy=LC4+l&)90uJ8 zgO2Xg!2lR+>E~NGHUy@hkKx!5kbaJXW1|Cfe-wtm;6ry=aBT3QyDT_1_|Tmc92@oP z&It^HQC@dmaBP&1GWa!IF1zj$Al)b*W$>-L3@{uABP>& zqbjYaLC-u6H`?>aF|4Q|&pFoEVps*eZF5 z6&EV#FL@jtxhD@xilbw_<#6VP9HsdJf)Au<&*>p@SR7>kT9GHFnyabz--3M zZPN#0kj-!;Y~ePoV=wp7qhbbgB^ZQ3VfrkH|27_j6X8I&8XPk^&UaK?((uGsx?F;D z%vf$#$MD|O^k`yWa3UJ02dGf;5YN$=0(1(=Luw?98968}Y4q@+lvKlvX4=GRkS)+w zZAg3~Tx6tkOkhxib>#2_y6iAIZg65ktR5Q}Tn)1f&Wn=c%osM8L4iTx9>|zHoNIT9 z8k3Z$Q`X&;Ae-)<;Gahg{@H2-+2H0-yHIcS!DEt=;@}D+TrD;+9(Qqx8zO~&59Tw% z?g)lEf{_ST9}VHe<0Rb?-MTUu9)+NF0{0g4h4T}gKi!B21?m1Y+M%~6$nYO+5g{J2 z!^aMfjcXMXpX@d&e#~f4awpMHp!&!$@yWwS#p^ul`A8q$9}^Q#$q9+cX4qqBQXIs_ zZHkW@O{HrZQDzoTimyDNy3BgKVn&ThN*r&d)re0VI(%?UJjjEL2lT*{A)in|8wL%Y z;O!Ky>}p%E;RpxY!m9Dv3}vpyComKup&*ZL5`zubIM_Hj2sVTgYzwc(CsIx&>^0~t zC1z0KSg4Ch$MCs=10y_fZZs$^K5?8Q(Qs6;pOjvy;dtR)Qu=XlXmy>LP%sqe6t+2q z+c<@Tjc|}tehQoUDQxDaaImLNzcow8t5O>JVWmj;(8wC$d=SJ3-Z~Mk=+TJ4MBdSoR995`OL>21cT;8j)1F zGRQbH2r|wrf^^3ruw!eUpz+Tg7Zr_(6b3UPHfe$%k+Iu?T1E5<@Azs|?^a`DlEUMY zZCHHFo?h8oV zWB47Ihx>XG*SjwRui$Q*#8v!Tz}vEpkhnjKbu+jpYX`QG5x^+i*_61aIT#p+J(MNg zufz?oF}PPL=`JO3vitz}A)ZK;xc@i{I0w6sOWa{x09+!M08?ZNFco(eCGID#0cOff zU^ea?O58Er3fwNY19M~!aJT#(h&zP9ee&Nx-1`F_mS=#)vKV*+YXv3Up9A9V91!oG z6%u#jY5;3$SV4;%Z76xpiwE^NT8!%i82jcD; z5ckx8J+BhYq!j^iFA0cyN4VpIyF$Pa z%MkRSJ9^C<_f8}vN%EAq|6`G@aoo$u>9{=~cj9B*bfUN1&~qTTlZ|xa?nx{t zSwOWTR)D^087ykjJrUe%m#8Zeh&4lu^mH7KI^c_S9aMw(<=^{%J&=3&X#TN+<0noQ z{&DKhqQ6f6edg@B^A|1_Un;p=dgbc1>o>}7mfxzledq4I`w#x}5Q||gF0NKL_iET} z0V~hEv4dWny1w=N>NjZEs4@0Tdb;T|&7N)E;`tW>TG;}Fg6$!pVXfP~+`eN}bmy+G zc7Oe?cLv1{pMvU{rv_LFLC-e!#6B#sxbvNW^(WJWB{I5OZ_#e>grs<}Dn2GT0e7@q z--}Dax*f*JjKfC8B)umRM!q-lJ>0!=VTyaikNDR}x>}NJi5{{P)Y_qkM49XbiQ?{? z$Ur_ll!YTbl#N`(myRTF+~{RK>7PCp)Z>cWGldJzU;V|*h&MU|;X(o7I040E;U3;U z%3n-|$3fVO@YV4Cz}I?o1NQ3i8n8E~;wmb_LbiwvekfdR6L;nCT4uZ)nd_1*2DTW8 z)c6*CfJrT;3)yOZ3yhZ*_yAkAXlCRms{k3mLJ;2+mM@Y-g?Ql~KfjO?koEkMe@txg z^Yb~j>n$e+lszBRGWCT+Esi{2*y41{(iitUe~-Qa*5^H1)p@?ji(W7Iy%5>5K|uQ! zyIZym82iGg=exDs*W&8)Q~weAkI?6bwCvyFfzSe55dFZefm6_fw4Sc=qZVCkq2_{{ z4Ht9Nucr@GCj6Ix+Izk)C8@Ft-S{8npOjS{cEl3>$3DInXjk{j57l3J;1p213Td42 zN97g|RC|qsz_SHHTui4URajh^$4C|Gu-+E6x45}FdD>Bz?2*K>-V!?5wuirmY^RLx zAsdH77T8V&T32saC#-J(8)S|jyI@MSf>(b zWg4d_7f}hc2It#T>{kAcu=6)?V&Fvkh>$~wA7M`kOtrV>ke=2X$0>oW=&R=-2L02wHGe^qN^x3BU~jWj;KYza^yOpFr8@100{7WULfeOk zm!{h8gaidP=vmk;;-3|R4o)i{5HNJnh}gi=kl436boUCi+V%G!`pKvf zoqz1mT>rM|>-a`DGQKmSAN#JJyxu-$NUweL4GRbhXdnIfH@vV*yY>t8Z$xP84y`-% zEDkSx^xI{3`wRLvVo}7scG2{GDfFd@dZ=A=hsmwSUfKhUZk^q3 zM#nwv4uv*tT^#A(&l(yLIW6pyuur18C3wGaASrK3?~FUGz3BUe@6z-YbHe(DebUB1 zbP)YQ%0s8W{C3ER-iM;L#aiE5(7Aiah0v_9>@e8tY#V99=2=@yu!^DqkJ~%uXvG*V z$M3=4`_-k>`HomUr0x~MgZg6!m-+crw3)81o$aP>74Fl^oIPd6>;F}c)tys)RLS4H z)ouJ=IfZ91s(#cRq+R=~NL~N4y}GNHrM#87>YsYMGK_$#aL`Z{e{oAxv4b}=ugd-V zfe@g5_w2OFxI1Sm>sR|vG^2P`_SYR%bmkLv;p{;wLv2l%{TiP6^4&!5`&OrH;0!8)w@QZN+7fR&s_s702hjO~sA_q~G|fmn#1IjHM_TkBi1>jZlT{w zsoyBypY6^3&Uu~`CL7=!k1-iaHo-Yg_??)gAM5ezlJQ@lk8w&y-;Iw7xGf1eTJOev@RSykUD&itqX;#JRQ z#?ilW8b^?IzPfk1uR88%qnQ^rxUxUhPr44NTd-Bc;7-Dp&6lvcF~mjR}5J3Ak}G(74T4kaaH(e|LGJ}F2-mMex>M-T*wx5gfHfjqHI5P0ROq!p6VoI zv>(5F(Ak%=t!j^<+ku-f3!m}iGCZzM4^s!TTdK>XGmw?5uH&Y?rW(z@qJGjjaOjJE ztfTtHsjpyjDArNNIp^bHbwk%X=M>oBwREg*Ki-EzJh_jA$o6xl;T)#9Q2mAZm*z&A z<9ziw+T|15O#I&`$`uR~_4`c2n2 zb=izD{yi{H8?)sjDLDK;(>J1H>YGmU5q;AmE0OfA^5BsG<{ifvtn^V#yR2u1-Qn_j zUeenq>&PRdg}SHPUOgW3%YDoxT!+KfLY93n)&t<96e;orDo7s;-GIOHC{6vC^quUE zY15tOpC^~Vb&4l`)V2REn30=ks(7Bo}$^B_yx!_zL2o)zrH6-@v8b<7 zUh0@rd+r|X$$4|rbU7IEG3O&yk$YO5FNjp7$F5f?2YP0`>aI>(-E!1XS#NnB^P(R+ zwePN@UxfD&_RiHk>O<5A*_h?4{-ZCP%T>zO%yr}-`q#=wtqzP34S#-{i<5C?_IJvu`Df`0l9VJ=5B zk9XH=&i6MtfQ_H5Zaer_cW`Xx_m55#BF#AWbI3%eWuh)0s+VFMK10><&S~c%?|b=EDeU}F#~u2s?izOB zr-PgYvYsO9=sZ(|x^;A-I)A)7IBW%zV--Qm%Xx&R&?9D@$g$7x(#a^}|< z<3AH$kd2cq)60R6YsZt-E%afPa!&NjJ}7szt+MPn%4g;^|3Nd?AscnukV9@-Tjt@e zlWm0$P5PzILJpT82d8olx@K+WKh)L(I)5e>Xvg~=qc(K$GD&v@aC|qH}!Gyq;k%5%`nDLGc)`@{hKz? z9AoN=W4`;J%04Mwl@_e!_*#&kFKi_~q({`p9Pb3`7bjb7q^r%CFHanwsETP!9Ieo^ zLq|8EzqaB(8{@c2IVXY$stVZA`=-uOn0PqKeCYpVuaWOAY;u_~4(?Mv*o*sV(XfYX z=hZFP$-6X0O`Ray^5}=`hyGC&x*dTn=J`vXgN-zE-n{CVH_dYNuP&S86=B#q{^c6} z1KU1*E;Y-Lj1_dkxg66m>}2H}%W^kM>Hq4c*&aGJ<%D{TF{9@)P%jRoCrQH4%&$*pSZREcriP`3?wo$#fe zeSS3W&{#LyVf_11+Y3%r#`~e&&+W76 zI`MB@PlZmF8Fr~o0d$jS9M2K2;79-B_!6#{%JJv;45Fvk3wd6Ktug)D2Wa=%gWX_@ z(6>mt?a+0-KGap<)Q7~o8|f0_b73c;uL5>f6{j`De8DuB&Mm!PIegQU6WKGPFDqc9 z;7d5qF`Sod&s7@pkAGBg!EQ$^jb-HhH~7Eluw9OHw8fmWs+2>#;lof~l>>b)MSZ@o z?L}!FRBoDG0kd~Jd`?YJY<`0reK~h{3-Cf7vqD^zsyv` z9nj|ivb*qE+N-}DedfsZ-$JGhG{>A%-`zbv;K`*-dxTUzk3hZ0$ae?N8jW*P_nb>p z+o_yWStD6pg-k2-aY?orV;wqR*n>Nd>x25k$(EGE*KpN&SAUY)RU*X?)z&~bvr}Gz83_Ya(8+zMR1^O61Kl1xi zNjlGZ{6jn#zPk&^e#xD_W7Ap^xrY z=wZ;JIlPQ?-L%1u@k_SH^v5dmSNiB^d*%G;T!!=&W9B}!%aF|#)Kl!xahxx*%uJe( z{~P^i+KDIm4`mK{RL*H2>`J|L242KLaZJ z)$ibU=D2{Y+yC!5a?L4)10z!vA1D`o>TO4#nsoGkhd)Zk z=6I-@hmO)ZJRbLWjJn{MkJMlAz1U9xU7V}He>hNiesB8xAfYbPe20&(F{k~l&z0O(!BI^j5S@b0V6{%&@a<`~UL#LsjmKbMLB=|96jM#vUw@GS+}1 zjFpVM_7rau&DfFgW5&UZ0;D@ukwQut2k^_suL!@Zvq@cb9(DlHSDU^eYG8Mc zw}db4%z;;#46+Os^?ChiL(w=R%d98kbVf-=dB(#`_e`J6Mw!hsZJBK|BQv{a_RSoW z8J{^eb86;{%=sDXGnZ$s&)kx^8!KivscE~XVio6X=)0G;OBMdG3P<sc!8q(B`Q>b2K0;8PvMFj#d}JTa$?7H z;%)mJ@Nr}NSmJH_SWds2(~Hvg_#(ah2P^ZLf%I!Th9P~XI+K%zI{(@=2z8#{LoMC2 z$EJO)%D@w)U)VJj>8E#JKzi}+ElB_AdtdEqwC@bkv(%@%nnK2eTNv{g4-+CC(%&@& z^>6t8wDy-e`F(|UT3v%Y&#CepyFuR{^e;dTvmo0%(BF?R=zl*JZ=9U3?(UqYZCAH; z^`-WI*A%U|{;dtIDEn>!T5)OndgS_2-9VYSs$zFB%AeldK|7*8%}If*2@464>!>=o zb3G(aSf~zu7YoS~-cx^nmkeD13OCTY30kw&vG2Nr;`N+^qzO4awOtQ?$XO0e5j*{% zDL~F^);o&o-L;?VZN~LBLmks{Qc(x6O5RA$n?iYa-l4p}LN#u8B=Q20ceXmR^B~DC zr!!i;Xt$r1$Kk_j{Pw<(<7LF3rZx+AZH1`)#Slp7+V1(tdv(_-?H5(PgKLki>JDk6Dq1Xq_r!DIq?A}({@FG6T*DPV~l9Q&Jt?D&Jr$H`fW4xQU!3fy2uj8 z{0v>W`KC3mjG>9og}abOZ<;0YV?ZCo~0NXbKRzv8#-9 zBj*U|#ts|GK?*zeap`?rdLNhGhtjim&F9klxb!}f$j%5uBGtj+ja^A7U6Q#2(h&%6 z$7s_~?$WkFkj~YdL68pNzZriQVn-|J(v_VvAfs!$HS9o*afLcidV3@|z_}qfAl$;Z zOC5stVZ3eGLE~*(6O_KVH61)rp2LKoFgx@Os;EAq>SHFHQewdfmV_w>T za_ccStw){fQRjNpxdC->T7Kr+7HE0N#=em1 zjV&5kuAN)Z{zAmhRe$gJk=nog4BB7v?IE#&ygwQ3OGZ1AUBFIbcus`(39Bh;f#2M%2guTI z+Y5d#e>V~Qp5ELG{GQn1qs>u8TVlcW(d;5<$1mSD0oR3ypRY~=kE#tB4`DM23)Spx z-JxNNwjV$XE~3;u^)2Y!C!{?e*EYw`hF@F!BJt~P{QBa55PtFajm2*&elzf!kKc0q z{Dn-ztelpQUlD%TX?9(fa9OtlzaNpl1;6#iKlFd?LHr8vJB?one&zT*geJJ-=Yt<; zu4{xJ_*n;j)`6dO;Ab89SqEO$ftPjQWgU1~2VU0AK%IB+4}MU#J|t7N9W#*-TYuD6 zsMDJ=Fjn$E@xfRj%*Nch03(LNuq@wR*K#n%PGj_J-&#uJbMpyUnJZa2urh@IX2e+C z-UlPy6_^J(cg9E}3P(iKH4n6W#xhk7I?#*nym{PuTdJ$FtQ=S+)B-OG^R2cC7Umbc zw@t&|bjJ{LQu_n8hMo(eH=DR~n$(Vft_Azn9fkLh59$`|Uv~`QliEqlgwPc7dQip{ zWjvXx#6Edj;4hi94{2umc z#q)dEpB2yV$*1Z0J?x%}=l8JVDW2cMPN#Ts4||x>{-S;8$vy02iYNE5lj(Hq7&-|1 zg?@#7OY!_3_ASNpd)ToQPwrvY(o}p$(vy2Kfu7vMo~3wlPmZQ1_hd3Xvj?hpW)FLp z;+Z{A$1{7_zZB2xp$N9j2|nNQE@$zwDgn;#zH5@qSFsl=9Ylqdx5R>wB( zM~O4pnD^Bs#6V-RpmC~V%K((vq<-D}GfLFj7>^SBvgV=0ErfrAb~ZwZtLaq7i7jJk zJZ%Z!Iw~+8sE#=(QM#oBC16#FDnjyVo(Bz5(|YnfyTwGDWFlZp#U@vXFP!1$}*>mYOoBK zve(Ns>h`j=B#Z1UY8mhl$HO8aHIMNMBUW)_uNNNJl~-&m!rr@tYZ*5%ZexUnLpyyM9b!&15 zJmcu6&SiZ@v(Ba#sFSIJubG%1k#DjO>?YwxcDnL`lK>S-`IfCfk+2p zPk)t}J_=Nnc za@|e$N3OdY))Mvgjga@!I(iQGOO=Unlcu(#?Q7Kf%s%J?u`!j(W!X{gm&}JKmz#;Z zot(EV^6s9Pj=Vo?XvujY8{~CI-ji!jao!)1*H8T`;~}{AO-+FuYh|=UYmehPj{-kq z!TXKO1u|BZtT=@gv29fmyu1$?PpXR9{l{hp%A@LFtGu4SCV??B8bYlT>`K=sJQcph!o^P({ z0C``|=mxonLa1AOMNsrYCuoMnvAs>{TTxo zaR!P!Z5VqpzRuW-5oa?2mo>W~sZ!k=SXspL+!9EtaP0`tct_n|xdeUu+~RWdal^59 zNC(n4ixv@$ z)rXiyAEx0$G*(?;8lglZEgJad*`!wBqE3Luf@l!mzEYXAq@yvf~$4 zVeU|8SNcF(N>`g$D(tfaeD)oUae7EQfE+GHbiq<8cn5U_`D_4P=`>Bnnch>ckb)Kfs%pOvdfe^YbFvaG!WZcePbfL<#{3~XugEc99#=+pq+(&dHNwn8Mg$6Ia) zL1}d#wDIoJQnZl}Hfm|9mJK@yk3@)7J3y~+HLi%E{?pXPH5#3DtrMWvR^3f)04{Ua z-UFApt9OG^Ip*MZ)r~cK$@@=jKw7Yr{F$o=!~0)>GgWmdZ4vb8@)|#Ic67C`2Je6L zRnWr>w1Vv4$**aSC`!>VIG;xaOz-}(66#ZYV9mfYW~sFCXtnD)0r^#5;05`4tQ|%68|AhFyK3JG&$Zuy z@0M#;5v8?bHF&ja_A|Zm6)nMEQQCLl?=aT8!@^>2*a*7;lh2q%2>$*|^#Om76!4XV-O#WCP`bYQ z1X_9v?aftxE_qu!4V_CRdaGM9e^*jZfNDijFHpUPFjoGo8G%-pujVtQG`t5u)LKry z&Ayf7(ceNCXG&|mLG|jgQcyjyW&}}PxPS60GP zRK*CVs~^(_5XZ}=fSwz+@p)Ict?~q=U9e6#n^`ssZ7&AmY$oM8xID7D0lez+0*29pc@bEH(TlMrqc>wMMqKSf9?Y6R z*aTo0A!x&gNcoI79@PXK7k%9oa=4k=f;e9N031J9d56aM>IdMvY)J+2y?P7yE(GFS zJB7~9&a89+-Zy|KuEZqP=JkXkTAC6aAOFY8d*DFpH3D zOQrr>4KH5ZUH$+y6s2HZRDY&4Kn)jHqW{oWEl|VJReMR^3p1z&jNIkoOO>A*P4y&x zf_hFa-^LbiA5mR3fT%8QNoSZT*=WO+CAeOoZX*U~n5npqrG8!AooYy>>jfoCm!pQm z%YQ~2%2xC=YG_9_tT@LtH02t4aSgGk;Ra-h^M;k4Ttj!NVQ~S~uw*~i5KA?zDy14$ zoZ}kWaSem0hUHgTN|#eL)bRIm_QvMsqlVm-=OC*iE2dE!mXxE0iqv$rwJxaP+EN$l zhgCMzkhf$AYRF5~P($v@Ak=UHz6Gu>EMJQnD&T+6?mJ>x9n5!xu;V}vMra7a&oQ@;c~&(#5^d1E*lo;nyM$u5|!0x|e#Oo;j*~QHhw#IFAwc z1QGKM<3h$2jH!$(8CNl423YzTI4X`W#wa?oA|3rem_>;5!Ir6`aQ+TNJ?QnN>q%lj zNDK&x0UNSef=%|IbIlbkA+eIVqi|K@+#=l7rSl+2FK2lYwAgbw$#Cbw zwXoG91!GW%StrPESTP6E%Uo`!JAEj18l?)swVyhRJDGLk8 zOIg6@dh@+xYxG+)9zRFO1|SSO_BCBk5sPC{|H&om$!}S51Xoh-tf2dzKP<&nMw&^e z{<#`zdq-VP8A4+dUKZx@S*;0Vw^5}i1*w0shcBy6nW~#VrtvvrQ<~*hta~tovDQj z^0=H_SG!% z$(GHbI)(2y#c131rDQvQS^5dlUbY2oJBjeSkkWKHS}p%-5WGdVWs_m$iWhu_cG5Y| zcJ*mWK5X4@OUa8YTkeV0{)|+32(VCCd$2Hp#=){i)O*YNpq;yBZ$X_>T}ml~$4h8o z^kDR6^kJ;SSeMb4u^ywJ${$`Rda2uATo-RJVqB*TWXmy~v~<~Y9^H3%bl>68eFu7u z(Y@H0M)!ixXml?+LZdsyjyB&wOuDL=Jpi z{+Uy0yf2MJJIlU44XL`#kA+lgEa^|<9eFU`ff(-#12DFrLra>H4lT+c9s05yb)H-t zN;y0)4(pFFX=CHGK&?$TbQ zRZDY7tB?w*<#Z37k6evynb1`Nx(PL>cWHn??bLiQv#qFS5 zAxp}jTkYphgg^1h{4{t8FCjcY)L9fj{^b%Iyj{<)9>UvoUoxM@?ZUC(<?X*O57WP=CvzKhkxw zg?mZo7Oq9hOPA2KkJ5z=;2|~~dYZ1Z!ZP6M)8aXFwz6a!U1?pom#(x9y#TqCFL?<4 zJGT%vgmdCb>nsoQB^M=-w$C9C`r;y+c20=dsgOwFq6E6qI^&cGmSa`v*E8if#_^03 z7$-7LVw}wQ0po{^QyD*E{DkpS#?Kf(XPnOX1>;P{S&Xw87cwqlT+FzHaVcX8;|j*r zjIjCOa~&i6bA&S(H#2Tw+{%biiPRm8KQjKrxQ}r^;{isjT0qXjjK>*IF#gSm{eh5r ziLr$7Iw9V=1#fZwTGHijPLnQw(E@#$ySO#!@`3`=I1>r3kXjH2wM%@!iaNAgui9n z!HDyuxlM5&hVVy@`H68K<9@~ijHLg4=w1zE0&5S%90WYf@y8iYF#gSWh7tA(rAipD zaQ>@|*C__x#1`CdY!JJ{d?-1`g#9Iy*$VJ$HGzIrASK{8~YJP_y z-e8FPms{jE{hfq-*YaQV7DB#f`KvrY?;AAVG)Qk5l=)TOEqEGl6gFVK8f zp#E;as_zAKycMt(-UwJ1Zv(84dzw#r2Vel+`WMXa`s;u@njN)B+|e}O?C1PmzbC)Z zFBb3eqqq3!@9;C<-)FwPZxY_xr@yg})~nDv`ph@FZPu;0=0*utI>=qny*u z!&<%~zfvW$%H=ND!y6bgIc6J&cXBv~aTmwzR=>|%0IebX zfn$zx>Pf~z#y=P@GG1dWW4y^&jxm2gK2%q}ip7SwKus`uGS+0Q1utWPR-42AjLpPT z3xpQH;ns|87+)i5T6e}Cj6FI2b;e$deHr^P_Gf&X>EmnB(4w(TlMrqc>wM zMjyu7jCB~p7>Vn#boB#>XZz=s$T$w;Iy7R25n}bs9e7on7`HHD{LiP=xOubbO!eYd z<6$Sye_0GWSu(Q_cJd_R^El^W#v`x}6|kANKrNrc$2eT5?#xev%_KYz-&TkgboXfH zAe=1_V*Uk2sRuJB%TA1)8M`ocWqg&f8)J9I9*nOu_G9eN_)o^S83(9}fxR&U+?qX| zuA0rAfvckBpN+>=QNm$_@F~RC@iZsQIfb^Kn@MX>uFZOg)j?%*W?^*@;lCN-SqoKDBeUkN6#4*?xHCxgT|dw{&Rk40z=fR_floIq;n7 z&RGP{snP6qkfrO~4)C0OXFQO0l`|_IXFs{K7RXoBp0E97xY{!pSM1cuuZkg$lkX-# z4{~QbpgX>^+tD50SqtcnFJf@VcWwu|Xp zGsb5bpJQyn*oyH*#z4kkMmu90#&E_+#wf;4j9nPJGQP_A8e|92S&GR80tVuYuL)G3Vc)DZrNaT?>tjF5o{e8(pI8J}is%J>XpGsb5b zpJT*6CCHC=SOZ^V#CzBg4ra77wqXosjAV>r?8MlGu`A=NjIS~FVC>1*o3Rh$Ta0}f z`!V)se4B9qfq5M~u@LKW2Ot9#hctDH5wNr_aL7LJ0kzJwUGI z@CL?hjJp_jtJ7}}!aPFw1IHX<3b~AVj8_=1GL{SR}%c$*Qn4Kc8zKwP;5-e-J3 zsNu{V=)&mAXk~O`bZ3OELY^9owdmO<&6mUV82uRQGd5ss$k>FjDdRJY&kBz(f7F_D zxCMu891dg*;ut%JLl|KlQExcMM=(ZlOee-Jj9nRDW9-h@gRu|eTa0fr&+jk}U>wNt z!#Knn34~8GLQV)nPQYf2&oVy8*n+VYE5=!jvl-`LF3zJV8aR(}KI1ox z3mI`Xfz(vSm5i$x(?r7=eKC{OoN*BC@SGliYatE3wBcF^VHRT^*61`5hZ&DBV)jH# zK4SsnamEvjm`Ra}nH2aZV-e#y#`BC9880!G5X#1k7<&l&Gh)0U3=IO>7@<1|2Qfl- z5XN`|j>LT32k&erOk^BICN)6&p3f`BI6`R>=A`LA25E%IF<1u z#!nbOW&DisbH?e6Uog&OoW(esajvL7!zveYcoE}b#wCnP8B-XSan2PSUd@=sh-<0S zTVRa@(OJha8#tWK;S9#jj9VDDGH&BM-*R{d<1UW*f$>L<`H68K<9@~ijK47+V!7oq z<}n`T_~VQx82@HG!+43Ygz*aJzsh)>W6JS7#Y9@6F(ZLiXv|2^1fwToO~zWn`{OLF zHiyyw$P>WWnh`bvF&O!uPt>|IV*DfAljC1!#8^QdjC>$QJ`f`x_%>61hj9QSd}zc& zUw|0ZK#b}c30TVkIg3PSO~uF8ab8vQSzDY}5zc2!r6+IXN)E3<{jFpshj%jOFz!Zt z9<2fd9%4GVjCqVlInPPPLdHKBFEU=?JXaa7aZDNGO~!I{{-b%aLS3HfBX2X_VZ6(D zkMTa^1IC9cclvakfdO#_MrfgU&4tmG(aPw?h@CBwTAk68^Vi^TO%B&)^yQd(jDC#u z85=M*WNgCt6hF!2&!w7*8Xv9Ko?&bz-WVUMJuAc~1se8iMU5EY2nTRZ8^;7PVw@x1 z&fyS7tcyba)||5qBW4ZciC~Ok`kgpW7sjrPuaVqv21(e1u_u>(ov{~VAI|v}V_%Nx z$Jn27Ajikx{J|e*kR-`rT;p(#k7pdo?M>j+_c>=0<7mcY&OesJ<2e6##)%v=iE%RH z2aF$b{;8a28q@rk5bsZc0%8WYVCKf^Ph6*d!A`0+O8%K?}tX?`4p z_kYBRp<<*+6yvb&eFE0NPZl3wp8QZu#SA%3e2lkze2Vwje=eqDW&D?zsb-1UVh(1e zd3f{uH)5e!ff;C}z{(=bIO&*aGBCqz72k^OVu#o%_KJOCzc?U%!=@9zi$l1JorhWH zh&YOwra+v)mES^s7krU8C(etD;*uy4^oIKz;-)Ca-R^thzIY)1BNRPUD_x}(E9|RF zPw6FVN^j{SYs)&aF4o`IlYX+kY=Cw6jV0z9*<7}eHW?^`uqHo5hRQJ68Y}bL%9mt2 z`Lb*;Uy&VTI9BR+#7h0nSe@ThzAC%P?y`q`UG~GO{C~=~CGHwyO@5plB8OoOeu7NI zI)1!0MvmsEkDRaO;7JqB`HGJD)2MXUW7kz%ptBT zmobm=DCa-PSjhMX<3+|RoaZXzHI6A`yvbOu@+T$1hMk>U1{+3*{ndeY8SgRPXMDi; zP#yY&Y#0zWjL<^yuwjI*jM&izVK+v1#_EiooWBN#YjU_2)?1h0suR)i<@kDxevI`Q z8!$FxY{K}IIx#6p^XF1cMfIs^uw;bIIW>UeZ5$3{v@?bHE}-CMyfJ^cYz6mU3$F^h4l5L5kO zs|dFX@j(D=7U53Dy^Q-9_cI<~#FZB0Imq}s;~~ae;qk#N*et?3jCUFDG2Z7qIL}4d z|1iR9`XB(dpU}eS%4lWuq*7SXL+Hg=lhK>87NZYiZAPr;nDVx)%Vlwn2b%R5{TS;r z;*1#a4H?6@6nrqmv}JsWu^nUOnwF^oSM2I`Z-py%S20ZuVI0N?4;{S0;{w7%2f{-~ z+sDzJm}D2&veNOMuw{g>Wm5*gmQfhC4B=E&@!@XxT@(Y0_Q8*2(GU-d2F&DmSTuxp zayW-^7su=t)hCaIMI(eoLmu=1YRqNKV?4_FPcjxV{=s;W@e1cbi%{ws$CNSNWGq*g zKO6*$c4a~nSTw>rjCUFDG2UlM{B;)@N+M*pRUa<5Q~i!$GiRRBuyJ`_((JWrWSd(1Zizm3PdbJ{p2kP%)P;_Vy`VGLz#%{kjJhI1ZxWyl%D^gD5$E{t6nUn9A}-Vyd-?8#+c zXY9p@cea3&w;20!Oh3l{i~~6y*Y(GH!n#q5;FY1q;T#{&IFj3&z^S<2kDR#P55)C; zAiOfH4@E>jG28R1k_I{7+unPQ;JT>?m#5f5DkW^z1q8R4BA&S8WuBWAZ~G|2{C zCWI~{20Qhl#$3ic#-p77Bx51tAB-0nuW+8LjMq4(jPWL8xjNjXJ#;yLJk8F8cNp(7 z-ebJa_<-@D`h9{YbQuU;CbUpIbeYhV(aPw?=+0Q3(UbGn;BZY2*JkwPn0ky@y8$}& z85=M*WNgCtlsYnQ2XvY0ZK}$X4{FaaHWMQz`I9bB9t&M23eaVQ130IRV*(lJO2Sy^ zGUW+j3}tN1IomKomyrj$42)v>oj6Yy#;%O7k=&rmggqF0a@p4zdolLmoNqDq<(Phq z{TT;xe2h9geh74#S_EB2jl(%Uo^d3%H-S^%=bTB5qZy&g$Um0D<2e6##)%v=iE%RH z2aM2V_faV8mLj2?@9>M5X><{FAYW@f_oM#{b3Mo5xpG-2eY) zxi`7F*%v|r*&qCd5qHE$5qGqH)LOq*arw4ZEv3|zqD4Oz zsUj*;HW3loM0O$~+x|RzG%;Pw5R1g_Vh^!IWELRtr;9Vix#E2BM)4-`X7N^Wv3R?9UtsToUbGes`8osS zD@5?2z*qCvU<-y1i;swpioX<}7FUYTh|h|=kFa12wy?Qx3bydkb*Hff!&Tzzf!Z5# ztT)6r#q}cJjX}Rr{6ORl3N(DV1AZiK7C#9bo3|coGTb8a#D%<7+$L@pcZfR!$GJ*8 zi4lhIHtP$@Jmx@0ZAF{0L7n?B!_^|kh z_^8O30{W-LmEtqvvm)~(7xblkoS3(Vd0uZ{KZjDXk4G0~9z$jx!;b^s&ppVz3Bz;Z z58`=4=1mwzi!oxX7$?Sy31Xs{Bz84qPQ*LcJ;^Az;TrMJfwvajk4HPca1pa17;SKP z7iBJUb_2T?oMtwJiTSCxCGhoi2bm3VWM*^b1{nG8MrPGpJFtJ=Vyw*275!qIm>?#J zjl^WJv6vG0aQ?Gc+4l=#urkADVskNDY$3K3TZy@12k|N~U+gG$61$1L#lB)cae!DZ zR)_<|!QxPHn8^H@`FpW4Lsm&7Z)3!4v94}UhSQ#;Sml^UdGprUTi&I2KQqV6E z8A(CDLtHBUT)fYUy!LHsYQq)cgCft@^9#)LHRSmkt`|3m8%3V4(NiBmp06Rb66EO) z@?5tt$BbFQEh1x9$kbDiF)O%Tq|QR#Dee+K7rzkqh>Tnjhk6Y%as?T=g4hzIM(#Py z)X0z;8B!znoMuL}7UtL)VwRXA=8Ab@zE~i35<829fpc?iqDF3dZ5nfcl1Xj475B3l z_&&o~hSm+V(b~-4MH|h~75!qIm>?#Jjl^WJvB><|N&C#)mbn$w%Z6!Ux|ks{=LLOt zv4>b9j^G+In_8SE&2(|5I9Hr6-YDKA-YniK@^yMrW;P9c-in%ZoF3u$Nr&luX*ua_ zdWM@;?VxwqaFzJFl|1Dj7Ge0NxL({KZWKQdd3TFAo5YXA%_0^tXQ{CWh($mw0%8#m zi-1@J#3JC%z^Pg1>2WqP76Gvch($mw0%8#mi-1@J#3CRTG3yg!5fF=jSj4PP%nYjH z&D6GrSjU{D#yTL@0kIB z9mDzJ0z>9rTXQ-v=jhz5D$|yn^CYdv9mShzMH;>@?g-TW>?~hQvVOLSQG+Id3p34g zf?=9STNatNEG!bci#@~=k=en7^2RKjDb5w=i#Lili8qV4imaeO{M*InEo+7!yEA-A zUuX@kk+4cKK4@;W@j(zD1o1%-9|ZA15FZ5bK@cAV@j-C2_(|Z0$*<9lGu$G6CTUpVy$?=vZg;~(Vqw} zi2*~?p9twsgkCXBj0jwq{F)spHj;*3LgF+QQ^ZuUiP%(3H#y-6#4t*NIxS-?;)0mJ;h!kPw0e}ihVfWwWdsTyJ*S;q)b4{ z1T?kB4}$!(NN*xCJ>&3MkvC>$E}-?jtxGMnsdWvX{s`>6I*Qh};VSV@fgi}*tKvzk z@r)?yD#J5kts!;QwAo9js|>qXnO*i#Ixk#9DGdBD{j^;o_7r=Gy~R>-qKRp|f7LCt zyZ6l0cSvUQ4!9LQdkHg6t!s*zhkIt0X=|UGebUT4M*h2G>eSh<@f}329^XOa%J3aT zbA9fn4gSeEzI`XUqF;;?6T~F3k(ewt7E=O$p0b3}aBSMWlm^2zk!N0Ho_S%B*j?-) zmWZ@=I4-RnI8B=A;!JU_IA5d>nRMtwhBu41ii^eD#rrI4${y1@1y_gK?h-#2zYzC``^5v|A@LjWh< zUC}FsiH!nZPr7I`QVSWWg^bifMrt7=wUCiom~M_s-DAi|EzA*f#XK=zED$@1oy9Hz zY!2(*Ogi0^T9MdYWZhQuJpwx>F2?E&dy2ip-eRfPFK~v?0b-e0E>;8%PrH}i55qy? z5OJtDTpTBk7bl2SBHz21?l&zl&WM&a@^osTT527`S>oKlSLAS>IA2^~XuW90)~3v0 z_S@mA)y#e~yh*%SWIPuQ zCgtBs^E=7Bd5z{}@%P&6kCNAje-d9&%vUA9A-<`YYsEh+bY0+&l~v4OHvEe;?<(eh ziyOp^BHy6phL)YjT!7=oT zVPd%G6C=ea#gCR8D>+e2mZq_oBBqK>#HM0~m>D?8QL?mG^T0P%s~N#DY++5D`l7>I z1(SC$f@5-$E4iIw=1G$;7D(Soa%ZuNSg4rY#NLWiD)v+Q0~DuRq;!)0U~}CV!7&^v z4%5EF#S!8d#T+YEN;6IzFIG!GIk2gEF(WwUO3u;|XG=dvoU8Mlr_gH^bD?;hxJdDD zkbI-!-z455&8^~M@iy^x#iyK-|D{Uv=Z5aB##XJzsW10j-H!Uw&=pxr6**2!5R=44 zVzSs+ObI+yZQ76*DmT$eG;Ag|7qi6{VoR}=$XEk0JBU|_j5wg_C^F)J+)eB)_7!QZ zqo=hFX{W;qaiBO@94ZbIM+CMJXQVhvnla*7u~Hl-ju)%Ms{`jIuAyaU(&znBSS?N# zr--+SOT-%S4sogYa}lpoZN5u#p=ycsdf14dTYYcUOwL2l2cieX)koVvHCo#)_89Tg&9SJD4e42jIU;@M z$ax~Y=Ewyiz2?ZB#jZ-B(B#UrHm@FUTAPs8CZx3qX>CGUn{c9)KIut%$qe~U(8O8v zmBm&~qp!?xmN++Xc2XPaNW=N!0z=-SGJM#wCT*eT_~mi$(Q~|JLK}LHjZ9Ayy~TeH z{C?6xp4HA&kKkF&@O|+^eV2!ko66nvB_FGvLSM4k>r-(HY4VL|k*B&z3soY|j(waG zY3Pc6F-}Ynlf*`1ve;Nm37oCEffnqZarEcXrt~g)3QM^gKS-e$TEb`4qQeGDLj-%WuE*I|-?-uV7pSRkyjxw#;af3{2 z7QSRz6Kw=rLEI>QAo30)aW;t`iJL`g*~%s6O}nZ*N{`_d z@iTF&xJ}$H?hxtO8-J40W8!}u~h71t{%NJM@JXYPBttPdE*bc zB5;WG2a1ElA>vSRxHwK6FHR7vMBefnZ@wE~IE|y+U^8ogH0O!T9Y9_nUaLGT)P4Ll zb>yy=_fsMbUC}Sbi3wto*howk8;dDoYTyL>W{J(j=3=(kLTo9v5_824;#Fe4*iq~x zb`yJxeZ_v_0I^)G5C@8b#i8ObaYSJ2@DhO>w=*Oa(L>#ScV$G#b`u zgdd5#lYwkJ$9_D=g%K<99EQx>fXv%~TSew=ATw_R?hyHIBKg@R`E&6LagVrPJRlwt zzY&?YK?)}WKMc#I-ZeZU){5UNC&riTw~Q~@&pdW$8-K80{$RiS!G7ELgZ;Mg2m9p@ z_S?oE?6-|S*w0(UhV<@@SV<{2q}>LY1p;ZAL1uwK+GLPfAdog0WEKeQsuT(Xr^j`$ z`&sd0``QDJ{qR*wMYHHFu7Ajat)ijrgY3bZ`;1<{P8J=>7Q) z{a*v;hUHRs8on>`JrVSb!wzpk-TC**$EiF2I`$xSr%8vpbDZiEVR*a6(4=N3JrX{gB>H zH1u{tdOIP#osiy6NN*>kw-fSaXtUj>w-cG(PDpPjq_-2&+X?Bhg!EWK`YIuPm5{zl zNM9v>d4-f$NO>LI z$&^<}{SB$VA>|cPULo~2q`X4PE2O-_bkf{Ic{R)ubHrRRPs|q!#7<&ok@89kg~)uT zniBbxDVM#?x1!@mK0^IhJG_|s&v3rDz>xC#@zBR8uZC+xTDiktq`bZ|3CO$&$h-;2ya~v>3CO$& z$h-;2ya~v>3CO$&$h-;2ya~v>3CO$&$h-;2ya~v>3CO$&$h-;2ya_lWaB|pk%Bvys zCeSc%0y1v`GH(JhZvrxJ0y1v`ULE+pqLT7zWadpk=1oB6O+e;NK;}(A=1oB6O+e;N zK;}(A=1su+0^bkoz_@`S@0-F0MauDzZKfPU$}yxIL&`Cv97D=6q#Q%aF{B(rzTG#l z7v=aEIb=QF^0k!X!$U7qjty6de+rx@=BwhzoJ*N?B5;oQCj%GB)hY3eSSvEWhd9jd zfj@}M??Jv0I5%{Rby2(|1`KUWbVaWiCPoB)sHkK-(vZ196=h~_5M*u;j1`$1gv{I^ z$lM^v+#r|~_?&GMOHB?J_{gnId!?Otv!lMP zD5I1g9oUOfzI)hlMqscmMqrGM@qydGuCg7bwG3%3Lt4v_)-t5E3~4PxTFa2uGNiQ( zQv%Np8piYM59L)nzZ#~Aj2a^|Y7C3S?jleAXn696BP^@pN%Q;)`L-UK>EcY0b49~h z!W+e##GA!i#l_<7B2S_f_nRkC$df31J+PzyE!0njZ;I$^kl$Pv5DAJWad%P zEb2l-RwRcxVy>7c=8FYlC$Y2GC2+RydDDXBhK^8KPfEmw7J;0$@2 zC(aiah}Y^U3k^*_)WEwr?SFa?;#<;|T5uoEiL@^CfScEO!o=bgbW)nFb}x%uEBw zOatf_xvR+BPsp8wjYN9HkQ<9BmNg)ZGJUl4e#*3AnwTzTh(%&|v4>b9j^OyErcA?W z(o7d;ioCZ&YV*Y##hb*N#al(bwMZO#!QryN*@|NNqzspfcZqk4_lS(VmTBY_u8{no z_z>6QNi)|KatGie;-lg(#izxU;xpp2;`3HycYTGpJjwKP!I!Ma{%7$nhO5Na1K*bK z!@C&1DXtech#SQZMEoytHi;jJn?>$w`OA2hgZ(S+N8B$S5D$sph)2W|9Pu&J*5$Z(5r`LocoBHPN=&|qelEjHV!#knN-Vvf zey+qayofYmVuUrbTLoUk5HC{xvS}wnyad>{V9u&3BdWRwXFqf8M0QT`bHT*oSksrwDfMBaZxW;Q%miP`Xw+3=9r@Q~T? zklFBXoXBi=WM;!dX2V0i0#rVTy5EplNX5_MT}CUC6^iLl!2m17DZ$ zy={>ZoB0)x zQVuEQkW$`xnknUwQVuEQkWvmQ<&aViDdmt-4k_jEd8F;p_2LF`qxgaNp}0x>NZc$^%KPo6lz&vZkWy~AMf^|7%Hw-KOIyKc!o&#c8uXDOrM%y6Q_3Nw98$_5r5sYqA*CEr$|0q^w2V@| zwbZoI4S53)@&+K}4M4~nfRHx;VY?|_ZuwRC0D{$Px zz$d*oQPxeGMPhfcSnLtl(mR8)ZrC%hrSD^mJ{h^USSt20*O0Qlt7rse-LOn77b^mv zlKwz(kjPttXn0Ey4j0FX>xPWl^vR;EZ%uffvTitETp;phD|z6} zR_^BCDC=9hcA=~r@;)Z?i*aItm?Uz&3FUf2t~X?Tn3A)U^;0FwDeH#J3Wv1KAZ;_q ztZ>Mza7b$k(wc&l^?b%ILsAbUI$lzc^&AP*8!Q=0XK;s(Yn5dJ}IO5*h=oTmh!qY{|M#PaEthv zxK-RHZWnimI|EfJ@D95dOEub88%_+x*wE1IRHf??wWm2FV8^(yS zVw@N+CWwh5t48!rpd6pdKSDV+WF;k-BBqL17aFV!rknGk92;hdIbyDuC+3RB>=F1n<`K%VVNbY%a%|+@VyW22TwBWV z*$!qlyu};i9^Jp;&5@CI9{9}R*4e>r?}da0;jsHp&Xxz`3=wO z$2)z(^SUA5M}Tt!JByA`vJK~p3&d-ctA&Q<+vYJZ^Q3SHQjhf*!}CHj`r7^rxxk?| z>wOE)4o|>81YYd2##tTsv?Rs(V_+w7)=2-RDK$Jtyxieko+BEUj^{bztFCE0M;N{@ zerUBr{>W<7JBp`?HpKZP@B*4omBJRQORojy>#4m<>F+Z#PZr2K0$+9d#Mv1*ORjdI zdBuE56@D4`1`WM|gzgs)SQ+H-ta5^VmdxQDQB!_DG=3}pyzyHQzXkDI5WfZSTM)km z@mmnT1@T)jC9pa##rUnxD~#WQX(E0LIYY!Qkg*Gh--0C~ek?Ml_kwt@2v+Wp{Ia-8d_Ay}+`bXm(>@FDW#sjNJzRkefj!;dzGi=V`0eLP{`a+i1#8tcrS?eg4|(vA#kEcC9M#{OJcxk z%DXPiYVWZI@7289X1o`ncrQb|S46GxUJ&oqeT(s45bp)?UJ&mE@m>(`1@T^xFY$J1 zhxa;AXr46;8;dDos@OzqD&oD0j~ed<@m>(`1@T@G?*;K*5bp)?UgQezh0Lte?r#|H z#c}aoZTxsIbHpOCyI3stz_*>pdl~i=dx^cpQjzx_iWcL&PLL*}#IQ_ctQff>u%}xt z-pg>1I7A#O4j0FXBVzEi=-=hp`tv41O6nD*=H1hq0_jbG^rk@Cy^t0!q^%1ZiS(u*Hx}tl>Fl?dE16${C)8al72^#d6UT9#3H5KUF;#2h`klsSL`P;dy>7F zJqejT2?vUUMP@al87AUc+m14x6^@dIIh2GlhZ0tb;?3f%;$o3m&ZM?Pyj>dRP@=g*Tq^!tToyP~FpTF- z!{y>#;@#ps;(dWv+4li)h4`TOP~aVJF=H`?4~vh8kBYw(pB7h&&xp^8&j(&?*`KkP zmpZaWu4T37U4XzJ+Z|yn#&DJRr@*%y_f_%rz&@_n8{(VddU1oeQT#ysP~0SbByJWz z4jk<=gW6zA*UQufhFiqX#I52sal5!f+!@$L%w3W{7rzkqi2KC@;vw-H@rZaLaESbW zuj8H*e-O`$7Xsf9dQrS21`K)jG-M4R=oQ1n2y0?tAzt4w%A~*ukztG&E5?cOVuF|` zCW(y#hYN<`^$i<~DI(<&4Re?C#;y9O>5F2dh) z-SGD&wIZ>*SSZGWadYpZbdAS?4@; zlw(cf>RxX}urA9TtZKj1UTS$*o8>OPD|)wm5AwbCy~y|5_rnM52Q1fKVXr`b$bQHQ zXT6qRSn;gc@(B9J?8neQZZprE^;@2RPufqymG(+2g|%FsA^x-Wv&3Xg7xe#^uU)0u zzhxC0m(^WfK=XV1_sDDPH5~U9`xW@6{U(~V_FDG+GwVRdo0VUz1p6KP9pbb83!xkA z4ao1??-T!T_9o=be9JAu{>1*2Gh!_m;%v3I61v^qLFi6%>|=KG__ zTWBFQaz3Mllz`3?IG_$IdHk!^*^ zwqoqR@Fle{`(69L2@TrTMtdVNYYf57yu;(OKgG6eRvG&23ak1Y`73)LdSgi*`y2ZR zvauweEXiY^v`?Zj_T-U0`R%jz#X5U(9T$6YWKWLl$#tSxsUT=iuIwpN_T)OrPE+(5 zP6jl#6)W3vu`0f(?6knPQe|7=SJ+m#Y|C}>ur0?aa0;w&r;|gkpjo}ha8vVXuGnoNW4)sn#s;&?2qr1p`XQf%EJH2=3*W5 zb#2$V*13+D#{R;wzZ;Nm#0nkf7Uwo(V~gQVjZ=fhSfndkjBwUCYpe)aWRxs2LKYb> zi%gJ3#>*lTWRc0T$i}kBI9X(hEHcr(m2a7a*`KG=3T?No-9cV`4oPWye_EH`=^x`Y z>~V!eo4qc*tdJB+IUU`9luDZ`_x-0ms8Q>VQ5KRKj=>x0b)hrrrPigE)w;mq*C3@Y z_y-;Pleb&yb?Nj`1oudP+=LMJY&y?pby9<_LAuB?KqAA z89SsqnsGLhtI77ikTUlDtI$@Yni*$8+npt4rP3f}a>W~TnOt>Zce+v+49&ee_sQIs zb6?Bd&>&@&)ax?K>ZL0}(PdWs;~ov6S4a$4dGwq zKT26NRT^|zQ6@BtdwGQ}%jnv7p+tH3zgL5lg-2@8Wtlsn&@6mW`(9ZuXMK>hHER#) zHArP4-H4D>6_RFzB(ujYKiYT2KZI`0dYqIONN1$r9!6Rg(gpW0y7eJlLtdW!QF2n1mjr(ep{<_h`mHkB1}W=ey{;Kn-4NPL*4_}W88#ja zZH9flOYqk`&9d6O(&lZ;YKEt&ld3|}jF7Z|7{?XwmXK~)NLmq+ zxN>!=ybzMsgrs$$R5pZkpM)gruWsM{^^(b7-Eq%_bl6`oUh{;I6grRQ?Ls>2FBq@g z6L^Pv%^5fE6_P6IC567I&{6fe<`e5B_L-%yg$+`+kCZpI*@0$pNLn6}9tug^)w+1( zpiV;9e6RN9e%I;V2ubgTq)j1dTfJnCTet6#dR_C{kaW3TYRF%~m z=?We8)u3y^RpMyaRox!D+ZMMS)OJkU87;Piq~#%LWk`A`B%y0@P(!COr-VR9{ zLlVcR+v8+NcRnQTZt1DdK}bSZzeh-SzFunCE+oa&r_wT|UTT|b&d~f?HV^)f)UH0X z<#@J+DYQ5w^$$rVbeaimP5EfOTk+!n-|B|uM9X(8SJ zARVl4kEeyCR=#>&tMm0Kx8_cyoi4i;C~9bZH9QrW z7LWP0@0A-mw+5-z8^O@Dqpd=I_llO*tY9c%%o8}1Z&vV+?hIwyAYJxJQhrYYqx-ZHA?S^dR^-VN%!nW zq0iU*#D?}u>s9sT=nARsUbg+9b=|!r&y>8Ncf4ZXqoL!TC4a#>8C9L+M`~hXG~~2R z8+4tdBd5U@l-V-BHYLITjnvQrXsF{G&b;B8wt11*y1ETlt;5R$m!b$is*U$wSe=b%SwyD}uLt&8 zvtA*EJbFXxp`m7PNTuPLHr$Vfc4vc~wp-7!<@Fk#VH^BV!*f)Fl+JZ&Ka8iUxCV*5 zJ2mD6u7$R>n}0vGnpYF6sg?FWtmXB;TgmI+uj6&QRr9}G!|Q*yde?Tos`~$B^{#k3 z;Xhuz>!;T4>Squ5muq+Z)2dyjAM!sKOW{MQjPZLJ;rGKh{=AIvC%`1W!RTeJ)Kp?L zu`-C2Wwk^$Yq8muS&hxMSP>YSHQ8)dW$Omb{dBc9o6G8Kz0sHz+HBTn8vx5$rOjrY zw!zS>)n+r_a-@~adTpbS$FN$P&3bKBaH3XkFsrq>tk*UZ&gIW#-M0DgMpkZfwRW4U z)!S_AcK%%MzxnFqGUg^lGBfEuD~6d#55kA6hb^CF)^GDMOZu1aX=X+Hn3MFfmB?Jm zRq#*zHD+z3SK;e?Ej7$q&tI6efxj@mmAVmrU~PsU^XK8~%v-T~v!)wwKk;>4hi~id zfo5Gdhwtegf@XC$hxOe~Sh1E_<1LP_6PkGxW}P<=Zy;RasmXtn^0DdzPIAb8JRn>|8q+=Gg_1b>CoDyDRYv?LsSy_1}uw zs|Q~djbh!mK8ywRGw;M>MZ5>k3UGrMFJc8aIGpc>Mz9WC6~vOPMyvog3(jSQqXe@C z9R2lXj3rwA+R^IQPEx;iwEDH1s9(FO`n8*=U%RRLwbRwFouPj1RP}3Ts$V;etMW7M zl*?5xmgkY>d1QGWEUz(|6#jyi7mej*6J}Nd{MYtpEH6=(mnh5g%JRIjJg+RzE6elB z^1QOV3|U?#mN&-AWW~Vo*ov_~z6*~1#ahPx!eoD8vOm_<<83uX47={(Q1OpX|>k`}1LcAEDpO zpCkK=!~V80e{ws2&9THC$p5n~=E@cu%N84Bi>%;zFg{fHKwd8>r;#RzDFfiYNWxikwp)~N)P$mIn{YVMoZ(yUT?zg~ zQ~!VKK4-s|V2<{!`&PosvAIYq-4_xP5{@7pcUQy@OL!M)lk1P~l3?-um;5hC zdX3QF9eNsRopc`}?U1y_u8lHndDl8(*G8Bp46X^O+$8BOa%9?xToWfb=A`awLXFwp z=(LSnfVI2UPGZMI7a_fG*ZOWyY@;*J3S1dyUEE1Ye|FZz-!Eyrvku!ap(~Kg(~3#u zQryOvGNg@2$uY(zcG?%?--t1H*B<2l(dO>wD*H4*I8}<8n$ijN7NpLd}sHHJsnYi z?m4?x%zF{JXgxK+sup8KR}2@c#OuWy#dtANj1zH`{A@7_+IBvr!?p)H>tK=H-q6Hg zZPQwqAboFX`b+N5*CJfIv-?)e!Kn3;o{QNXyIRs+Fom6$<( z$_slei+Lrc)JV>{m={UW=w66f8P{LZikQdZGLY_c--_HF#pp27bCFwO$4FWkxiR*B zr16Bl9mO59xu21*D3$B&iIE;jecUG^lOzpr-|}TdWf5XsD_E-_VId+NSy%qgL-6vx3 zL`e6$Z-pmBu95UyxX*YB``OEvBHDx->3(-*SZ&N^BRT8BRA%g#_-Jj7_FeA26+=Z1p8~Wz3Km@?yOZD2u$w$5~p>aGh`Q zty8>ZF?mtDBt0FI6FDAfJNsUYi6Z3*+_w>tKBQ|eUy6A<+(GJs^g`G<;=Rh9>7-nZ zx7S#H?-1nj8gqs{vCFp=@*$riYcAXyG3`Z{X-|c{hIXi8-{-D~-V}r9wC`}A z2zyeoxtlQw(Z(k3bpIOTi@pizfk0WrqVQ_zHbmD(J&AOy`-*34_-W~$j^68kf|31~ z+=!@Nb*U_i-V}M!?CY%asa!cd@VfF-%s8@wzQFl_Qsrk41|Dln{(1K?Vl`Xl=qsY{ zjeZ5G2wOT5VbbC}V@zChSk*R3{VrdMKIh$sF3x=`DlcLyQeGf7dVkal(mfZI7PS_s z)#XdR!(Jox;(BBmkHDKDvC+dQUannu`BG%D*PK_4`-D&a&0gldNR{!L+}(rxe#i!|1Ei?Px$lb2JL*C$WR9?QH1{=+7o zv(9V$gh}NcZ${K!Nl$x|B1Ul)|KO~nT$xbv;a@1}xaE!9Z6wF0v^9w&6{Nc&8j?*&MG$4aa2L@tySyku<}1)Rb%Mg7da-UlgUD&~d&U+;`Wy?B3?A_Zy49 z;P&&q7lG9xW&74DttNG8we`K|D^YGLA`VARG;y3--(!ALwtVg!|DecEkUF~)BJYhf zdG)!2BGyM9RJ;qmd0|JzrP>P;Y8TA;qyod zpJ|TomH(MrWNSSKGWbMhe)T0(M!@- z;b)C)Sr54W@T01Q@g?E=T+WSnm&14X@IKbhougruF4qO!y72eH-;=aGd~H}?q&w}} zFjLndx&E-ch#4F=)2)p->!lRf9{l$@W@x$A-R}G0=Y4M=-ERDx`9`xfyTwb~&c zPJ5@fH%{@&-Eg4tUaZr8+94*4#awcX`E>ZQcNaF3*7H)qHa8 zT<5E>1o^XE*9r4EYdB7>>-Sy`A7mtFm$x=-honp1BVlhKwGEVog;^_+GAJ?Ac;0fI zUpl?RR+74F4|c=t+^~b%W1aU6Z+}TYcvpp&N!spx-d%_EkaNkqI?UYfhdA1A!uCkF z%ll;5HIgoSANDRq%5*M;H3{owBoMuJdpEnr+WdB{8>|cb&Lwx2cdyYg!Ehoi03>IJJ1Q&zi5h@A_XrYY z$qib~1oy9D9`7o2A5!x~>FCRybspKj&6<#&YU$o_)-k8n9k($7o_XKWZN`JlhF;6 zbTtWVl(dZ5qrqd2sX=MIM6Nw1#A0u!gW@ERLa+s}m(VTJbtP}-f|7fed)$jf+vW-c z(`r>i3QnHob(bp6?Uan9pu{!vd#jPNYKUX+<;-flcqY4mbo$C7XuY@FJKeiH8@+yxS;CQmc_>o)w14QUF}e8=eJ~_F7(%N( zxSgx*3evsk*+9BI+-05`$M}yP?z5g7+_OgFSaVD|_GI^2%kS<(?y3FW(YDFQaI@{$ z&N}l}6EO>r7D}26gU5q$e0){wH=S$`CMcF*kWDG)(XjRE^}X& z6GBfGNtsqrk#pH?!;z4%+$>UY?JR1V4U!^xjy+m0G0)nB&ai9o14!0^Q2O0!Oh_`? zU`|uaw)F#huTabfoE`QfZWDA5IJ@k7oxMnRI()IpH6ClQVt*R2-8ZyzSbGCe@%S5m`{)&t}sg)JoZj*;{|491yh z;xIG1)*Ygd??O-R-;vXsoGyeqggk6Q?Z4BL94sxFYzI@wt1%&WGKW_8%euhT3#N5} zo|?W~Bh!-HXyeHwrQo+U(X&2?;6@3qVP=T3^U42%xH2HEZ03r<2~u}&^;k3K9p`7v!C@< zXuDF|2Z^bJ)!{Jv0`ZKv(qLg9n7Js}i6P}{eJ!bfPk7vaIH*(>CE3KcRS6!R*! z`Ou?yop^&dLtG@@Bwj6!6z7Yh#fjovaiKU}oG1QFoFHB!jujV(Q^cv_WO0r-4ch#f z{^;gB^>(t{zWoP{vfiC~^TIjShB=cL&7=NFqbGP9qYek{-PUn>t1jDN^vovHYm#Gk zu)EkLc7J;K$JkHV6Y)Ruc&lLv_2CNMdP$r-dCnroHx=5iOr3n)bbIa8sdE=NDPoHm z3nx#t*Uy`mepafi4^+%FyxPtIRBZJraiVBYm}?XwGSSUAsKeckm7 zuCp&LLgP5b1KGT}P&!~lmQzwXvMkG~>Qgp4%eiIbh|(;)5?+eFp0&aMy{7fWHl@In zNZY&zWB&JyqR-vvd85bt@4iA`xCwu^ZvUF#|IUbDJp1lC{k*#Pd+Wj{*6AOq``?T} znRG_f9dCJEyp+1_vf#ESs_y^l;Qwwl;Xd}utIL-ewJ_-n)3)O@;Vp(oEMM>O{j!{+ zeR_||a#r^l0gn#sJsu7oIw;H8JYp!{K-)aZgdQC=Vj$Gs9^RW6#CtYHq-cI-JjBrW zWt$NZ!&EUsq>Q795hKM|(UdV;rO#%x&B*a$W9YDDuAsx{NhAI@ZB`FQ_aS>jGFt)G zIYJj4>-7>Nh7po@auLT}dDq@zh}QOOw8G&!M&4}CKE`rv&ujc!&%d|%_X_{$t|pG6)_ZQ76r-xay|=g^3M z8yfj zMtCYc6Ft+2#~z*+`1h3dGQH_Y-7fA(ca%HY9pjF5tK6&IDehEvnmfsz=uUR4-EnTE zJAqb~rg_!HhgP7u`rV%8hno+*mixjdv5=L^sK8&J zChbR^$9Rf+-1(LBg!6x#C!JqAPdP8qdRyOYjwdD8^|)R) zj3?t9x0RdgwszaNZQXWmd$)som7C}0y9I7X#(~aT_sDLIHD71jSTQz@z3RHzr+A~z zT)Ue8e^FO?w z)U~{YXKLIFhP*Qgo!6;f@KK&ZkMaIyR)XOPuHqJ#`iOs&JZ#`WN}3txzhG#`628IR zOiafu=INIIHHL2fU(1_o{Len7{&Jkv&O7Yuaew2!?5?35?{Qyo*ShQ74eYzmJqgcw z!pK1k&ki;|=q`)z{@5lr9{x?UUS|zn(%~O@<$tqcv!nO*g72d_SkZGF=a$M`bqHC)y8 zyl?oLbEk8+8A0ZWwzJ!rJau)u!ftLaIKX8-i9668!5;N7tSe$Tis4s`Na8dySDJLX zvVW1=o%_H}AN9;e5ectf!n) zjB{V&sUm^$+>u#S4l#6>C{e#fUgi9e*lV4&%+1*C>}H>R&OY`z?ObG^NS@o%@g^x& zYd0I8(uO?p7AqEm1z{@}m`#Emad(4fx|;Wd&Dmb~PtF%xxbF&&_fwwgx13Y3w7*Us zgC5|&$>$g3^3+e4@SpYu|F&%ZJD>0$-D}6ZEuHSHVs3%MHKB&IW1VMt68SSe{h7M| zSL*)<+@X}lB6k?<<_?FY*ouP%4U~S6^n;}zV)S^CSgv@GG~toQnr+c+W9W5%mT58R z&**-<)$|`?HYJ8B@4=Y$VL^=vGe1M~td%S&p)R)3(AHv($FBP&q!|?LBHVM zrafU=n~xF?O)vhe>)1x#wEgoh4jepm_?x50j-NPr`pkD{zd!fG`3n~>T@K(1T#q*_ z+!x`GjEatljf+o6Olp+eI3=}7)3o%A%&cb3vs<*xX_ec$P1|>_SapNadPo6e&*1Uy_ZdxpNXEL_bdc=<7Q%7O$;->w6 z`WI6gr(f&Vxw+~8^b70Rvmhz>>%wnn&CrHlb#-B3VQ2H}GMowV{OH#F$zQYLUfxms ziu4=Y()_Z2;x{7C{00}-{TlK6iC9LJln|p|YHDg!+rLl zL*}&V*QHZw&(3izi;Jsgm6VkXt*96}vDfS_h5d&2>lc$A7B;abNpiM!!qGPa<6 zaPf3+UWekM>HRBG3wp-IW#@CzM~@oSZxWYi{&;h;BS%giJHTI_8Pm0(phNr8s);ko z$1WJVa9n;&W_fwv{Gk;cE9Nfrcer-^>}y6%9W`+5sHUA3GzZrJFfmR{OkW3x;roMivez>fC2QhyKII z^&d4pCMLR|Nl{j6k2d`pXJjR(WVcLh8u}%)yy~jt_~h0`@$(KHVpE!C#wIo@>>TdT z?c1V7)9_%#NKalPJ-T#B$txJ!q^IK~Cr3nF)wEwq*wt-&_v+{CJEm(^)9l=c z_JvJ*6pbF#WB8CxEnBt=9R{J#l^aI3+GjP%>z7uL+N0AyL~PnL@5fR9=j}fImUb&G zY}&DJpQ}0)=Qc~3G%>%a>9~v-Ea%3Vvlf|BI(yONIkuM=etbxdRbbJ_ zkJ_QV`4In^zCaTVZ(-MlWY;nMVnMw}aZG!FHiU^8)QfTQf6R-td^#Q}hEgShnZ07R zP5+}woq24QN!j%5g^nzr79Qg+Hc5vY&lp7>Z^gabXZ@G;r1iY@duHOjWo=-t=ML*D z=5e03&XZd|^W2))&2848lvFTvNu z*WA~}x7hc*Z@ceH-yz>g-wzQ^L}Wx#L|R0Ph;|X3B8nsWM+}V^7cnK`XA##$+#2!Q zh&>Sp{rUa{{#E{OBa0#*iF`8h`N(}yZKFCyb&u*3H8AR)s8vzhqrQwf6m=pxCwg}D zwb3_6-w}OJ^e>`+75z-~@1p+{y)Jq~^vBUVqW4B0jy@TEF2;%}in%Z5P|S&#?_&b7 zgJU0z{UY{2?6KJIVlTycclq_-%b25@w3Fw6TeRUHt|g2g(NpAGAS{sX;R;$fk`8i?oK+Hbgq%r z$k!;YQA(qUjh<|@r_rHCCmWqhZj#(QxlM9Ga#3<=^1$Sg$rF;NCeKM;lzdzAoymVl zem(g@W4Cc+ znA$WoJGE_U$JFA~zNv#!$D~e5ot?Tc_14s%r{0(Pdg}VrjZNa3jBWB*lTA&Xrv9c0 zO`9|w({y6f8BOOky`kxnrhiWhOKY1pGHqGf{b`S+ZBE;k_C?x(v}0+tX&2Ky=~3xP zc-@@ztI`Y8d#9JD4^JPLUY$NG{o(YF)3>L8nSLn!L`Iv8=^1k~uFtqFSL@EL-)`f!DQ@%oHph8y;_kLv z+hw$y-fne!U;EPbkG0>>{#g6k4&6H}?eLeYoU8g@wfw5T*lWC6?QFLSa@^c9fkK49_!Y=+k$R4b*t%iv?#x*ThXGTRYgaNPIYhDyDLK`%bI+T4uI=UY z>euV(-YLB&^nSSah0-3S_m*z$lh$WqpJ)2)>)W*N+`eo2e%&vsU$1^Q^?S45_x%g{ z&+Wgc|7-ov4CpwZXh7+J`v!ba<}a%#TVA%V?7gy0WuKLOUhXT8D^DrUDxXk3wftx0 z*OfnA{@e0Dl)qm7m-6?^KQ7;15nC~$;ujSk4U8I?IIzjU(F30z_~oFCL6w6Z7_@nC z=fO(`-#Pe!!H*7pa`1*B&X9;9aYM?7EFbd4(5^#g4t;d!=fj*~V}?C3?Ac)lh8-RD z!*FYO$?%24-x~h)@Jl1Qj#xJ0g%MwmY&~+|$oofb8M$-h$x*FG<&7FSYTT&mQ8Px( z9kpmw&8WLZtr+#_s8yr(jczl#BJ?+6^rX>qMlTut#OP;6|8Df^(VIpe9&^>08^$ad z^XQnb#(Kxbk4+z&J+|%Gg0bDk_8L22>@{Pr9edl@d&WLG_Q|oo8T-!I-IcMGrIo`f zCst0coLl*Hbt9dS-rCQch#>~|F!zF>aVN6tG+O~ z`Q)O>{U;Bd{P5%rQ#@1Rr}Uk2-INEXyfbCv)Rd|BP5rN_znc2=)Za|~!_-%&{(0)( zrhYPY`_w&C_fI`C_0+T;(?(9adD_Zpf1b8!+E>%QpPn$i+4Q{W{in~KzI^(F)8Cx_ z;q>j(4^KZoBXvfX8NFu=nlX3AiW%?EI5RV9X3LplX5KRM!I{s@{L{?MGq=t>HY;pa zn^_ZP&7M^=>zP^a&N@E3)$EGd*UY|m_OE9DcJ`{-ug-pJ_Frdjn*G`AU9-QMeR%eX z*=J{8y2f)&!(9sVx%vTArXx9C6iFkp z`+feXh|BGoXJxf$(X2_6)29y~_~zhuSsgpKZ~4n5g29j8BfU+T2kS-J(T{ ztkgiDaeDLS+0C2Xkge9hKMx*b0mta_pC2RE zK7I6zH!d}fq5hvZT=u^_+*!ZR8=L4o_s#ypbw@U>mZc=qN@itpER&HImX)!SA z*=~Z;TqRl}Y&&+K4UG(G!r7;W?-TuV8g^Y8!5FEb7{P5jwB2yjMs<4_O^w^cL|Gm@ zO8s%8+SrZ$QA19W{!#Pap*X=KnTr%Wzuwj+PEo${k?_ea}KXnP6g z(IYlCDmpRwWFT-kuxHQiua0JP$ZOTA)!wf?p0B_D>hL+_oSZ|u{=R+t_uqf}?ZLyx zjvY&D)Ap*oPMz|P9kWi~Q4`jzS-}eJmj}z(Dud2JAcae{t7cLwIG=Kt>8XP@oav**h%?}*`P_uO~q>@e3;Tk8fc-;rq^Z=1Kwin?_C@c(1&z2oFM zt~1fwIp>_?q@J83W-tJe0}&WRN=y<(ik4&rOP1t_JCH0}_U`(vz5CYoAAY;`^X{|! zv-jucCtJ3nNs(qI2oe}f&beoLI_G|0-P_&M-2g~{baTibt3%bf6;7S{&N*LgY;0~d zHMyh`36)B2&Ck!xtp&HQGINHY{`EJm2lEA`2S*A^E*=d>rTlC%8IO?{PSeTth3T0{ zgo@GHLUdtqac(IrR9dk2`|6ZJ&V&$lG$)@gOmGBpnkp2Z^I6o}c~A99GCOO*f>b9h z2=A#0S>g7z!_FoheqTAeZq}4DHRW`<{4}I(uNsuD;l6sDew|*2HiQ1}tFw6nZsG3i z?Ch3q_~Zlq{gjfHdEByO?v3jUWCSLJdrLcFwOTx(Z)@{<{eB#yT?T3|9$Z*jj*A7w zekn<+EJ{jNJxwX-Z!@FuIh49(GQ0ggWl_+oO{FsU5#)IeEp|pC`$;jGW_`K6_4Re3 zsCt4b$^72Wwv8UfcR~@pp5oG~-5mffq0Uascc0IR8DG^Kw5oQ66rZ1*aYpX9z2w&P z`!l;KZfZAn4^3NZo4(t8*+gZP^92}kRT=qm#d1mKZE{OGkJXDw@|7CB7%P9L^s5bS ztzP__@^{$zKpv}7l3K}_t??m)Ch<7lOkp+b#!$%HBly_s4A8T?I?{-S|$<*1%lO8qDR+TTfKOB zJnTC78d@5P1^0*sibkW`tE($3>GbU6{9ZaEbGNs*+X~U0)wvrtZrqxhe*69Rb2+g> z41+VbSvZ=+}yL&4utE)oc>Pkju z7Uc8!be{9=%6)Vh)l`m2wx*2Rg{wXn*OU)_|4lp%XM3CeSWonwkMl$%R^?Fj$PEsj zJce;v!E1GWdTGh+Zf_yDn!{mhbi0?9uHV2X%s}>x4-Qhc+Hq9O&&R|(g`%kbXcfd2oA<+<5I6Z)@CyMPngm)T0s9rTU5{wFgKnN8d+ypJXn{MOa zhm}vottpd=D?hLOzN_YQbxnD?rYx^1chr<+m9RQOD+X6nAG%lwqJG;2fm^{Myx!vTpR8+NUjrbA! zT+D2%88Eifd-!58e5-|m!_j&8_}TNv4i9y@EN+hn=v~~MOJdRo6x}&_9e4JdN z;C55iI}5|^!VM+uNs!CH2iK$%#6t;*-l0cu{B4z5n5A5v}UW>YIiwZ-a2@O8XD?48yk7NOin0BCR3@*UOYZj=W&`;O0`%ji0q7w z9XWhxq!U(qps%e>olJ%zxtv5Q7Kz|@YM}b)E26zO{^|RF_sWg`=V;+mg`vX9!f%Uz zA#3p38AS;zUMsg>pIU(#KSxWY8nw>jX>$9WDyWv(jackZw+D8-hn9OhssiWtzxzL4 zot&JWEzT)oFB*4-`&-D##qamFH#DTv;dr5t&E{##ej?$RT-n*)-ok5VVSQbv^Ey>3 z4JD)H!tegUpZ&#OSWKp(6kd(G>6L4X%)*j=?X!fb$XFi?h9czLg@acj z77m3%dwaq7&W#s;@Pi-x?46a}l-h@5TD_j8*6BYq;^gmkJ54gAN`)5Y=jK;KX|CGp zE{@fwP+!kI)}i;#LVvQeiUc_1pL^^cw~QnKNQ+c1aH3d zkN^0OKfkuRm)7{%jvvLhh!5Xr zEA>^C=iJPnQY+ zBgQ(6P|m><2<4 z3G}GWMRYo4G9(j)!HnVVZa`GlKDi{_jolM0;>12={k!&`@&WP{- zdq}4%N@td4Vp*ldYf&2RP0+S`5wueER;)EInyAz$J>@c)X9GeiU646(=+!DL7Kg8~ zvC*a1nyf~BhGVa@YLwhmDwWJo@!-zh-d-#(QL5DviQiATXl*pRzqqgvFG%5~G#NDt zxtKd)26kG&SmUHHD{Y=-vZxxxfL2eO7}ai0Ai>CcbYwo#mlq~#{Ji6_^6 z1UXcU9Czt0H!7^`E%Kn1WpPbeRxUHW8SvG+`-gk`0uF5W9cR5>K0h06AMWlZaVVW# zsZ>0XPG>UOmcjP1!_PhW&{@RB92sn*x@g)a(l)p~9*8u#m)VFn%Xap%SqJaU0Bd)KCCu%ipNXcOO33?NA6Np zKiX=_h!`w>PWGd&p<_p|j{A=uZxO{eH`W;t;?}}~Qt7nH6)F`c8sk$0A15z@kq=Q~ z+Au#C3g7(M53j}bI!$JIzGR?^85?nbW@=|bD&+58OQslOnjKXJ`MB@aDgnp)Zn*kT zUa?X)@eq}BjayURswu~6%95ILvZnlEO<7+qvo?$VaN)PF;Jr%AMo%iOvTW}7EmC!r zWuuQAyu;3b6yB(Ox@b)qKC7zFYkPxK$o~G9Fhe@>8IedTmdNF2&OUbhfuqO9hw2bQ z7pOM~a9fpFr4_1Fb#?76P1eHBN=V;B_0uB0WN!^9-#l$vSVM-o?BT!n%4=`F2hkzCdVXnnUn~>z z`Tz8bx%Ihg|Hfx`sK5L7kkC+;PzJxy9!GAFbHpD*SleyF-bL@`&`9O%AdOa*W##f8 z;W;HH6JI3FTJT?bB*zs9y1E{E?85QW4-Pcwl*w2+n$O$qbsb$ThX%U_pg}0K9$a!ME?1}XwDt6LcXTkpjU62=1FfyF9k0k4y}T4&TxP^E%r6Er z1xmlZTayfPU7q7kky31!Y#^XHR=`&*k+3@^4^0Cse z3LS@=Zp_ThZS2I-X<9OQ?0B2UVvl z;yPt*X%4uI{Jq7cB|d%uJ$Z$fgL`(uB2ua~8l6$j+nb-AUWKb92D^4C7)=)@9Qkz4 z+}%G|@ALb-)c#&5F3{PVnwp$ijZ~=PvO1-7%DyV*%(G8XdM9|>JG z@>SQgQ~p+|ri0&=-ieC=E->?@^bY^FUxjab2ND_CUlrRAGwmCz+fRu(+tqcybX!gT z`?hxuo|fIq4tCm-gtzz5E4S5>EWg9naGuYk8Na zb~{}#wAF$PP=FMI+2LzXWMi0{M!PJP*$!uv!UR-Vu8`bwOM?pw%evLI9j-MHX!03u zy#My(UPh{eTZ*D6-+THLe3e<|e1O zke#pzVlk?z#i7uO;;~3HEjGFuxL_uN7i}}i_V|;Kw>^SaEEtU@1R6~s(9vWyN)Zj9 zwrVXVy)qUEIHkGt`up#{zZm06Jpn2~OJ90AufFllVk)~bJ-4*78&5|<3Kc(_PN$N&bZ8!9^WOA&n9`yrrPLlxdkVNmJrCeG zuc@mur*6IW&c@8l+#;1vrgJM>n_=Xf(5lwfI-?Z($jsE_Qi5-3Wm_hBX#QlBK)X?_ znKVx-@px=1Nj?;ehzqHtpUl&Qz)fZ8-!t=h@wK(A1*_3%A9nsTgMF6o|776iSF9%j)>BpPN*R(|O_*(^vQD)#nQ%C)dk{J%BjS}AJF zoSN^~KE-P_pJ!^yiJEe{TqY-5A5u`0;mGvVawyIf7q-GVDkqe}6(izjqO+6mAFAg@F4`p4Lc{NE1lJ~S-n_NGwz4#L z>(;F;zLpkGO%0AU`4Bin3mGur)>P4h{0dqcTVCHs1G_ufNFtHgj3!fwSR&8M0*Fgz zITH9Vl)^lfM1rOS@n|Xuw*dZTL!HwIxCTZl5+CE6obdGYbdWDoOXcSJI-iP)6Mx7~ zny>YEJQgq?)3hd+$%cdBM1n^pG6E`{O64Y$0-;2YZOEvWaiMz)^gxD_PbXs0FhJW- zh_BM?v>LTS0zE-FomyT5$yi&Pn|lSB-l@^31gX#}*)|e-hib}& zn)1Q#CrJNnZx;%Q*#7SBZW5HPNNv(6WU=+->({Tpd3|9i2z|V_y_ZWxLg5hN;1t5p z#x1Pxw`LO=71F?T*i(fxZzmq#ftuk;)OcQ#N`}Lr4$zrVolZ^gv8!Zq-$CSzPOTPE zh^I4W(_1q$Gq;vwGEY-+&XBCM!8HW5tgo%cMY^UoyxKeZJ3AAJD6@wp5~=-UG9xh< z+S*#0{B{r7^MKFQdpv8Dk=8Op&)=K8HMJJ*Kg7-l74v50&dHUEcoSkiulNnNAIa(D zkW9wme2n&uqzgWV3?;o`CcddE8&6n%ht(WBs;xNwgi*qtO9PQ6uR;QlkJ@fWGFVS< zFQOfD_~Pw7AS^w4rFQz0a-Fqpuw~JQe10Gks}Xs2{GfuNG!m^GzgP!3yRTDC)-iQ=9zu#`t@M&le_;=&E9Hq2cySuyF z{P+QFgEzA6z#A#ro~)WM0wM3)6>Uh%Z4i&{XgnT?C6jPDCDUOzxuqs!N5_DWn+S6_ zEiKI*L!)O;oP6-i+2g|^-I}45bF-T< zXjwrzmlc|Pz6nJn72Mnn?H5O|Dn6ngl~rnm6Xh~9OR$Kb{)%E|rAA|Wd3{lhA3jS; zv-7_;a`NoFxrC>0Z1eaLsog_TFgrfD1>1HMo)any^%r56No6PBaZZjvu*xSoMV%;? zkDzULI$c-yL2bHe-Lj>paGM6GY3pB5*I2H&i1O?r(s{k)k%or*yPE?ofd-G)k2zFV z*V@n!kMC|Hym=?BGebqSwn9n4{pD!KXl63R_ZW+EZJsW8o!DhK(A7oaMA%V&`QP3z zneY>!kKj#kLHaNuma9wp3WE$VTa~^sd24ZF<<`4f&eA7HIK?O09Ad0zmKImmN>hND znO6>;A)C!HGrWMUskg7Ur@yzS)!#&tV3Due*4VhayA6NE?jA>NYwzvpfudk_1$Nlm zg`@>(N+uImDomOcgui#==Hyx=xUyIhq*9ywSsD8$mcT^0#A*V2O}W3OtSXoPm^_z? z!Wk|j5E+d|gFz(|7oz+7x699t;GLdc!ahDiitRM*t%y9Zn9X`&aI*L)TQfIGeSOJNqXntSS=<2`t#-KZtt_!gYX2uoGr-@DJtHZg)5Iz+kf*URR~kWH&fkQHE9+>FW~-B7oYq^GZ_-Zz-+bqBLKJG(*woei%^> zkw`eY6N$tHS}hm_eAumV^iXM)#(D(F;hmngwnBafR`_aCtns(BIAu7Fl=sd9t%}BB zZWj2w33E15T+fifr7k2^A;a{X%2teI_zYU_x_0fYw}3OiA$FUgE0#z!GFfP25s2s*;NmFpv7-@g2>$31f7iG zK-cG}c3R1_#OxYVQ?T$R_`guF3%^W7UO#kPxQ9(MNs1%6$rL;N*m zOS$|d$TnbfAmcTWvf1rUuZ5h)3S7V?WLQ!_nfM}#>-X~t@o-9s)DEp&Mqj1TvN+W-+iLY2upI8Nqp zXIIzg;9$|;ED+=b);gOYD|Wyf>gdq44xNAEi6<_fz3}u4pZnbBo_T_LoTlr9A|Oi~ z4IWX;O>S;)0U=)wW`pxUD#LEr?qvn<*Ay_MddQPhl+?W zRlBN)R9XGb?cX8QR)0r&@DAHDA8%By>H{Ao`?;e0`JJANRLB0BoZAb)iqM^#t97Ko#~lbS~EAh7B95727sy$9GaQEIX6r5 z=ce9#FUXDqgJf<-_9HOVxlU(Gv(IEu%MsKX?(?|p7K2^~gb3#(u~?~5t96}w61hw& zmGN?gdPHJe5lk=4Orja-+}w5+AwO!V0QqbxkRm8`>T8pvHNo8cwV(a)=d}2xAHVeO zjo1I`Km2j!3;=^_Ro~#ou&OfnVynsr-~S?+wQ4nVR%kbt+|LC8+$fchE}DL(!zP_^}4jt=-TwswaM98S~54c!nIA`oWlR( zQRcT(yaqC5X^Ge0=$*GVSiC+~>MxL1ZF-qZW^_54$tzIjwOP&5Y&x085HvTp_w?55 zgxOpw7KIEZ6CA*Hr_+uMCfcMjIH7Wy?RMlSxP5`vHr4*Z!c8=)n8P>;J(=y`%H$37 zr*Lj{V|Q&cR&}QR9_px^N2!`}w5Hr#Q?C8}TQ#5O%Vowtn#sKO`fD$x5R{;gCHVZY z(Y|3(F1#C5$|xRvMe15d>gw|H`ffN(OBd_Bb|h6;bxLLB=w8J)D*N~KgN6oPDYXJ=@apR3 zvM{qc4+q^wL8(A4?Ivx5L5VQ%SR%zqhqksh5mt~Zq)|a6jet9M2J0sxMXeG^A&%o^ zxR8iw4GNJcy0N~M;3?EB-pXW_Y;SA<8{OL6-X&YZc5vh7%P+tD)_kyl#V?9eH)$b4 zT;s`fenMBsBoTSe=f_gn==SzbCK1mG*qVr*qmfh1_A|jc2gttC%I5!hJBNW2kOb!H z;MPMJ@;)}FIhjmyn4D&0Fl*5{B=--AfQarC;3|TAeuB+vE`@e!gRn%|L}r&X8$@pE z=4PcN4MjT>gNV_Y#a5zXHv9Z_3~7={!i%z}jOvQI~>-P^Jq?@(tZI5#=J zpA}^HlURo}*^bUTRDXxqfz!9kuND-L^5bHA_O9|zXbS0AjP1m-`0Tl!$+N2Mh0)Ra z2Dh`Wx~quTx++0^UPmS)mtiz2Qa6h_?J{IVLQGdzbtSbemHJz~A(O3FC;Z&K&;I~* zk9D-zO%6vxgF}iQNO5g$P*Azub?rSp!^6XUoi!Ohx3|kDd8b`;3j#573~yM2xxkCS zeTD=H)QA^V*xL-H??EWGC&?L$ZZE|5_M(|wCLN8|*Y|efXp6X@-d>a0(Wo@TPk-Z; z>o_#jxlLxPPN5-*KT^yl$=b_5_}97Fd0N6mDUva&r>WFcb&B$O{TfOzGc~<%{YS6t zSu5u?w>Ym^I|EFfRoeF;`}ZBvUqFkvR9sX@@4xrn)GjQ1a9^lIayztwL@JdRghEiV zT{v>&$awd0c z3@Y#-0}6-NKnr!6gi?9iS}k^!kVEaSF3!`cnVH$y<+(Ris?vg0GEa%*xTUS7Qg+IXkYHD!RV5yBp?f6rAELBj>q3yT?ey6-t?cmqKVv#yTRC zsi`pHlnYsf)t1j2jBc;h_E0GESLX*jJ!eTsPil>&CbJ(*NX#p ze}5_!jbr12e;}3W?;jzIddPA{iZw)rOAB#ELmd)Mcyn_T;Zc+sDbrjoPYzH=g>67< zvezL($fd#R;b!(@6H<>`$IX4;>vLn|Bus$}^gJz{pAY7F=O(8~AuzuX<+ZkWJpK&y zxLjjED<+LbtJji1CZkrT(O^A04H~&rTEt8QWJTdwSWU}5+n<)7?U%{eH<T0GekPhGt9;Ndnmu1%$Boy6cB z8tm_YBNf5!Zk?b&mS6Rlu<6Oz$=8D~{q4_|i*t^Zs;?*&Te4M^){CXjLP7xLl`I~9 ze|HDFeYjfu{`K|ADQvU-V~6^fGzKPz8mHe6nhXdJ1)T&|)W*hSRm2}FiKj}ESXD_{ zEd3hf8WyD2VREd-IWP>QB0i5VmMgitGdET;Ss=SvvDoNzxY`4Ho-L7!r$wSehfW{s z>+|~Bo2d@k0g6yhL)+0K;{)xjUb_`QiOvxE@gIHn@ArbU3*Z@fPqC3K!9J)9H8py&J2h!@{~dgbTOGp?rm&JrJI}FbbM=2vZB5U?aN5`jW?^&T#GFFd5++qo7jUa4i$oH_r@`Rf z^43;kV}B3hYN+#dG&WK``ihnrnnDT&-Bx-N6exspMK?dcl~w5E8mrY}x0N(t)kI_G z_vg`m4{}LcIs}4vibNxb#nQY$aP;V<2Qj^q;W*stS$IAp>GZ(Bi3f<&rnmRR32F>M zH#}}}7iO4Vu-r6^ipB0@u>?x*VLc)lzo*sh^>SD}#`qknXJ|3wUnNoK$xEl>ibUtn zKk@ikyw05QJH2Ea@G?6b&p!JrU;gx`KP|?;Pk-UrXQ`)3Gq*%H!wP{N!)k3Vy{9h< z;Y9k8$=ptB7AdHw3IQonp#Pho)WL&&NQWOsdn>kfiPmMfN6t<|f$TIhiRhDl*#VToWao15G7 zYimHd$k5R$ubV?1434LiJ60OFqKH19+1yxskDW8mqxCL!A9DEXT~^F7gD#T^hRCUj z&50N!R&|$CWe{Mw?@(fMYd@t!Iy#vORDnwE?}igOVo=LdC6XFiQ?{q=`Op%6H#f)5 zm%k($iDY!b}z;}2g1Q$YLB7}@qZo9d2^ zwRsvGIC2P0)}|qRbmS;}@33M#@^o%~eqm*Gh2+N6%A{ov9OVw;*9A&g|DTXvYO`Z! za`lX!!0Y6(<0HL&Lx%<%8$bQ&uYB?8r=Nc6xz9cGsZV|C@kbu=`;8=o>$ztidGw(N zI(*)z9zXx+3GBhL)+47M`o!bpyVS$juVe;)alaxPmUwfU>{lhB3ds;NjBZ9~>{uJj zjzzgV9&cS)#5oKrJC6U09mmR@skrVLlrW9-_3rK?V>tVE^bd9cD{OA4i^XtSOe8Rx zY|deaw%OHahMASu+3K2_$#iu!<`>L)+>(TmsZ_8&ynjRG|TP-@Wo&m;|;&COXis+}6_KVuGX7>5M|73)=-W5(ozjAgqY& zfku07s>fH?i3yFdg3FSJSwE+{U-isROD^`y;(9wotU0zh!i|2V< zDC?+**-1ULo7wfZcT$OsL@Jr05Lc&KnVkdi+tN}noz06>?3fZPc0i?xY=!r8>5bLp zj8TWME|1&2wpNp+$0psm>pJ_p`+H#e^ady=J)sdaz**r;1c^RI{vPTCBpRXS3W_b=W&c{fA`r7Ji zFTMzni0y;Ffh6=8yliP1uYhF}S_3nlzOm8iY-=CAaQUfIr_Y`}_1J}T7cX6MdEDMc zY$)x=$?MpKlP9IpOaaDfU8^?G9O&q1>+o?XnL+>Q$Hs@LOY{{Dd+d}8W^#HEqky=Y9u<~7U=+0xdTLEyQSObnLxffKOEVsK3S)7 z>ubTiFkdC(&~}f%qt%j32!-1vVkUISKfV0!wd>c>C5+GM3Yk$$OZx&?h|b2wk>lfS z7QJ4u51{Va&tLlIf0(@&J_&A-pu)DfxfKNJh!>mFD zB$v@Mok7o6R_zT&BXAmc-7bz{RvMOJv?#s=v}gICy5{Ug%HE}Yrx%u0o{ znr!Kg3-L~5O`^7rzC&YUR5xwl@P+WECL^gT%B7l}g;j72WmBnlN|a6D)+zVSeNkJwD_?l-i8IH}o;%mtj_Al^XD&TbUdw{s-UlC~p08cM z6(W>yv62W3Ym}ZNmQ_6h6*L-)HUqN@XEG*Z@eEsiO!dM48`=Z7FPFR9dxsD8v^Tkx zV$8x&cn`S+N&_)M>*@r|SyoP~e>gk)gK7)=!(_V~qt*9Fe~HDZER1leOjfqSFX0Y| zafDzi6=JzO5Ewmo`LR!4I(K5Ur5Bg0 z;Hm?oyq0B)^PwzTOl*)wE0d^eDeUezumH|#>F)p@g8`09;BQu_yV)yjw;eFxv1dqZ z-vG~x*5x1Ebw(KskcG+YJ!L>#$aLSM~Xqpj(>T z;H`%Hh1{!7cJ!U#FIHX#LDCy|+&iIacahWG(gGluo3cvpRB?@dD2`l<$7M?uNAAx_ zAA#341;{GH5lM7Nm}qbt4bA|LLrr#t2>wBl26nXB?y|RalPF|D)C=g(575(M(Ti+) zp%ON*7>B@N*f}Fjh})Pz^17gq%}tCI1e>cX*to(X9a4^=4}p*xB^-eOvi4MC z2W%0IjcScltg13>NkoBazeE;MavqgTSVBNig|K>x=No3N%4815$H-=ebhPH5=jMJ0 zl?J+tkCax;&N4A0)W2Ir@J5HQDb@syQ>Wu@UkH$v1LZSv3 z;BuI?Mmt`JNYGiWpZLV{&*Cun$iojm|2)t-1~x?hL!}l8Kn(l)i!&ex0gv&AAcqWN zhSOy>IzAF)+Sdobl+m8|0h*Rnr`H43$;@jeW01?W+dq;J%wE?@o1&Oo>|Xa#v0DZ^ zE}jX7`}!IXM?gY3!3;&hxAzg1mX=v<3`h4J7(yBBm{HUmgkw~nxs7~~e3CQw$|?ig z)?$O~39mRBZXf6kHn-8-2ex;cprfO}8kpIDQ@)my-;I4hi$%b+Qq-c|$n0Z{Hmwf2 zO=qYd>2B);M%~?+&Kes%I(z?x3m3-Q{S7{_pAYx7H+2ZtmRHxpDFKNrQi((|nMz~t z<@bQ5rkxfS3P{k>^3$1#KAsTiO2O?NM5HXtzxnc#s1Yy%rT$FB1#db@w$$J^0w?o*aGP#PJig?zIo|$x%VQvt>ANpnXJB670b>{c(TN9P|q#4PxWhH=zKK%+AX*f347@mOs z4!2sJz^Bn@d~1Kdy?qq;4$)NssHy@Fm*Bxpp!FQ5!>*A??_V2E;K*CroBoiDQuAQD z%3wB`S;A9M!v@2fYI6e7_p!vOm9(h`q^S}qC{uezNBE#jMUX^kz6cn4(@9#yP^JFx ze_S0ee6sL$(OJ8U@$%)N3{&1DZW`?;c~?5HUxu^CtXUJCCN zTV`x6m6{uj#1iR^<(<7Izp7f@+#Y0v7$Xt`h0rNlT+}wI9F3_|t#wURVO4B=07W9A zSZ(`%l9kk`kpg$-WJCLTmDOk|lNa65?!27=X+Y9k*9zFZw}EP-jU1cD?XYS!PAh+N zlBF*~tHkYCX@TC(fut;QAaxA)w%0efl&pMz2Kf|x{>6*`etb;Ygw-0S{vA1)m`EWZ z4=Fpje02W$7P0x8Da9$lU<6w6Xca&qQ!`N=}z(4n)Z5Ie}Y=ZSZ|8qHW+yGdeo@P!T^ z{PwiExSlm4ZAp|`YoQvsKx7mSTAN#Bh}hE`YejwG3lBYf{_N%ZwxS-zuE@q;vH;XQ zT2ZYfFZD_EgHI}LaVurXdXvS3WbIs@uQb8yGB|(p*8Fr%AnL?nwHcU_-(Tk-_hTts zUN22$t}3#WA0hTi>-E+5TD$9tJHs$)v`caGmP|smd2#cW%SG$6-gLU;K03ip5y{thDD zJO0D&tp_(Lv3)2m_92<)0~(Nx6bkXVS8h=@i;Tlp%Y{f9v+H;cn@XW!FxHk(Po z4`Y>uc?xp_jF<)!Qr`M-6qG4b2${)A6dIL6DhRJ3G(8&Iiz@^IT$PTbE{Oo<6SfO^ zej5zu8#%eBzGOp@XtzW8Jjejy7|l|#T;2eRgdSYuJbIkmN7L8l?}Rscs2RRRPgf^$ zL3_J;EoL>aYQ05YhZO)8VX4n2k^zrFZ287_B>8Msz)hx*dr3QxMhI}lBFK@*`moFG z1jtRr;=5q>SPhkM!b6b3Q2?aJ#1(@*CYNhy03EV_1Sxy{1Lw~(1i(z@eDoj3*5i8_MM`y1e*Q^bmK8^cU2FFjH z1nWwcjBp{BOJuY4^)k8IH;kytzW#IPVCW;)pdhICc|0E3&cf;}_+DOrnYlO!*PiV} zz-;_sGLU>Ex<@KObgCTQWU2J<;iE?oL~;1=`1$kXrcbriYV%2i0%!^Z?s*#P>wTaf zulG~u5YB*zoi>-*YT_)<)AHHbHPZ0`KXMV0R}BgBsY@ez?WOl7CnvY}6UO$|jyBe^ zV%Jf&xQ^IWHc{p>V#_<_@=qb7KqgZvc`>>rnJjP>2&x?&J%8%ZkOBCJ&Dzj#>ePkH zmo8m?a;UGlzPI<8XP$cQOJDugZ~Xc=b(Ny)f9tot{Oq&OKmXv#Q>RWGJA=zIac}jR zGj(-f`nK6zjjag5Z1z!Kg&W4_Yj9xo%0}>P7 zePHWpKICw;HMxK15u{N?8Ig24ueUj zB_0xoRtl6IHE0B>BmniCET5;re?)P3BA!qwL!NgbTZ*bCcFLjd{ytpuULu!4J~Ms~ zx(V>=rMr2$26LblS$Dwrnwz^@TanlekS`p@-*zYzfPKJrLNQyp_>iTgt(y7Q_T$A_ zz*6^At2qi>{;7dA13N;PA_Bc>rQ4smyV!{H|G>whT|6G>x*O~39C}=HtRX=PDh>Wm@2IP9 zberTncFtcR&qa8Uz@8e%-(ECIvX$XF`7o*(@XX*3G2mc>saAQFbF$1e!@D=GnX6@P znJ+-H9)}KEMrm>(X9PzmXPt*IbzHo7`7+SWix)3F^pM43G=XiyY%wDlrK_XfVWA$R zb-A3@+uGCJ-Q+RpkZP&6N}viP<=*ra)6`yl@j84r;oQRHMy_a8eV(+60TK#%AW#8l z<8*of?ArwoKYZ!JgXb?^JaNM3^VijTdzB`m)#h|LJRSnElCVm#uij)@TZ6Zsu!Y&I z79$vk*H*Wa_4QsK&G-2={9RhMvbr!oJAGYm0O-%|G)&}V|IlDxaSu6AW%BI`fySm!j z5ogxf-A%FyL8XS9PTLD4+({hjm-fBx;Hg8H8&%}ugGk% zxsGtL_kQ@sJD9A>*_pVG5B;E!v3p9zFDmDkq7SS%rNgE!eJIDh}JAw7fL^MPCyW$51&N zTU)z(y0ANSb&CZ;<|Ze2Z+d%&Muta5MuvwE)Ii;tGw08rIeYfp*>gPDP)L!wz4`t_ ztxXN}ZZ#$mNm*uP>Oa(0`DSG&uqQ~yw=p_4GNoa{j*gBE_jPsmwzqaQAf3q;U>x#2L|HO6erb=! z{=K|1BbO_baw&g8&06_Z5eGPGECwsT0-39A2{`*e_sBS`kwg96-TgzuP1xt0FeV^D z&*vzVX}N`WE%IyGfxSQrL+UE=CA2ghGfK ztF_!wNNQqx;#u@W=gBi?PM$n9FbM}&19XobnY;deyZ!q}> zhK`*?UsOH)RXI8Qy!zq|=r*F<)My!!RESd4-v14)InB!Q{^+vVZ6=-GWHTCn>6gCn z`Om%Z!WVz-C>XM?;-7EV{q{G%hP!IeCot0M+Pga28$H&~zVMab_~PY<9)A4mzxwkHXhR4y!*SNwNSK3|_mSUC2=kiThmVgUhp>56Jl@)F z&gae4ks2v*I|u0}hH{`h7S)qh$M#)m6r(Yva$~E~);AV)DdA0S%Vxv-_rqz-+v4+g z?v>b>U89UQgU4l9o1{j-lWw2iC=^go_$i)JtB`GPE0j`?%cMp+gIXnBS(#d(_%vGl{5~!?Br(4U*p!=8OFs#K1T$_maUM^>Xm(XZRr(ky1J77<|bNPVRkjb*V z7e-9@kvD3YG({qz5a2YXn2_Fj_gcw1XNlxqLE8*2 zD;LR~sZ=^0qhbP(H|PlZ20O)X%yA2Rfuw-_aRNKab|S_~1@-6hA!mqNNQG&Hgc}Wu z^GmDCtIJDkDUN}iqu<4>9Fq&Fc#a!NtV~0U`F(+=0kIA(unK|J!LbrI)b{sG4GA@TQ(PrvmoyqR?7sB!E7#{)F<3M0jW<_TmKLW< z6yWNWmDk=#MhR8IK1ZJw3yOO(iF+6gh1TG3w>&F#mzo53WptB7PU*AO;U#XNph9tv&ZeJGg^+y%Z5DQw^j zNrx$^!Df+*%{r^AwY96OzoX9A_UMQ}z<*yTqqpC&W@$)}!v1hKB22;^+*Mk4vh;r@q@9_r~Fx%`EvFP%IOtzKcu zR4oUCRvX#{hnY!j>pRrSJn`y$@rhr5eF{&^4X*EJxDp%eS7|sYN`fHtNWkZ#F@0fz zheKi9Nl(k=~rHS`4{ir z3t^rW%PF#toTqe3!pUL|=Q%tvE|%l%bJO?6qepgRL3}He0?SVmSbh%mcG_$JXUt}; zLMm1mt+;Cyb~{usz;B{L^9oCV*zhIFAMG5Hb_)T_-S*C&I-AYX+H9OqZO-2O#mg_h zI|Mc~ ziTi<#)RnLi+-9EQiYHp|klEbR^D*$Ds~;U8Vm!}GWJ7l!q9=NB-!Oonj`1T$@OSEg z2P6`qNP=ra@y8Jg4<9~q1OVY_vtGj2>0tI9IeX#B#~y#?fhMO_=J z#zLvxtpm2(8pMdTc1l#aV0{8rE`cwc<7dwfHUda2&T;09avOHi4x8rbwt&mp)b!9p zmp}38=f3u(FMZ=zzV^$X`NSiSj1G+rjvqaGyo{Z6b@5J}qMo@o0EJ&7>(xf9$a<+| zskE3qLos_sDkYUdF5J706MHIy6y~hVZ0hVB6!DS~E*Bvvox@}2PM*4O_S}h)&h~c1 zk7?oHaWOkvXMY#B3(&LvMlkq3Yx7p|>6ZHCLn(xr(IftV5fGsR#Z{{{>XH?F`ZSE> zOK!VHZnGiZ2)UQfAz$aS&prJxKImv^Ywqst9U%Eh)aBB7|D~UlOk&nvEBLL0N-> z?4?zTm7MH=0r@i(XbY1$SlM(xYs*0M#IcP>A`SxQ@!q*L|O z#Y5?Ey|d(*_*1kv76|Bc27|>S#TDN;yDRhnlugj6ZN&G}8n|P#m5`I73UeBQGnm6c zj-`Zf%M&@@0%dhn!K2(>yEFoGhl684QJ*{pNjNJj^nKl|@}2v+ zS>+U3@FDf6spIY-ThsVBKsF{mwH9cDmy7}0Dtu1_HI$K=3L}(d({q>D^sF{p6_*~m zt)&I`e<8wyMc{=+UOhN12(KQwl&i?9CuLamWEFGwd9?`-3eQ|+nd05cPEZK-EACsaK0N^bVtf^-uXPB-K9T(C{H$5Cdw-zV84PV9$?U> zNF)eY{s0hV?CcD(;R5iYs&zW8R;N{~&zw1T?lf?(vu6*$C55jZDH;bzsSY$57{Pv| z{lGDxyes;~@BvzFMuR;Z-U;IDgIjC!Wq`2(O0>>mEHEw@vG@RBg!i_WeQxkv>jiuU zWSoBUt+yGF5%g>%UAF`>RzXVa9-stN+~J59R~?H$myiHvzj8G zPB_E_(EB7)iPbsxc5MXMzcJa15|P-q;U0Y|n$Bc-axL6}^$j>=0xMP)K-H2K8k*q? z9cXQ^E0MP4K(A<#^`g}Rt}%NW8<5iySX&DfK+L4qBZ^od0C`*@NfIUCz>gzY7_cxQ zcxErM`5vxWiLbHe6yggUQ>mJpKUV8kKSJ~DPR))*TM?CE5epZ5pe%0xdqg@}HX{&< z(rJ~+SI=yX17kx&>>ffO&OH3l;I$h7E`3-jE#O*tq`nUvIsVY8ht7|YKuxX%_<$~B zF{{)DhXy2nvNbCdW%ez1^su?H3X9e2tlh0F+`;p2sn}|985M? zHE`3bSy%wOSZ?|Ti=^qmbi^^uZN)2n^l%WcPIR0~sa=>f#d*r)VR&3kox~$cI2`W| z*bpF>c&$p-R_pN*+z!e-KD!qb4V9`5oVCNXT2{2;^N%!u(~9kVCbBM(`02mgsMSz@ zMnf5H*HBp4V87DB52DF zi+;nh)9@r?M3KQRdI7~{7a-IHyoOMaJmNB>#f-nO4l0CwMs^J(sM0;GY&l*ovnwW5 zwPJ$h7=;U1F%3G6l$*`1E#vYYwaK81L~z%EA4DZk7h0W8DhREEN7X@zaMMtMtM&W2 zi#KlESWXbgDw&;7BE|!EXn_IVCKl()_b{1a2jBZy-2=d93 z>j|Lkwd>Us<^`p-3K4O(x-vU2GpGz=}D|_D!j|N*N zj4hpqhk62?eT^*+quF@=!m*J9ZE$;Z5Q!ebF-Ka~2;vm2;&Cdq0HTG|rQjnHB}I{F zqM{~XPFTUYYtzBqy@>GMjU}%co0e8iqJC8wi$<xY_4p!g-c!X~fR;Wo;+ zMJX6(%xEu)0>a!|U#B2I~o-WGbX{LMkWM%Ro~z;ZH|H@CgJX`_UK)dQMU}K^d@E z(qIK7(U?XKmM5zrn0UX@fv(2(p89}ETOADXL7UKp(H=M<5vxV8;nF7XA)63;-X;4( zvCmoklPl^Uwp3!iBUWVJPk;LV{q(0l{lzcvpP&8gU;p{%KY#Js+-xEt7AF!L@Ls^G zjN_QQ7Ywhz_4Yf{TYH2#Jxa|2f+INb=4LfUEZ*3Ze~6dx_t7dnW-=3(QV!fKAMG*< zUY!=J^NLPt)3F#x)df5EaUlg`*^-@FQt!5^O?IWWkfK`=K!|i9?8(GYTC;B-gnGnq z!$7?qQ62|7HMKayZim&IW5q^Q#AbJPD@KV~8Q}XLJbd4F%EgPN+|~QDQ!37gB&McU z6yFA!AHWsDGA)pbJxvZooZ%k8eZ;rV2eWyB%3>8I=iYqt&FQq<1@p9?2X=9qt90yN z4e-ShwbkvlsbzAFR*E&MK^1)PmT-9p4Dl+d*!%ClznKUP4|~~F@if|O&+|Cgk9XJi z2pQ*ISb_^sqfux>troG(h`hjsS?vgww#&J4r-?A)(;B`+D#XnOYcorPQ92@of6b`m zBL^X$#RUNczTD{dVHIUB3ME$UtjjfB$Db^UUYI^0i<8))znf#lzH<`ma9s+zT&Ugn#7JiF1!! zzI^%8<%b@B=pj&80Tn!mMTEZ`*M@kS9(d-lUX7RyM%2t8z6ZgGw?l+wqgf<(1w5?R zL{Slp2)8-0xNQnvDIAeK129&;RZ+AQ5GCjI)qC{{J%$1oeVCNsuaJqN!dkP zyidIy#8#k{_;g!olEO6gK3HZIY$;S5Vm(jER8m&UE z+Yat*@8BBAic2>NTAKla@lk9oVAg<$nq0t%tGzdH>7`7sY=m}Fi-L&hT3=n8UtQhhNcp%~ z(&f@P+-CD8k}cL_yUBD47YFKa0Z1fNzW)P~==TM!fhN1wRM%Gdwq}>xOzs7#e6RXm zkO^)iUWyv~Ya%TJXH>R!pW^FplOMrk&6T}KTAcXk@>lopqmVgZ&oK0e6+PT+)_XgU z-9HdkXw+lO3Rgs%I+}XW(}Frpp)|ol+$IG@USvsajC|^uIS6Czp+uo z>>nJ~CxILJ{O-09aMca8^fYO8dS_clKO&t65O_jMm5MiByEQj6O(JqWr0%PvzX!TH zTALsnVp)1SI)GV5nfO}fp3$_#h`AYP>+D915-{+Ap`HdUOhkpHse9=3!w)}vdWb^b z($YkbjbR|#fW0`bQp+Ne^Mk?|80~WmvUooENrT})TikB@!|qo{WwJ<3d{d>Z&#t&X zCr{=P6Df$!6A37|u>2qSZ2!k!3jZ?3OpQ`;M%m4yYq8naPma=T#f7?GI9i^X79 ziQZV#B3ecZdNhuWWDm5oXmA%VCmanUAxxlAIO~9+a*KS4e}HzzbTT}fLT@xk^U&`J z36GDs2dOxo(8~4ZmH-K3K_CkVM$G|_NzH?o6`mc!T`iDE0k`S|dV}6Xd1<;y0b(ND z6x3TM+qv=H^>?oAMo}{wM*`Kx^8DOpMqnc@k${pvp_5ty9Y@FOISZ4M@4t8by`?B$ zEGRxb$rK8RL>%mIxH66C2|Xeh$c-X}7}z&9cN1KZ(&9#@pU0+?Q!4t8cy3(JFdr zzy&Cpix|2ncxC9I5Dy9{f+0m|xeTESff=chV+iar6LvELGM*_PvLxfv{UL1EDDdf@UtgRs4+BE38m3Q zhA{(zs~Z`QXu?{mi!9I3YA7Cyc!7M_su~IH$F)t=GnsP8#JPx061y%p~AFxww@OF(0nSSiIU>>DWk8L6Ku^kBPytP~1q`6HSdj5t)DLh@1KF!vBB0LMMSnZop2N;oOV zD|}LLBOzr^mlWP&hp2Q74<0=&U=EavO=Ko*1~^%FI&30C1%qm@TqzKw%zTfP9KPUW zc5`^6R4rr9p%gasZa24gbX4$(5{OX}2@UAD<~+|4O&{~zoaOJ~?2W;ob^Q45XZYZs z15O+g8}iogP#9!3;u&Baaexbz6|Te2FTSzDB3V_*{9n&;qqn1JZ?B;JpfRuPqKGBn z{(UTqoet&|(tEaC&$cqc`3-p6QvlIGuaxc=&MF3Xxn(}TaesDt|6e-NnI19D8&0=W z+=2HF_6JlueUtG}Q%q_Md*o5e>F*mF#$?O&gbQ^tVlHZ~bcfZ7X?qjz zu3S4jtfd97lSmzTGK!7t<7%yl5@|eiwltJw0xHY(swf*J9rs_pnR=n5Atn-#TpK|-2-#1ejh!%KIo!wrN>w? zGjtfbof4Liz5T+`ZWe_~!}3u9-R^S4(e7}_;}&jSsP}0IRMqFSX;?k+ARF*Y_88#) zf_=DFA?yjARVj>t-!S`g4*l`h zYKLfJFp!-NH}Vnfx{5yw6`fMk7Ohyk5ZdH(#NhX>;4q zWl;CbIKxg@Q&b~O*(Tr1IAW(-l-=brj%-r4iKiS#L<^Q=Sm-oksTB$hrqlD(T%wPNH zSw^F#KL5!Ws+(iTXrovvHV3>STL&$Cq}G6e$Y3DKnR2PI$uvskv?!@gER{}GGDo>_ zomH`N!)B>m9I_Zy?68fT$qv;^!{uz<#HY3+9~#3en=RJb=5_C*Z`TzOZt8Zq3jw#2 z>k5w4@;ub})y0@k#R17CTOo;k;dqizHo++$ih8V?08wYreUK&~KMkiNkPZD-sZOyy zvdZu56e$s>bc+BDEw@MXrd_aUNa$6pHY<5&wqduM7By!Ok`nsLWCq+qZ&vLtjMvmk zGIin7jXBaT4ybmv{)@>;d96q8|J$q#*O;TS{Z|prbM7ors_g?@F^X?FY2i)G#I4f-4rmx#}c%4vIe&WplEaPde21I9J*!`OITl`WJl?e7r| zl6JY>tW;{P{*dr6x@5l!5RJ`Q21~ymN+$q?s9_gw2c&_w#{(i0P$7Hpr10dRR&8|5 z(MW3R68PYf$l_OAT4LE0zCj#|Ry>|!$En3d7DSW_@Vw`8JioX1L;OPSrW-5vA@DaCj0Y!&1{!Q-uKNa+n3+0A$x_X>^&*5joBjz=Ik-H zSl!&ZihIU}J*$1tvD$}&XtdSx`2~S?1@P-{*Zt9Gy9V~IU;QK}TbT48Tpm;@%^uop z2WJ0`{Um>j%6`#)@D6Y%_LIA&agWK!h7AZHA%ySa-Y0uhczqd^jsJEsu8ga=PN!bTV&TuCjvh&=1iBkOaL%~O zY7H*0hpD|!?(H`k=2gFmw$9B(MQA-Q|A(sdU$oO4Lo(W69g7I`(NCV3`_{0}^M&og4!;Si*k5kKyi&xK41p7)ruUk~9y_>` zidZp+TmAlaiDgUF zJzXrO@w-VC(m$GvX!0C%u|W3xV6x}mV9)gxAHwGL1wcU^)EppZxnDKRS5u zU_9;*H-qJ8Pw#&8(MNXy{PApiYqvBWKYIAtouB;2|MK%abHo^us63TRq@SC_M5cDl z=lyS_pP>A~QaA*qf=lH`P}GNlG znPLe2Ie8AaFGzV)yOujdgsuQrCG`DD%ht*sx3G>>YoI5iX<#}?qD5w_hdbN*`}ZGg zRgQ9U_34xQANzj#X{E{7k+f&!Tl=si_i7+k)o9@8r4@SIgIR^rsR43H;@xWb7<0o23wDw9``8O?$kxDeT+r=sL5E& zZxBb{?a0<SR0rxYXsZ&S4U6OelIb1MO< zcMPxFVZMn49Y6G-LpYpZ48meo!8cz&B1o2d2VK!Oi_-JdLc(KJU{8apOGWHRS5RYy z6Y>ql?n^+h%?8ymrKi&=nz!iikGNG;5?JD#@L*C%5>Q}Z@3%|EMz7w2q3IuXnuTXi zpYG;M^&0;5?AcMf+iJE3sH6_jycNo}rtmbiI;(TdgYBoEeSEjj;*ma_nykbBfjpJl zL52YXormaY80@jI&+Rh|w=fu|VJ?!PVsj#K5`qocKQ0^e<4}lZWME)#Y*;tukyvO` zV42S>56I9M>|G|EMkcR}@qn+E;IkR&{`HI)63pKh-U9$IKRz?sIo zJUDL4w9T$-Rt>JfsjRv8LO((E;`WBftTg%=BnDt)$)6II^h zf>{twMCXD)5oOtGVZR#6$vI69_NDoKAqBIz$q3;d@f8)|nMB-GJLK;=FR@ zOP4R7IlaENyo`z?uRk2Y)6XHtYB+Qc!+u3!7?PA|u_F~Dh?p$|j=%Kh|M@4M@BiY@ z{+(t;hA0?2nB#JG=de<(r7hJGP^1*Qng;9_>R^zt&6dk7T`fMoztiZIDx6U55f!&m zNh^rE;YU{>FDjGKL%c8HX1xrDQ5iEHqmSwt6 zaOqop+Vl`lG(n18fYQj8dS2dqCzBP{MO`(sktDRm7fUNE$SePWv68#72P?VTO@84q zgFVZ(D644=7yCJ8&^vgR%@-O2nuk9Zv21rcS7^XeXcTg7m){rkxNJNP?&mzv{o!8Y z#=VKWxI@0FW!wS+1<0ApZRl@gIzajF0)w~2 z1)&a`I;Rl1WV2fQsd&gA2**}tbgRwSsoK0Vv)CI5dR6H7>Fd1elp6cnyIcDQrDk_1 zsJdHs%H8@%ZRdA$MM8pP(E)-U4Ed!-zJz%1uwBYGBv2ei0$$ycU`MSof<^g`Iw}hQ zgm&tx03?gj!TuJcbwhHhQ*W}3*fM~P6*!C+mSJTmOmM72m1z`yXnY&8+TCOq!braO z{$y32=ucB-P-AWEPS-}_yI28rBAfx5s>pzZO(0WArbq}QpF3)@VhBOB1-BD%`G8nRduN02XA97nLo@=qQ=J}j4N^*s21AD0>D3~*~gA$G{K(+o2be>fj!DB`xK zn0Pqo2A&W-3`hKEE)s@aE@|#iMB{rzJT?0k%|~j*Ru*Uc$o4y1M@c_5fO@Uy3O<{n z%P5{5P!_ALUIJimzfp#7SYt=sa+bJY%H0vG`KNUm{|arp91eFh66CnV(oE2eTiARN z2fvwu7OyC$PcKK@o{bH+CP?liAXCdiGSpPp!ktIcSv24Bamt96)RjH={h3 zbVvshZP_~PbPo;+1BL;+98{~BSh%g^9m?!qx(i(P*8^s3JX znw4Z|VFjMlbFaR7c{aKhLm2|dfNR!)f9*Wz-u!+P?)zw><6l)n(VzPTkO}TV3IKd zV^i!GyQAt}Wql(W@zU7`kN1wr;6PCApk-RHWGtL}8Bz1XC18-8*?9fkx2_R&m? z0r1>E59Z^f6SGWoE?j)$#`*KVhny6}Xnxx4-vp-#3}>iQ8q;S-Ww=zDqU3P2S~Q)_ zPNesrlJ5du_|XWsU;z^Q0iaV09t{{@Vj!4;6K8g25fvf{pKoUE%&MPf+Lel3ms!W^ z+yCfafA3;4>i23Az9r919Mo{s`_sH8w)2hA<^lwM@$=6g?HpIyW2OOf^V!Z}9SAWs zD7H{{utE`6cQO`pZd#7-{`Avl@W*Ug6)f(4V1A7n#lqD3e-k}T{54ZWXg(Z1K~tgY zUHlKns`c(Ke){2uADUEmgn=5=5g&f^;a}ao3#jijeV8@J0Lf{d&1R!f^7||AjJvuj znyCL4=PoTtJOH}&HV&tE)0 zi#0wFvIj%316@tHa(V>H!e$FCoO;b>TUxf+mI2we$j@%TcD56K+PJfKm_w~X-yf*$ z>=`r@UNH0a#sUIm1G`&jHZ7mtegsZLgpeEvoizRaeqVsQg?5FE0Nh0W8WJ}*mAJ-M z^~6?oy7eBO;Bb^MW@Swzj@8!ayjO2x&qVzB}M1g<{aWD z4~{zHR(9w98-}9N5FEwLJeLz9w)r^U$PU z7F{}WJ^16}{>P^LVIQE&xytponAPwAgY3hI7x+%sJ zR4Qpx*8LNC9>8%KOBT5^%U)(PH>L0_CCahHz_nuD<3)enmvH{?!+agTj|_q5|Hz!O z!!#k5kh;7GIWUhTkW(v*R@GiA8R%6ryX7^o_sQj zPJ_1I_%E$sobAF!@%!=j$$T?r)}+a`SSxfn_$);WVgAf_+79VIc~s=+VQww9&0*v1kjb;`TzvFaSzuo zBk45TpT8?@H;m9mxNR%0(dFP;6-bXILR&H# zzlmP0oP=l(1{Y`Hy2o1PNd$vWp8Whag5|~vX>hN*k@kLKIXnsBj3odo^^>P3*iXNS zR^~y2IJ0UM zz<%G`-96ab*_sb{lQT2(3xvp-Hpf1s3*`nkBlxBI0pLMUBMSjoLJk3)=IQ+57V30_ zFJE7nn|bxyY3AwE>2>eox@DJx02fmLy#>R-aj^|0TZ2*qtJ?+CNVZok_N}UG7WKj@ zI1vF05BDu>8``4LrM<@w$AX3Kw0i)`Oh9v;a}M|Sc6av5Bi77lo@n8t^kfYD30k}! z;*~iN3Shulpi7ag2Ve$`t8n7LXt8<-GxuD~>4kmm1iYu)LqO`>cDI7G)9!%L0@4hj zjM+f(PXfi|5R*s?z}O$AFO3I1QUa4mc=$o50BD%c_nIt_hZ2a4b9m{JjAkwLXuP}} zRam-pOgtf7!Rk*fPv*i?(mzc{;7;Z^tBTm3+a{?ziBeSRU0x58NYi4%^D7@=`ET(I z&mRG7Y|BRHkiw0Ohe_v>@V&rugx(?EPI^cEKYdEFDqyL=a{s;|hhB^_zxZ!sC1j>+ zm?6K$<4%Sw55Mv{L)H%&W;pyG!&y?acn$o??s9JOTBs?f&WAD#gtZE zf~yYZ;2232NM8`(LunD`PN6lH;70N|Z5)D45R(*7jlfncC7oL=w>v(lqR$WJaPz9<^m-Bv7PvDg{*esH$%QtG6vjKcn%j?07%dH%NQ3rQ8J+Iuvjz&uRK!Ri%9`h6n=lNFj*sG=q3Lz|LH&f-S__44TPQk z2;{eaMEyZVhDWZ-*nBF3T*1ki~;`x!;zHxL|!Ft}6g^Z=rLduw>G37ZSy;C7UnQ?N)Z4`i z4#ST;oaQl*ToKk~7wNQ{2bCsaXixe*c6eySLbQ zJfLK#+AXGDs!isMIc~^61Ae!7qlYfPreEGj1R|^~aLKMU96eR*#`{J}!=W=18%kT@-~3hnW}Su*Tk6 z&Eq+-_X5uKlRR%~Cgt{ccz$WlkG#Sz2I*|n9mx2x^!ehaPj8T)xXXF|t*^cL&Oi9Z zo9EWgo2?ml@WKPfYN4f5OK?@4xLL-3e(RlgzlGFi4^VQT+pVod9d`2$ z#OjhTJ4St^@Y(HYpaZ)kNs)m}E?N}$*>nuwkQXjoPLQqON(gyTc@)X4!)N9SBT1b6 zgmo=GgG7s1at87S43=IbZ&<^t8pk~b__Q~4y2Zs7B_&@dijcZ-+A2E z9&%ZDTQIpC@VR_*XJ5Uvb~a=G?BV^re13PY*=x{Xs@#1##TzoN(8Ye2*1WS})N@9b zE?v8HA#+n_X%%a}*K7C8pT2f>eszj&@at$};Y(N7R?pq|H9$?DBH+f$Yk=N83%UC0 zm36q2mQJmo2igh*?dz{+7Oa{r0R8*YRX}-OymA%a5!@|ni%ZB!S>L$u`rE*DyLRRJ ztFK(XWIn|;v=I7xf9J+)Z~yvt-us8|e*4|;{?>PY`yc<#-~HCR-~G;SyaTf5U(1Bd zo4$GmNx?JtB9ZXQh3nU@e(9@U1+s*BA4D}}37-!Fn}rOPgpuy$=`PKK5@vQa?#3Uh zabx})v#HZ(I?%hE0k@EmTeahk1UoFn#*vjworNh2QT$Re*f`OeY))V4F^ zu1}D>8|SZ_PKN5+ow36g4XD;CXMEcIJ!UBjyb` ztiIHl3uiVm_I9rW25io5SM@DcwYR?V+{K@H6S z(lQ!?kPXEUMoAIlX*rI05RArwXS}|Ql!Ving-*XK*lc_(2_m^@B1tTZV8o-_;pFoI zfu3B2!uGk@_|$W2=udTcKt%9~BOW4Hgzj{Wo55StJT7T8v^kJQDDDVGxJh+F0W#?M4-O;ymK2D zFPy*r#v33qHSbI0*ZSVh=eK`RtAp|Q?%i(l@WBJb#F2{p*`rTx-+l)5E}#AU&Ye4e z^WH}OTNXK`h0OX(jjW;0>&&T*(^!w^mJs%eL+s7XuU;hI{LIGL^XJyr%odGoPrHqW zZN=Ffo(S?fBW|Y-b<<`?sbbil3Boj6J#+C@u=KtL`m6bw^XDN2f&YB&{F$lbCi#QD zWYmXL-$W`IOCp3RIJ^!OwLh#PXMAxlAp^E?Xhjr!GeFyq6Ips{-Ry;NJ=V8U6K6o# zr4fJwraNJ8a6^anRT-E;Mjc?i$}qA80dSB~^_tTWWN0)tchaTXErSfqu+bqF>Z!hC zjJ>dvrIpZZMo=*UKs;f#JhA2GOXZlW@xo~3ZWDi^?jyX?k#ZPS6PHYM07FqE2^X_P{1oRou?$?m%jKn@qOT{pTD9Tlr<+gKW1hZm*?jQ>BmhO zgYG%$$Fv$6q#tR<8__d9J?ufj*Nem-WoiB@*k~sD(ht%8b<`>lQWNBI_xlqtC4v|F z)vqGXNR)dx5?;G;?MEt#3dd}tpg?Z t3=+)GE@JF#YWy6c1d( + + Design & Schriften + Raum für deinen eigenen Stil + Design gestalten + Wähle einen Hintergrund. Die anderen Farben finden ihren Platz. + Typografie + Wähle Schriften für Oberfläche, Lesemodus und RSVP / Bionic. Alle acht Schriften funktionieren offline. + Lesemodus + Oberfläche + RSVP & Bionic + Design anwenden + Neu beginnen + Diesen Entwurf verlassen? + Nicht angewendete Farben und Schriften werden verworfen. + Verwerfen + Weiter bearbeiten + DEIN LESEZIMMER + atmen + Tonal + Analog + Komplementär + Triadisch + Ein Farbton in sanften Abstufungen. + Benachbarte Farbtöne für eine ruhige, fließende Palette. + Gegenüberliegende Farbtöne für ausdrucksstarken Kontrast. + Drei gleichmäßig verteilte Farbtöne für ausgewogene Farbakzente. + Hintergrund + Flächen + Text + Primär + Sekundär + Tertiär + Auto + Fixiert + Palette folgen + Diese Farbe folgt deinem Hintergrund und der Farbharmonie. + Hex-Farbe + Gib sechs Hex-Zeichen ein, zum Beispiel #A8B5A2. + Farbton + Sättigung + Helligkeit + Fertig + Merriweather + Lora + Lexend + System ohne Serifen + System mit Serifen + Nichtproportional + Palette + Eigenes + Paletten + Farben + Schriften + Raum zum Lesen. + Textkontrast AA + Eigene Designs halten den Lesetextkontrast bei mindestens 4,5:1, auch bei reduzierter Helligkeit. + diff --git a/app/src/main/res/values-es/theme_strings.xml b/app/src/main/res/values-es/theme_strings.xml new file mode 100644 index 00000000..e84bda81 --- /dev/null +++ b/app/src/main/res/values-es/theme_strings.xml @@ -0,0 +1,58 @@ + + + Tema y fuentes + Un espacio para tu estilo + Personalizar tema + Empieza con un fondo. Deja que los demás colores encuentren su lugar. + Tipografía + Elige fuentes para la interfaz, el lector y RSVP / Bionic. Las ocho funcionan sin conexión. + Lector + Interfaz + RSVP y Bionic + Aplicar tema + Empezar de nuevo + ¿Salir de este diseño? + Se descartarán los colores y las fuentes que no hayas aplicado. + Descartar + Seguir editando + TU RINCÓN DE LECTURA + respira + Tonal + Análoga + Complementaria + Triádica + Un tono suavizado en distintos matices. + Tonos vecinos para una paleta suave y fluida. + Tonos opuestos para un contraste más expresivo. + Tres tonos equidistantes para un toque de color equilibrado. + Fondo + Superficies + Texto + Primario + Secundario + Terciario + Auto + Fijado + Seguir paleta + Este color cambia con el fondo y la armonía. + Color hexadecimal + Usa seis dígitos hexadecimales, como #A8B5A2. + Tono + Saturación + Brillo + Listo + Merriweather + Lora + Lexend + Sans del sistema + Serif del sistema + Monoespaciada + Paleta + Personal + Paletas + Colores + Fuentes + Espacio para leer. + Contraste de texto AA + Los temas personalizados mantienen un contraste de texto de al menos 4,5:1, incluso al reducir el brillo. + diff --git a/app/src/main/res/values-fr/theme_strings.xml b/app/src/main/res/values-fr/theme_strings.xml new file mode 100644 index 00000000..d09a0e8e --- /dev/null +++ b/app/src/main/res/values-fr/theme_strings.xml @@ -0,0 +1,58 @@ + + + Thème et polices + Une place pour votre style + Personnaliser le thème + Choisissez un fond. Laissez les autres couleurs trouver leur place. + Typographie + Choisissez vos polices pour l’interface, la lecture et RSVP / Bionic. Les huit fonctionnent hors ligne. + Lecture + Interface + RSVP et Bionic + Appliquer le thème + Recommencer + Quitter cette création ? + Les couleurs et polices non appliquées seront abandonnées. + Abandonner + Continuer + VOTRE COIN LECTURE + respirer + Tonale + Analogue + Complémentaire + Triadique + Une teinte déclinée en nuances douces. + Des teintes voisines pour une palette calme et fluide. + Des teintes opposées pour un contraste expressif. + Trois teintes équidistantes pour une touche de couleur équilibrée. + Fond + Surfaces + Texte + Primaire + Secondaire + Tertiaire + Auto + Fixée + Suivre la palette + Cette couleur évolue avec le fond et l’harmonie. + Couleur hexadécimale + Utilisez six chiffres hexadécimaux, comme #A8B5A2. + Teinte + Saturation + Luminosité + Terminé + Merriweather + Lora + Lexend + Sans système + Sérif système + Chasse fixe + Palette + Personnel + Palettes + Couleurs + Polices + Une place pour lire. + Contraste du texte AA + Les thèmes personnalisés maintiennent un contraste du texte d’au moins 4,5:1, même avec une luminosité réduite. + diff --git a/app/src/main/res/values-ja/theme_strings.xml b/app/src/main/res/values-ja/theme_strings.xml new file mode 100644 index 00000000..4f51ebba --- /dev/null +++ b/app/src/main/res/values-ja/theme_strings.xml @@ -0,0 +1,58 @@ + + + テーマとフォント + 自分らしい読書空間に + テーマをカスタマイズ + まず背景を選びましょう。他の色は自動で調和します。 + 書体 + 画面、リーダー、RSVP / Bionicのフォントを選べます。8種類すべてオフラインで使えます。 + リーダー + 画面 + RSVPとBionic + テーマを適用 + 最初から作る + このデザインを終了しますか? + 適用していない色とフォントは破棄されます。 + 破棄 + 編集を続ける + あなたの読書空間 + ひと呼吸 + 同系色 + 類似色 + 補色 + 三色配色 + ひとつの色相を、穏やかな濃淡で。 + 隣り合う色相で、穏やかにつながる配色に。 + 反対の色相で、印象的なコントラストに。 + 等間隔の3つの色相で、バランスのよい彩りに。 + 背景 + サーフェス + 文字 + メイン + サブ + アクセント + 自動 + 固定 + パレットに追従 + 背景と配色の調和に合わせて、この色を変更します。 + 16進カラー + #A8B5A2のように6桁の16進数を入力してください。 + 色相 + 彩度 + 明度 + 完了 + Merriweather + Lora + Lexend + システムゴシック + システム明朝 + 等幅 + パレット + カスタム + パレット + 色 + フォント + 読むための余白。 + 文字コントラスト AA + カスタムテーマでは、明るさを下げても読書用の文字のコントラストを4.5:1以上に保ちます。 + diff --git a/app/src/main/res/values-pt-rBR/theme_strings.xml b/app/src/main/res/values-pt-rBR/theme_strings.xml new file mode 100644 index 00000000..12b489e0 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/theme_strings.xml @@ -0,0 +1,58 @@ + + + Tema e fontes + Um espaço para o seu estilo + Personalizar tema + Comece com um fundo. Deixe as outras cores encontrarem seu lugar. + Tipografia + Escolha fontes para a interface, o leitor e RSVP / Bionic. Todas as oito funcionam offline. + Leitor + Interface + RSVP e Bionic + Aplicar tema + Recomeçar + Sair deste design? + As cores e fontes não aplicadas serão descartadas. + Descartar + Continuar editando + SEU CANTO DE LEITURA + respire + Tonal + Análoga + Complementar + Triádica + Um tom suavizado em diferentes nuances. + Tons vizinhos para uma paleta tranquila e fluida. + Tons opostos para um contraste mais expressivo. + Três tons equidistantes para um toque de cor equilibrado. + Fundo + Superfícies + Texto + Primária + Secundária + Terciária + Auto + Fixada + Seguir paleta + Esta cor muda com o fundo e a harmonia. + Cor hexadecimal + Use seis dígitos hexadecimais, como #A8B5A2. + Matiz + Saturação + Brilho + Concluído + Merriweather + Lora + Lexend + Sans do sistema + Serif do sistema + Monoespaçada + Paleta + Pessoal + Paletas + Cores + Fontes + Espaço para ler. + Contraste de texto AA + Temas personalizados mantêm o contraste do texto em pelo menos 4,5:1, mesmo com brilho reduzido. + diff --git a/app/src/main/res/values-zh-rCN/theme_strings.xml b/app/src/main/res/values-zh-rCN/theme_strings.xml new file mode 100644 index 00000000..274c5947 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/theme_strings.xml @@ -0,0 +1,58 @@ + + + 主题与字体 + 打造自己的阅读空间 + 自定义主题 + 先选一种背景色,让其他颜色自然相配。 + 字体设计 + 为界面、阅读器和 RSVP / Bionic 选择字体。八种字体均可离线使用。 + 阅读器 + 界面 + RSVP 与 Bionic + 应用主题 + 重新开始 + 离开此设计? + 尚未应用的颜色和字体将被舍弃。 + 舍弃 + 继续编辑 + 你的阅读空间 + 呼吸 + 同色系 + 邻近色 + 互补色 + 三等分色 + 以同一色相呈现柔和的深浅变化。 + 用相邻色相营造宁静流畅的配色。 + 用相对色相形成更鲜明的对比。 + 用等距的三种色相增添均衡的色彩。 + 背景 + 表面 + 文字 + 主色 + 辅色 + 第三色 + 自动 + 固定 + 跟随配色 + 让此颜色随背景与配色方式变化。 + 十六进制颜色 + 请输入六位十六进制数字,如 #A8B5A2。 + 色相 + 饱和度 + 明度 + 完成 + Merriweather + Lora + Lexend + 系统无衬线 + 系统衬线 + 等宽 + 配色 + 自定义 + 配色 + 颜色 + 字体 + 留白阅读。 + 文字对比度 AA + 自定义主题可将阅读文字对比度保持在至少4.5:1,即使降低亮度也不例外。 + diff --git a/app/src/main/res/values/theme_strings.xml b/app/src/main/res/values/theme_strings.xml new file mode 100644 index 00000000..7a4585f4 --- /dev/null +++ b/app/src/main/res/values/theme_strings.xml @@ -0,0 +1,58 @@ + + + Theme & fonts + Make space for your own style + Customise theme + Start with a background. Let the other colours find their place. + Typography + Set the tone for your interface, Reader, and RSVP / Bionic. All eight fonts work offline. + Reader + Interface + RSVP & Bionic + Apply theme + Start again + Leave this design? + Your unapplied colours and fonts will be discarded. + Discard + Keep editing + YOUR READING ROOM + breathe + Tonal + Analogous + Complementary + Triadic + One hue, softened into different shades. + Neighbouring hues for a quiet, flowing palette. + Opposite hues for a more expressive contrast. + Three evenly spaced hues for a balanced splash of colour. + Background + Surfaces + Text + Primary + Secondary + Tertiary + Auto + Pinned + Follow palette + Let this colour change with your background and harmony. + Hex colour + Use six hex digits, such as #A8B5A2. + Hue + Saturation + Brightness + Done + Merriweather + Lora + Lexend + System sans + System serif + Monospace + Palette + Yours + Palettes + Colours + Fonts + Room to read. + Text contrast AA + Custom themes protect reading text contrast at 4.5:1 or higher, including when brightness is reduced. + From fa8df8b8b87c6bb623685b109dfcf0bfc386d256 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sat, 12 Sep 2026 23:46:51 +0100 Subject: [PATCH 3/9] feat(theme): persist appearance and apply fonts across reading modes --- .../preferences/ThemePreferencesDeviceTest.kt | 59 +++++++++++++++++++ .../java/com/kairo/reader/MainActivity.kt | 7 ++- .../com/kairo/reader/core/model/Models.kt | 6 +- .../kairo/reader/data/preferences/PrefKeys.kt | 3 + .../data/preferences/PreferencesRepository.kt | 8 +++ .../preferences/PreferencesRepositoryImpl.kt | 18 ++++++ .../data/preferences/UserPreferencesMapper.kt | 5 ++ .../reader/ui/bionic/BionicReadingText.kt | 26 +++++++- .../reader/ui/focus/FocusModeSideEffects.kt | 9 ++- .../reader/ui/reader/ReaderScreenParagraph.kt | 21 ++++--- .../com/kairo/reader/ui/rsvp/RsvpScreenUi.kt | 11 ++-- .../reader/ui/settings/SettingsComponents.kt | 12 +++- .../ui/settings/SettingsSectionPrimitives.kt | 6 ++ .../java/com/kairo/reader/ui/theme/Color.kt | 1 + .../java/com/kairo/reader/ui/theme/Font.kt | 28 +++++++++ .../java/com/kairo/reader/ui/theme/Theme.kt | 42 +++++++++---- .../java/com/kairo/reader/ui/theme/Type.kt | 33 +++++++++++ .../reader/ui/theme/KairoThemeContrastTest.kt | 14 +++-- 18 files changed, 268 insertions(+), 41 deletions(-) create mode 100644 app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt diff --git a/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt new file mode 100644 index 00000000..b8b41596 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt @@ -0,0 +1,59 @@ +package com.kairo.reader.data.preferences + +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.encode +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ThemePreferencesDeviceTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val codec = RsvpProfileJsonCodec() + private val mapper = UserPreferencesMapper(context, PrefKeys, codec, RsvpConfigPreferenceCodec(PrefKeys, codec)) + + @Test + fun customThemeAndAllFontChoicesLoadFromStoredPreferences() { + val theme = CustomTheme(background = 0xFF172C29.toInt(), primary = 0xFFEDC892.toInt()) + RsvpFontFamily.entries.forEach { font -> + val stored = mutablePreferencesOf( + PrefKeys.customTheme to theme.encode(), + PrefKeys.readerTheme to ReaderTheme.CUSTOM.name, + PrefKeys.readerFontFamily to font.name, + PrefKeys.interfaceFontFamily to font.name, + PrefKeys.rsvpFontFamily to font.name, + ) + val loaded = mapper.map(stored) + assertEquals(theme, loaded.customTheme) + assertEquals(ReaderTheme.CUSTOM, loaded.readerTheme) + assertEquals(font, loaded.readerFontFamily) + assertEquals(font, loaded.interfaceFontFamily) + assertEquals(font, loaded.rsvpFontFamily) + } + } + + @Test + fun existingAndUnknownPreferencesKeepOriginalFontsAndBuiltInTheme() { + val defaults = mapper.map(emptyPreferences()) + assertEquals(RsvpFontFamily.MERRIWEATHER, defaults.readerFontFamily) + assertEquals(RsvpFontFamily.SYSTEM_SANS, defaults.interfaceFontFamily) + assertEquals(RsvpFontFamily.INTER, defaults.rsvpFontFamily) + val stored = mutablePreferencesOf( + PrefKeys.readerTheme to ReaderTheme.NORD.name, + PrefKeys.readerFontFamily to "REMOVED", + PrefKeys.interfaceFontFamily to "REMOVED", + PrefKeys.customTheme to "corrupt", + ) + val loaded = mapper.map(stored) + assertEquals(ReaderTheme.NORD, loaded.readerTheme) + assertEquals(defaults.readerFontFamily, loaded.readerFontFamily) + assertEquals(defaults.interfaceFontFamily, loaded.interfaceFontFamily) + assertEquals(CustomTheme(), loaded.customTheme) + } +} diff --git a/app/src/main/java/com/kairo/reader/MainActivity.kt b/app/src/main/java/com/kairo/reader/MainActivity.kt index 185d8138..55e9d618 100644 --- a/app/src/main/java/com/kairo/reader/MainActivity.kt +++ b/app/src/main/java/com/kairo/reader/MainActivity.kt @@ -86,7 +86,12 @@ class MainActivity : AppCompatActivity() { CompositionLocalProvider( LocalDispatcherProvider provides container.dispatcherProvider ) { - KairoTheme(readerTheme = effectivePrefs.readerTheme) { + KairoTheme( + readerTheme = effectivePrefs.readerTheme, + customTheme = effectivePrefs.customTheme, + readerFont = effectivePrefs.readerFontFamily, + interfaceFont = effectivePrefs.interfaceFontFamily, + ) { SystemBarsStyleSideEffect(readerTheme = effectivePrefs.readerTheme) Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/kairo/reader/core/model/Models.kt b/app/src/main/java/com/kairo/reader/core/model/Models.kt index 3002dace..0c4ccbe3 100644 --- a/app/src/main/java/com/kairo/reader/core/model/Models.kt +++ b/app/src/main/java/com/kairo/reader/core/model/Models.kt @@ -324,6 +324,9 @@ data class UserPreferences( val hasSeenStartingTutorial: Boolean = false, val readerFontSizeSp: Float = 18f, val readerTheme: ReaderTheme = ReaderTheme.LIGHT, + val customTheme: CustomTheme = CustomTheme(), + val readerFontFamily: RsvpFontFamily = RsvpFontFamily.MERRIWEATHER, + val interfaceFontFamily: RsvpFontFamily = RsvpFontFamily.SYSTEM_SANS, val readerTextBrightness: Float = 0.88f, val invertedScroll: Boolean = false, // RSVP-specific font settings (decoupled from reader) @@ -365,6 +368,7 @@ data class UserPreferences( ) enum class ReaderTheme { + CUSTOM, LIGHT, LINEN, MIST, @@ -379,7 +383,7 @@ enum class ReaderTheme { FOREST, } -enum class RsvpFontFamily { INTER, ROBOTO } +enum class RsvpFontFamily { INTER, ROBOTO, MERRIWEATHER, LORA, LEXEND, SYSTEM_SANS, SYSTEM_SERIF, MONOSPACE } enum class RsvpFontWeight { LIGHT, NORMAL, MEDIUM } diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt index 744dd9eb..d1f41480 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt @@ -77,6 +77,9 @@ internal object PrefKeys { val blinkEnabled = booleanPreferencesKey("blink_enabled") val readerFontSize = floatPreferencesKey("reader_font_size") val readerTheme = stringPreferencesKey("reader_theme") + val customTheme = stringPreferencesKey("custom_theme_v1") + val readerFontFamily = stringPreferencesKey("reader_font_family") + val interfaceFontFamily = stringPreferencesKey("interface_font_family") val readerTextBrightness = floatPreferencesKey("reader_text_brightness") val invertedScroll = booleanPreferencesKey("inverted_scroll") val rsvpFontSize = floatPreferencesKey("rsvp_font_size") diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt index c50a11f1..e5d975b5 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt @@ -34,6 +34,14 @@ interface PreferencesRepository { suspend fun updateTheme(theme: String) + suspend fun updateAppearance( + customTheme: com.kairo.reader.core.model.CustomTheme, + readerFont: RsvpFontFamily, + interfaceFont: RsvpFontFamily, + timedFont: RsvpFontFamily, + theme: com.kairo.reader.core.model.ReaderTheme, + ) + suspend fun updateInvertedScroll(enabled: Boolean) suspend fun updateRsvpFontSize(size: Float) diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt index bd2547b3..0c7c5e81 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.kairo.reader.core.model.CustomTheme import com.kairo.reader.core.model.ReaderTheme import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.RsvpCustomProfile @@ -18,6 +19,7 @@ import com.kairo.reader.core.model.RsvpProfileIds import com.kairo.reader.core.model.TimedReadingMode import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.model.encode import com.kairo.reader.core.model.profileCadenceIdentity import com.kairo.reader.core.model.withReaderPreferencesFrom import com.kairo.reader.core.rsvp.RsvpSpeedControl @@ -298,6 +300,22 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo } } + override suspend fun updateAppearance( + customTheme: CustomTheme, + readerFont: RsvpFontFamily, + interfaceFont: RsvpFontFamily, + timedFont: RsvpFontFamily, + theme: ReaderTheme, + ) { + context.dataStore.edit { prefs -> + prefs[keys.customTheme] = customTheme.encode() + prefs[keys.readerFontFamily] = readerFont.name + prefs[keys.interfaceFontFamily] = interfaceFont.name + prefs[keys.rsvpFontFamily] = timedFont.name + prefs[keys.readerTheme] = theme.name + } + } + override suspend fun updateTheme(theme: String) { context.dataStore.edit { prefs -> prefs[keys.readerTheme] = theme } } diff --git a/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt b/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt index 7e46c229..55a9bcb8 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt @@ -73,6 +73,11 @@ internal class UserPreferencesMapper( return copy( readerFontSizeSp = prefs.readOrDefault(keys.readerFontSize, defaults.readerFontSizeSp), readerTheme = readerTheme, + customTheme = com.kairo.reader.core.model.decodeCustomTheme(prefs[keys.customTheme]), + readerFontFamily = RsvpFontFamily.entries.find { it.name == prefs[keys.readerFontFamily] } ?: defaults.readerFontFamily, + interfaceFontFamily = RsvpFontFamily.entries.find { + it.name == prefs[keys.interfaceFontFamily] + } ?: defaults.interfaceFontFamily, readerTextBrightness = readerTextBrightness, invertedScroll = prefs.readOrDefault(keys.invertedScroll, defaults.invertedScroll), ) diff --git a/app/src/main/java/com/kairo/reader/ui/bionic/BionicReadingText.kt b/app/src/main/java/com/kairo/reader/ui/bionic/BionicReadingText.kt index f8d69aae..570effb8 100644 --- a/app/src/main/java/com/kairo/reader/ui/bionic/BionicReadingText.kt +++ b/app/src/main/java/com/kairo/reader/ui/bionic/BionicReadingText.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString @@ -42,6 +43,8 @@ import com.kairo.reader.core.model.shouldInsertSpaceBeforeToken import com.kairo.reader.ui.rememberWindowContainerMetrics import com.kairo.reader.ui.rsvp.RsvpRuntimeState import com.kairo.reader.ui.rsvp.resolveFontFamily +import com.kairo.reader.ui.theme.LocalProtectReadingContrast +import com.kairo.reader.ui.theme.contrastingColor import kotlin.math.ceil internal const val BIONIC_MIN_FIXATION_STRENGTH = 0.30f @@ -128,20 +131,20 @@ internal fun BionicReadingText( val colorScheme = MaterialTheme.colorScheme val isDarkTheme = colorScheme.background.luminance() < 0.5f val textBrightness = runtime.currentTextBrightness.coerceIn(0.55f, 1f) - val textColor = + val requestedTextColor = bionicBodyColor( onBackgroundColor = colorScheme.onBackground, onSurfaceVariantColor = colorScheme.onSurfaceVariant, backgroundColor = colorScheme.background, textBrightness = textBrightness, ) - val fixationColor = + val requestedFixationColor = bionicFixationColor( onBackgroundColor = colorScheme.onBackground, backgroundColor = colorScheme.background, textBrightness = textBrightness, ) - val activeBackground = + val requestedActiveBackground = colorScheme.primary.copy( alpha = bionicHighlightAlpha(preferences.highlightStrength, colorScheme.background), ) @@ -151,6 +154,23 @@ internal fun BionicReadingText( } else { colorScheme.surface.copy(alpha = 0.78f) } + val protectContrast = LocalProtectReadingContrast.current + val renderedSurface = chunkSurfaceColor.compositeOver(colorScheme.background) + val activeBackground = if (protectContrast) { + contrastingColor(requestedActiveBackground.compositeOver(renderedSurface), listOf(colorScheme.onBackground)) + } else { + requestedActiveBackground + } + val textColor = if (protectContrast) { + contrastingColor(requestedTextColor.compositeOver(renderedSurface), listOf(renderedSurface, activeBackground)) + } else { + requestedTextColor + } + val fixationColor = if (protectContrast) { + contrastingColor(requestedFixationColor.compositeOver(renderedSurface), listOf(renderedSurface, activeBackground)) + } else { + requestedFixationColor + } val safeFontSize = runtime.currentFontSizeSp.coerceIn(BIONIC_MIN_FONT_SIZE_SP, BIONIC_MAX_FONT_SIZE_SP) val lineHeightSp = safeFontSize * 1.48f diff --git a/app/src/main/java/com/kairo/reader/ui/focus/FocusModeSideEffects.kt b/app/src/main/java/com/kairo/reader/ui/focus/FocusModeSideEffects.kt index 813d4acf..af44706f 100644 --- a/app/src/main/java/com/kairo/reader/ui/focus/FocusModeSideEffects.kt +++ b/app/src/main/java/com/kairo/reader/ui/focus/FocusModeSideEffects.kt @@ -8,6 +8,7 @@ import android.content.ContextWrapper import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowInsetsCompat @@ -17,7 +18,6 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.kairo.reader.KairoApplication import com.kairo.reader.core.model.ReaderTheme -import com.kairo.reader.ui.theme.readerThemePalette @Composable fun FocusModeSideEffects( @@ -59,8 +59,9 @@ fun SystemBarsStyleSideEffect(readerTheme: ReaderTheme) { val window = activity.window val controller = remember(window, view) { WindowInsetsControllerCompat(window, view) } - DisposableEffect(readerTheme) { - val useDarkIcons = !readerTheme.readerThemePalette().isDark + val background = androidx.compose.material3.MaterialTheme.colorScheme.background + DisposableEffect(readerTheme, background) { + val useDarkIcons = background.luminance() > DARK_ICON_LUMINANCE_THRESHOLD controller.isAppearanceLightStatusBars = useDarkIcons controller.isAppearanceLightNavigationBars = useDarkIcons onDispose { } @@ -99,3 +100,5 @@ private tailrec fun Context.findActivity(): Activity? = is ContextWrapper -> baseContext.findActivity() else -> null } + +private const val DARK_ICON_LUMINANCE_THRESHOLD = 0.179f diff --git a/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt b/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt index 607f4afe..924f8cb9 100644 --- a/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt +++ b/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt @@ -43,7 +43,9 @@ import com.kairo.reader.core.model.TimedReadingMode import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.model.shouldInsertSpaceBeforeToken import com.kairo.reader.ui.saved.displayColor -import com.kairo.reader.ui.theme.MerriweatherFontFamily +import com.kairo.reader.ui.theme.LocalReaderFont +import com.kairo.reader.ui.theme.composeFontFamily +import com.kairo.reader.ui.theme.readingColor // Rich text spans, focus semantics, and tap geometry must be built in one AnnotatedString layout pass. @Suppress("LongMethod") @@ -58,16 +60,19 @@ internal fun ParagraphText( val textBrightness = state.textBrightness val baseStyle = TextStyle( - fontFamily = MerriweatherFontFamily, + fontFamily = LocalReaderFont.current.composeFontFamily(), fontSize = fontSizeSp.sp, lineHeight = (fontSizeSp * 1.5f).sp, - color = MaterialTheme.colorScheme.onBackground.copy( - alpha = - textBrightness.coerceIn( - RsvpConfigConstraints.MIN_TEXT_BRIGHTNESS.toFloat(), - RsvpConfigConstraints.MAX_TEXT_BRIGHTNESS.toFloat(), - ), + color = readingColor( + MaterialTheme.colorScheme.onBackground.copy( + alpha = + textBrightness.coerceIn( + RsvpConfigConstraints.MIN_TEXT_BRIGHTNESS.toFloat(), + RsvpConfigConstraints.MAX_TEXT_BRIGHTNESS.toFloat(), + ), + ) ), + ) val paragraphIndent = remember(fontSizeSp) { diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt index 0d7b2e5d..d55ebe6b 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt @@ -40,8 +40,8 @@ import com.kairo.reader.core.rsvp.RsvpSpeedControl import com.kairo.reader.ui.WindowContainerMetrics import com.kairo.reader.ui.bionic.BionicReadingText import com.kairo.reader.ui.rememberWindowContainerMetrics -import com.kairo.reader.ui.theme.InterFontFamily -import com.kairo.reader.ui.theme.RobotoFontFamily +import com.kairo.reader.ui.theme.composeFontFamily +import com.kairo.reader.ui.theme.readingColor import com.kairo.reader.ui.tutorial.StartingTutorialOverlay import com.kairo.reader.ui.tutorial.StartingTutorialOverlayState import com.kairo.reader.ui.tutorial.StartingTutorialTargetIds @@ -258,7 +258,7 @@ private fun rememberRsvpTextColors( return OrpColors( pivotColor = MaterialTheme.colorScheme.primary, pivotLineColor = MaterialTheme.colorScheme.onBackground.copy(alpha = pivotLineAlpha), - textColor = MaterialTheme.colorScheme.onBackground.copy(alpha = clampedBrightness), + textColor = readingColor(MaterialTheme.colorScheme.onBackground.copy(alpha = clampedBrightness)), highlightColor = MaterialTheme.colorScheme.primary, ) } @@ -519,10 +519,7 @@ private fun rememberControlsChromeInset(compactLandscape: Boolean): Dp { } internal fun resolveFontFamily(fontFamily: RsvpFontFamily): FontFamily = - when (fontFamily) { - RsvpFontFamily.INTER -> InterFontFamily - RsvpFontFamily.ROBOTO -> RobotoFontFamily - } + fontFamily.composeFontFamily() internal fun resolveFontWeight(fontWeight: RsvpFontWeight): FontWeight = when (fontWeight) { diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsComponents.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsComponents.kt index 4be8e807..f5f8726f 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsComponents.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsComponents.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.kairo.reader.R import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.ui.theme.materialColorScheme import com.kairo.reader.ui.theme.readerThemePalette enum class SettingsNavRowPresentation { @@ -278,10 +279,11 @@ fun SettingsScaffold( title: String, onBack: (() -> Unit)?, maxContentWidth: Dp = 720.dp, + compactHeader: Boolean = false, content: @Composable (Modifier) -> Unit, ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val compactHeight = maxHeight < 480.dp + val compactHeight = compactHeader || maxHeight < 480.dp val navigationIcon: @Composable () -> Unit = { if (onBack != null) { androidx.compose.material3.FilledTonalIconButton(onClick = onBack) { @@ -397,6 +399,7 @@ fun ThemeSelector( val themes = listOf( ReaderTheme.LIGHT, + ReaderTheme.CUSTOM, ReaderTheme.LINEN, ReaderTheme.MIST, ReaderTheme.SAGE, @@ -479,12 +482,17 @@ fun ThemeSelector( @Composable private fun rememberThemePreview(theme: ReaderTheme): Pair { + if (theme == ReaderTheme.CUSTOM) { + val scheme = com.kairo.reader.ui.theme.LocalCustomTheme.current.materialColorScheme() + return scheme.background to scheme.primary + } val palette = theme.readerThemePalette() return palette.background to palette.primary } -private fun readerThemeLabelRes(theme: ReaderTheme): Int = +internal fun readerThemeLabelRes(theme: ReaderTheme): Int = when (theme) { + ReaderTheme.CUSTOM -> R.string.theme_custom_preset ReaderTheme.LIGHT -> R.string.reader_theme_light ReaderTheme.LINEN -> R.string.reader_theme_linen ReaderTheme.MIST -> R.string.reader_theme_mist diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSectionPrimitives.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSectionPrimitives.kt index 3ca4aa98..3b7602e6 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSectionPrimitives.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSectionPrimitives.kt @@ -275,6 +275,12 @@ internal fun rsvpFontFamilyLabelRes(family: RsvpFontFamily): Int = when (family) { RsvpFontFamily.INTER -> R.string.rsvp_font_family_inter RsvpFontFamily.ROBOTO -> R.string.rsvp_font_family_roboto + RsvpFontFamily.MERRIWEATHER -> R.string.font_merriweather + RsvpFontFamily.LORA -> R.string.font_lora + RsvpFontFamily.LEXEND -> R.string.font_lexend + RsvpFontFamily.SYSTEM_SANS -> R.string.font_system_sans + RsvpFontFamily.SYSTEM_SERIF -> R.string.font_system_serif + RsvpFontFamily.MONOSPACE -> R.string.font_monospace } internal fun rsvpFontWeightLabelRes(weight: RsvpFontWeight): Int = diff --git a/app/src/main/java/com/kairo/reader/ui/theme/Color.kt b/app/src/main/java/com/kairo/reader/ui/theme/Color.kt index 2a522c25..809ffa51 100644 --- a/app/src/main/java/com/kairo/reader/ui/theme/Color.kt +++ b/app/src/main/java/com/kairo/reader/ui/theme/Color.kt @@ -186,6 +186,7 @@ private val DarkOnErrorContainer = Color(0xFFFFDAD4) @Suppress("LongMethod") internal fun ReaderTheme.readerThemePalette(): ReaderThemePalette = when (this) { + ReaderTheme.CUSTOM -> error("Custom themes resolve through CustomTheme.materialColorScheme") ReaderTheme.LIGHT -> ReaderThemePalette( isDark = false, diff --git a/app/src/main/java/com/kairo/reader/ui/theme/Font.kt b/app/src/main/java/com/kairo/reader/ui/theme/Font.kt index cce70f19..f3bfec31 100644 --- a/app/src/main/java/com/kairo/reader/ui/theme/Font.kt +++ b/app/src/main/java/com/kairo/reader/ui/theme/Font.kt @@ -1,3 +1,4 @@ +@file:Suppress("MagicNumber") @file:OptIn(ExperimentalTextApi::class) package com.kairo.reader.ui.theme @@ -142,3 +143,30 @@ val RobotoFontFamily = ), ), ) + +internal val LoraFontFamily = variableFontFamily(R.font.lora, 400, 700) +internal val LexendFontFamily = variableFontFamily(R.font.lexend, 100, 900) + +private fun variableFontFamily(resource: Int, minimumWeight: Int, maximumWeight: Int): FontFamily = + FontFamily( + (100..900 step 100).map { weight -> + Font( + resource, + weight = FontWeight(weight), + variationSettings = FontVariation.Settings( + FontVariation.weight(weight.coerceIn(minimumWeight, maximumWeight)) + ) + ) + } + ) + +internal fun com.kairo.reader.core.model.RsvpFontFamily.composeFontFamily(): FontFamily = when (this) { + com.kairo.reader.core.model.RsvpFontFamily.INTER -> InterFontFamily + com.kairo.reader.core.model.RsvpFontFamily.ROBOTO -> RobotoFontFamily + com.kairo.reader.core.model.RsvpFontFamily.MERRIWEATHER -> MerriweatherFontFamily + com.kairo.reader.core.model.RsvpFontFamily.LORA -> LoraFontFamily + com.kairo.reader.core.model.RsvpFontFamily.LEXEND -> LexendFontFamily + com.kairo.reader.core.model.RsvpFontFamily.SYSTEM_SANS -> FontFamily.SansSerif + com.kairo.reader.core.model.RsvpFontFamily.SYSTEM_SERIF -> FontFamily.Serif + com.kairo.reader.core.model.RsvpFontFamily.MONOSPACE -> FontFamily.Monospace +} diff --git a/app/src/main/java/com/kairo/reader/ui/theme/Theme.kt b/app/src/main/java/com/kairo/reader/ui/theme/Theme.kt index 4ee5586e..9a5180c9 100644 --- a/app/src/main/java/com/kairo/reader/ui/theme/Theme.kt +++ b/app/src/main/java/com/kairo/reader/ui/theme/Theme.kt @@ -9,23 +9,45 @@ import androidx.compose.material3.MotionScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import com.kairo.reader.core.model.CustomTheme import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.RsvpFontFamily + +internal val LocalCustomTheme = staticCompositionLocalOf { CustomTheme() } +internal val LocalReaderFont = staticCompositionLocalOf { RsvpFontFamily.MERRIWEATHER } +internal val LocalInterfaceFont = staticCompositionLocalOf { RsvpFontFamily.SYSTEM_SANS } @Composable fun KairoTheme( readerTheme: ReaderTheme = ReaderTheme.SEPIA, + customTheme: CustomTheme = LocalCustomTheme.current, + readerFont: RsvpFontFamily = LocalReaderFont.current, + interfaceFont: RsvpFontFamily = LocalInterfaceFont.current, content: @Composable () -> Unit, ) { - val colorScheme = readerTheme.materialColorScheme() + val colorScheme = remember(readerTheme, customTheme) { + if (readerTheme == ReaderTheme.CUSTOM) customTheme.materialColorScheme() else readerTheme.materialColorScheme() + } + val type = remember(interfaceFont) { Typography.withFont(interfaceFont.composeFontFamily()) } - MaterialExpressiveTheme( - colorScheme = colorScheme, - motionScheme = MotionScheme.expressive(), - shapes = KairoExpressiveShapes, - typography = Typography, - content = content, - ) + CompositionLocalProvider( + LocalCustomTheme provides customTheme, + LocalProtectReadingContrast provides (readerTheme == ReaderTheme.CUSTOM), + LocalReaderFont provides readerFont, + LocalInterfaceFont provides interfaceFont, + ) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + motionScheme = MotionScheme.expressive(), + shapes = KairoExpressiveShapes, + typography = type, + content = content, + ) + } } @Composable @@ -39,8 +61,8 @@ internal fun KairoFocusedReadingTheme(content: @Composable () -> Unit) { ) } -internal fun ReaderTheme.materialColorScheme(): ColorScheme = - readerThemePalette().materialColorScheme() +internal fun ReaderTheme.materialColorScheme(customTheme: CustomTheme = CustomTheme()): ColorScheme = + if (this == ReaderTheme.CUSTOM) customTheme.materialColorScheme() else readerThemePalette().materialColorScheme() private fun ReaderThemePalette.materialColorScheme(): ColorScheme = if (isDark) { diff --git a/app/src/main/java/com/kairo/reader/ui/theme/Type.kt b/app/src/main/java/com/kairo/reader/ui/theme/Type.kt index 6514b501..d26b716f 100644 --- a/app/src/main/java/com/kairo/reader/ui/theme/Type.kt +++ b/app/src/main/java/com/kairo/reader/ui/theme/Type.kt @@ -52,3 +52,36 @@ internal val FocusedReadingTypography = fontWeight = FontWeight.Bold, ), ) + +internal fun androidx.compose.material3.Typography.withFont(family: FontFamily) = copy( + displayLarge = displayLarge.copy(fontFamily = family), + displayMedium = displayMedium.copy(fontFamily = family), + displaySmall = displaySmall.copy(fontFamily = family), + headlineLarge = headlineLarge.copy(fontFamily = family), + headlineMedium = headlineMedium.copy(fontFamily = family), + headlineSmall = headlineSmall.copy(fontFamily = family), + titleLarge = titleLarge.copy(fontFamily = family), + titleMedium = titleMedium.copy(fontFamily = family), + titleSmall = titleSmall.copy(fontFamily = family), + bodyLarge = bodyLarge.copy(fontFamily = family), + bodyMedium = bodyMedium.copy(fontFamily = family), + bodySmall = bodySmall.copy(fontFamily = family), + labelLarge = labelLarge.copy(fontFamily = family), + labelMedium = labelMedium.copy(fontFamily = family), + labelSmall = labelSmall.copy(fontFamily = family), + displayLargeEmphasized = displayLargeEmphasized.copy(fontFamily = family), + displayMediumEmphasized = displayMediumEmphasized.copy(fontFamily = family), + displaySmallEmphasized = displaySmallEmphasized.copy(fontFamily = family), + headlineLargeEmphasized = headlineLargeEmphasized.copy(fontFamily = family), + headlineMediumEmphasized = headlineMediumEmphasized.copy(fontFamily = family), + headlineSmallEmphasized = headlineSmallEmphasized.copy(fontFamily = family), + titleLargeEmphasized = titleLargeEmphasized.copy(fontFamily = family), + titleMediumEmphasized = titleMediumEmphasized.copy(fontFamily = family), + titleSmallEmphasized = titleSmallEmphasized.copy(fontFamily = family), + bodyLargeEmphasized = bodyLargeEmphasized.copy(fontFamily = family), + bodyMediumEmphasized = bodyMediumEmphasized.copy(fontFamily = family), + bodySmallEmphasized = bodySmallEmphasized.copy(fontFamily = family), + labelLargeEmphasized = labelLargeEmphasized.copy(fontFamily = family), + labelMediumEmphasized = labelMediumEmphasized.copy(fontFamily = family), + labelSmallEmphasized = labelSmallEmphasized.copy(fontFamily = family), +) diff --git a/app/src/test/java/com/kairo/reader/ui/theme/KairoThemeContrastTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/KairoThemeContrastTest.kt index 52abdd46..fb727ee9 100644 --- a/app/src/test/java/com/kairo/reader/ui/theme/KairoThemeContrastTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/theme/KairoThemeContrastTest.kt @@ -12,9 +12,11 @@ import org.junit.Assert.assertTrue import org.junit.Test class KairoThemeContrastTest { + private val builtInThemes = ReaderTheme.entries.filter { it != ReaderTheme.CUSTOM } + @Test fun readingTextColorsPassEnhancedContrastAgainstBackground() { - ReaderTheme.values().forEach { theme -> + builtInThemes.forEach { theme -> val palette = theme.readerThemePalette() assertContrast( @@ -29,7 +31,7 @@ class KairoThemeContrastTest { @Test fun rsvpFocusLetterPassesContrastAgainstBackground() { - ReaderTheme.values().forEach { theme -> + builtInThemes.forEach { theme -> val palette = theme.readerThemePalette() assertContrast( @@ -44,7 +46,7 @@ class KairoThemeContrastTest { @Test fun themeAccentRolesPassContrastAgainstBackground() { - ReaderTheme.values().forEach { theme -> + builtInThemes.forEach { theme -> val palette = theme.readerThemePalette() listOf( @@ -64,7 +66,7 @@ class KairoThemeContrastTest { @Test fun surfacesAndFilledControlsPassContrast() { - ReaderTheme.values().forEach { theme -> + builtInThemes.forEach { theme -> val palette = theme.readerThemePalette() assertContrast( @@ -121,7 +123,7 @@ class KairoThemeContrastTest { @Test fun materialColorSchemeUsesReaderPaletteForComponentRoles() { - ReaderTheme.values().forEach { theme -> + builtInThemes.forEach { theme -> val palette = theme.readerThemePalette() val scheme = theme.materialColorScheme() @@ -187,7 +189,7 @@ class KairoThemeContrastTest { @Test fun corePaletteRolesAreOpaque() { - ReaderTheme.values().forEach { theme -> + builtInThemes.forEach { theme -> val palette = theme.readerThemePalette() listOf( From 212b8b1c6349aa5e44b88dd5ee7c76f5dbc42f7f Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sat, 12 Sep 2026 23:47:15 +0100 Subject: [PATCH 4/9] feat(settings): add a fluid theme studio with a persistent live preview --- .../ui/settings/SettingsHomeScreenTest.kt | 11 + .../ui/settings/SettingsSearchDeviceTest.kt | 4 +- .../ui/settings/ThemeSettingsDeviceTest.kt | 213 ++++++++++++++++++ .../kairo/reader/ui/navigation/KairoRoutes.kt | 1 + .../reader/ui/navigation/SettingsRoutes.kt | 15 ++ .../ui/navigation/SettingsSearchNavigation.kt | 1 + .../settings/ReaderAndFocusSettingsContent.kt | 3 +- .../ui/settings/ReaderSettingsScreen.kt | 1 + .../reader/ui/settings/RsvpFontSelectors.kt | 28 +-- .../reader/ui/settings/SettingsHomeScreen.kt | 8 + .../ui/settings/SettingsSearchContent.kt | 1 + .../settings/SettingsSearchGeneralEntries.kt | 4 +- .../reader/ui/settings/SettingsSearchIndex.kt | 3 +- .../reader/ui/settings/ThemeColorEditor.kt | 126 +++++++++++ .../reader/ui/settings/ThemeLivePreview.kt | 86 +++++++ .../reader/ui/settings/ThemePaletteEditor.kt | 101 +++++++++ .../reader/ui/settings/ThemeSearchEntries.kt | 8 + .../reader/ui/settings/ThemeSettingsScreen.kt | 143 ++++++++++++ .../ui/settings/ThemeStudioComponents.kt | 164 ++++++++++++++ .../ui/settings/ThemeStudioWorkspace.kt | 83 +++++++ 20 files changed, 971 insertions(+), 33 deletions(-) create mode 100644 app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeSearchEntries.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioComponents.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsHomeScreenTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsHomeScreenTest.kt index b94dbb63..0b17f39b 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsHomeScreenTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsHomeScreenTest.kt @@ -39,6 +39,17 @@ class SettingsHomeScreenTest { @get:Rule val composeRule = createAndroidComposeRule() + @Test + fun themeRowOpensDedicatedStudio() { + var opened = false + composeRule.setContent { + KairoTheme { SettingsHomeScreen(actions = settingsHomeActions().copy(onOpenTheme = { opened = true })) } + } + composeRule.onNodeWithText(composeRule.activity.getString(R.string.theme_settings_title)) + .performScrollTo().assertHasClickAction().performClick() + composeRule.runOnIdle { assertTrue(opened) } + } + @Test fun checkForUpdatesRow_requestsUpdateCheck() { var updateCheckRequested = false diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsSearchDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsSearchDeviceTest.kt index 68a39820..cd0d88d0 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsSearchDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/SettingsSearchDeviceTest.kt @@ -41,7 +41,7 @@ class SettingsSearchDeviceTest { val index = buildSettingsSearchIndex(composeRule.activity.resources) assertEquals(settingsSearchEntries.size, index.size) assertTrue(index.all { it.title.isNotBlank() && it.description.isNotBlank() && it.location.isNotBlank() }) - assertTrue(searchSettings(index, "Nord").any { it.entry.page == SettingsSearchPage.READER }) + assertTrue(searchSettings(index, "Nord").any { it.entry.page == SettingsSearchPage.THEME }) } @Test @@ -86,7 +86,7 @@ class SettingsSearchDeviceTest { composeRule.onNodeWithContentDescription(text(R.string.action_back)).performClick() composeRule.onNodeWithText(text(target.descriptionRes)).assertIsDisplayed() composeRule.onNodeWithText(text(R.string.settings_search_hint)).performTextReplacement("Nord") - composeRule.onNodeWithText(text(R.string.reader_theme_title)).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.theme_palette)).assertIsDisplayed() } @Test diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt new file mode 100644 index 00000000..29a31328 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt @@ -0,0 +1,213 @@ +package com.kairo.reader.ui.settings + +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.graphics.Bitmap +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.unit.Density +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.R +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.UserPreferences +import com.kairo.reader.ui.theme.KairoTheme +import java.io.File +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ThemeSettingsDeviceTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun backgroundAndFontDraftSurviveRecreationAndApplyTogether() { + var applied: UserPreferences? = null + val restoration = StateRestorationTester(rule) + restoration.setContent { + KairoTheme { + ThemeSettingsScreen(UserPreferences(), onApply = { theme, custom, reader, interfaceFont, timed -> + applied = UserPreferences( + readerTheme = theme, + customTheme = custom, + readerFontFamily = reader, + interfaceFontFamily = interfaceFont, + rsvpFontFamily = timed + ) + }, onBack = {}) + } + } + capture("theme-studio-light") + node(R.string.theme_tab_colours).performClick() + rule.onNodeWithTag("theme-live-preview").assertIsDisplayed() + capture("theme-studio-palette") + node(R.string.theme_hex).performScrollTo().performTextReplacement("#172C29") + rule.onNodeWithTag("theme-live-preview").assertIsDisplayed() + node(R.string.theme_tab_fonts).performScrollTo().performClick() + node(R.string.font_merriweather).performScrollTo().performClick() + node(R.string.font_lora).performClick() + restoration.emulateSavedInstanceStateRestore() + node(R.string.font_lora).performScrollTo().assertIsDisplayed() + node(R.string.theme_apply).performClick() + rule.runOnIdle { + assertEquals(ReaderTheme.CUSTOM, applied?.readerTheme) + assertEquals(0xFF172C29.toInt(), applied?.customTheme?.background) + assertEquals(RsvpFontFamily.LORA, applied?.readerFontFamily) + assertEquals(RsvpFontFamily.INTER, applied?.rsvpFontFamily) + } + } + + @Test + fun cancelRequiresExplicitDiscardAndDoesNotApply() { + var closed = false + var applied = false + rule.setContent { + KairoTheme { + ThemeSettingsScreen(UserPreferences(), onApply = { _, _, _, _, _ -> applied = true }, onBack = { closed = true }) + } + } + node(R.string.theme_tab_colours).performClick() + node(R.string.theme_harmony_triadic).performScrollTo().performClick() + rule.onNodeWithContentDescription(rule.activity.getString(R.string.action_back)).performClick() + node(R.string.theme_keep_editing).performClick() + rule.runOnIdle { + assertFalse(closed) + assertFalse(applied) + } + rule.onNodeWithContentDescription(rule.activity.getString(R.string.action_back)).performClick() + node(R.string.theme_discard).performClick() + rule.runOnIdle { + assertTrue(closed) + assertFalse(applied) + } + } + + @Test + fun largeTextCanReachPaletteFontAndApplyControls() { + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 1.8f)) { + KairoTheme(readerTheme = ReaderTheme.DARK) { + ThemeSettingsScreen( + UserPreferences( + readerTheme = ReaderTheme.CUSTOM, + customTheme = CustomTheme(background = 0xFF172C29.toInt()) + ), + onApply = { _, _, _, _, _ -> }, + onBack = {} + ) + } + } + } + node(R.string.theme_apply).assertIsDisplayed() + rule.onNodeWithTag("theme-role-list").performScrollToNode(hasText(rule.activity.getString(R.string.theme_color_tertiary))) + node(R.string.theme_color_tertiary).performClick() + rule.onNodeWithTag("theme-live-preview").assertIsDisplayed() + capture("theme-studio-large-text") + node(R.string.theme_tab_fonts).performScrollTo().performClick() + node(R.string.font_merriweather).performScrollTo().performClick() + node(R.string.font_lexend).performScrollTo().performClick() + node(R.string.font_lexend).assertIsDisplayed() + node(R.string.theme_apply).assertIsDisplayed() + } + + @Test + fun colorPickerPreservesSaturationWhenPassingThroughBlack() { + var saved: Int? = null + rule.setContent { + KairoTheme { + ThemeColorEditor(ThemeColorRole.BACKGROUND, 0xFFFF0000.toInt(), false, { saved = it }) + } + } + rule.onAllNodes( + SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress) + )[2].performSemanticsAction(SemanticsActions.SetProgress) { + it(0f) + } + rule.onAllNodes( + SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress) + )[2].performSemanticsAction(SemanticsActions.SetProgress) { + it(0.5f) + } + rule.runOnIdle { assertEquals(0xFF800000.toInt(), saved) } + } + + @Test + fun draggingKeepsPreviewAndControlsAnchored() { + rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _, _, _, _ -> }, {}) } } + node(R.string.theme_tab_colours).performClick() + val before = rule.onNodeWithTag("theme-controls").getUnclippedBoundsInRoot() + rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress))[2] + .performSemanticsAction(SemanticsActions.SetProgress) { it(0.25f) } + rule.onNodeWithTag("theme-live-preview").assertIsDisplayed() + node(R.string.theme_contrast_aa).assertIsDisplayed() + val after = rule.onNodeWithTag("theme-controls").getUnclippedBoundsInRoot() + assertEquals(before.top.value, after.top.value, 0.1f) + capture("theme-studio-live-dark") + } + + @Test + fun incompleteHexBlocksApplyAndResetResetsEditorAsWellAsPreview() { + rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _, _, _, _ -> }, {}) } } + node(R.string.theme_tab_colours).performClick() + node(R.string.theme_hex).performScrollTo().performTextReplacement("#12") + node(R.string.theme_apply).assertIsNotEnabled() + node(R.string.theme_start_again).performClick() + node(R.string.theme_hex).performScrollTo().assertTextContains("#F4EFE4") + node(R.string.theme_apply).assertIsEnabled() + } + + @After + fun restoreOrientation() { + rule.activityRule.scenario.onActivity { it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } + } + + @Test + fun landscapeKeepsPreviewBesideControls() { + rule.activityRule.scenario.onActivity { it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE } + rule.waitUntil(5_000) { rule.activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } + rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _, _, _, _ -> }, {}) } } + node(R.string.theme_tab_colours).performClick() + node(R.string.theme_hex).performScrollTo().assertIsDisplayed() + val preview = rule.onNodeWithTag("theme-live-preview").getUnclippedBoundsInRoot() + val controls = rule.onNodeWithTag("theme-controls").getUnclippedBoundsInRoot() + assertTrue(preview.right <= controls.left) + rule.onNodeWithTag("theme-live-preview").assertIsDisplayed() + node(R.string.theme_apply).assertIsDisplayed() + capture("theme-studio-landscape") + } + + private fun node(resource: Int) = rule.onNodeWithText(rule.activity.getString(resource)) + + private fun capture(name: String) { + val bitmap = rule.onRoot().captureToImage().asAndroidBitmap() + File(rule.activity.cacheDir, "$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt b/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt index 1fa1ce72..01194b6a 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt @@ -10,6 +10,7 @@ internal object KairoRoutes { const val SETTINGS_INFO = "settings/info" const val SETTINGS_RSVP = "settings/rsvp" const val SETTINGS_BIONIC = "settings/bionic" + const val SETTINGS_THEME = "settings/theme" const val SETTINGS_READER = "settings/reader" const val SETTINGS_FOCUS = "settings/focus" const val READER = "reader/{bookId}" diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt index ceb121ef..a6319bfb 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt @@ -45,6 +45,7 @@ internal fun NavGraphBuilder.settingsRoutes(dependencies: SettingsRouteDependenc }, onOpenRsvp = { dependencies.navController.navigate(KairoRoutes.SETTINGS_RSVP) }, onOpenBionic = { dependencies.navController.navigate(KairoRoutes.SETTINGS_BIONIC) }, + onOpenTheme = { dependencies.navController.navigate(KairoRoutes.SETTINGS_THEME) }, onOpenReader = { dependencies.navController.navigate(KairoRoutes.SETTINGS_READER) }, onOpenFocus = { dependencies.navController.navigate(KairoRoutes.SETTINGS_FOCUS) }, onOpenInfo = { dependencies.navController.navigate(KairoRoutes.SETTINGS_INFO) }, @@ -77,6 +78,20 @@ internal fun NavGraphBuilder.settingsRoutes(dependencies: SettingsRouteDependenc ) } + settingsSearchDestination(KairoRoutes.SETTINGS_THEME) { + val scope = rememberCoroutineScope() + com.kairo.reader.ui.settings.ThemeSettingsScreen( + preferences = dependencies.prefs, + onApply = { theme, custom, reader, interfaceFont, timed -> + scope.launch { + dependencies.container.preferencesRepository.updateAppearance(custom, reader, interfaceFont, timed, theme) + dependencies.navController.popBackStack() + } + }, + onBack = { dependencies.navController.popBackStack() }, + ) + } + settingsSearchDestination(KairoRoutes.SETTINGS_LANGUAGE) { LanguageSettingsScreen(onBack = { dependencies.navController.popBackStack() }) } diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt index fc868ab0..553df8cf 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt @@ -26,6 +26,7 @@ internal fun NavGraphBuilder.settingsSearchDestination(route: String, content: @ internal fun SettingsSearchPage.route(): String? = when (this) { SettingsSearchPage.RSVP -> KairoRoutes.SETTINGS_RSVP + SettingsSearchPage.THEME -> KairoRoutes.SETTINGS_THEME SettingsSearchPage.READER -> KairoRoutes.SETTINGS_READER SettingsSearchPage.BIONIC -> KairoRoutes.SETTINGS_BIONIC SettingsSearchPage.FOCUS -> KairoRoutes.SETTINGS_FOCUS diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ReaderAndFocusSettingsContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/ReaderAndFocusSettingsContent.kt index 19d2b348..47c8fd16 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ReaderAndFocusSettingsContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ReaderAndFocusSettingsContent.kt @@ -40,6 +40,7 @@ fun ReaderSettingsContent( onThemeChange: (ReaderTheme) -> Unit, onTextBrightnessChange: (Float) -> Unit, onInvertedScrollChange: (Boolean) -> Unit, + showThemeSelector: Boolean = true, ) { val resources = LocalResources.current @@ -51,7 +52,7 @@ fun ReaderSettingsContent( valueRange = READER_FONT_SIZE_MIN_SP..READER_FONT_SIZE_MAX_SP, ) - ThemeSelector(selected = readerTheme, onThemeChange = onThemeChange) + if (showThemeSelector) ThemeSelector(selected = readerTheme, onThemeChange = onThemeChange) SettingsSliderRow( title = stringResource(R.string.reader_text_brightness_title), diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ReaderSettingsScreen.kt b/app/src/main/java/com/kairo/reader/ui/settings/ReaderSettingsScreen.kt index 92c7d451..df57efec 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ReaderSettingsScreen.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ReaderSettingsScreen.kt @@ -27,6 +27,7 @@ fun ReaderSettingsScreen( readerTheme = preferences.readerTheme, textBrightness = preferences.readerTextBrightness, invertedScroll = preferences.invertedScroll, + showThemeSelector = false, onFontSizeChange = onFontSizeChange, onThemeChange = onThemeChange, onTextBrightnessChange = onTextBrightnessChange, diff --git a/app/src/main/java/com/kairo/reader/ui/settings/RsvpFontSelectors.kt b/app/src/main/java/com/kairo/reader/ui/settings/RsvpFontSelectors.kt index bbecd3be..6c0f45b9 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/RsvpFontSelectors.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/RsvpFontSelectors.kt @@ -24,33 +24,7 @@ internal fun RsvpFontFamilySelector( selected: RsvpFontFamily, onFontFamilyChange: (RsvpFontFamily) -> Unit, ) { - Column(modifier = Modifier.padding(vertical = 8.dp)) { - Text( - stringResource(R.string.rsvp_font_title), - modifier = Modifier.settingsSearchTarget(stringResource(R.string.rsvp_font_title)), - style = MaterialTheme.typography.bodyLarge - ) - Spacer(modifier = Modifier.height(8.dp)) - androidx.compose.foundation.layout.Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - RsvpFontFamily.entries.forEach { family -> - OutlinedButton( - onClick = { onFontFamilyChange(family) }, - modifier = Modifier.weight(1f), - ) { - Text( - text = stringResource(rsvpFontFamilyLabelRes(family)), - color = if (family == - selected - ) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, - ) - } - } - } - } + ThemeFontSelector(stringResource(R.string.rsvp_font_title), selected, onFontFamilyChange) } internal const val PHRASE_CHUNK_MIN_WORDS = 2 diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt index b3599d99..d10bcbf8 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.AutoStories import androidx.compose.material.icons.filled.CenterFocusStrong import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.AlertDialog @@ -62,6 +63,7 @@ data class SettingsHomeActions( val onReset: () -> Unit, val onClose: () -> Unit, val onOpenSearchResult: ((SettingsSearchEntry) -> Unit)? = null, + val onOpenTheme: () -> Unit = {}, ) data class SettingsTutorialActions(val onNext: () -> Unit = {}, val onPrevious: () -> Unit = {}, val onSkip: () -> Unit = {},) @@ -158,6 +160,12 @@ private fun SettingsHomeRows( ) { val rows = listOf( + SettingsHomeRow( + title = stringResource(R.string.theme_settings_title), + subtitle = stringResource(R.string.theme_settings_subtitle), + icon = Icons.Default.Palette, + onClick = actions.onOpenTheme, + ), SettingsHomeRow( title = stringResource(R.string.settings_language_title), subtitle = languageLabel, diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt index 4e5594ac..5a9f82dc 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt @@ -101,6 +101,7 @@ internal fun SettingsHomeActions.openSearchResult(entry: SettingsSearchEntry) { when (entry.page) { SettingsSearchPage.RSVP -> onOpenRsvp() SettingsSearchPage.READER -> onOpenReader() + SettingsSearchPage.THEME -> onOpenTheme() SettingsSearchPage.BIONIC -> onOpenBionic() SettingsSearchPage.FOCUS -> onOpenFocus() SettingsSearchPage.LANGUAGE -> onOpenLanguage() diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt index 378e88d6..2f74fee6 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt @@ -11,8 +11,8 @@ internal val readerSearchEntries = listOf( ), SettingsSearchEntry( id = "reader.reader_theme_title", - page = SettingsSearchPage.READER, - titleRes = R.string.reader_theme_title, + page = SettingsSearchPage.THEME, + titleRes = R.string.theme_palette, descriptionRes = R.string.settings_search_theme_description, aliases = listOf( R.string.reader_theme_light, diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt index aef7cf17..a44a307d 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt @@ -21,6 +21,7 @@ data class SettingsSearchEntry( enum class SettingsSearchPage(@StringRes val titleRes: Int) { RSVP(R.string.rsvp_settings_title), READER(R.string.reader_settings_title), + THEME(R.string.theme_settings_title), BIONIC(R.string.bionic_settings_title), FOCUS(R.string.focus_settings_title), LANGUAGE(R.string.settings_language_title), @@ -30,7 +31,7 @@ enum class SettingsSearchPage(@StringRes val titleRes: Int) { RESET(R.string.settings_title), } -internal val settingsSearchEntries = rsvpSearchEntries + readerSearchEntries + bionicSearchEntries + +internal val settingsSearchEntries = themeSearchEntries + rsvpSearchEntries + readerSearchEntries + bionicSearchEntries + focusSearchEntries + infoSearchEntries + languageSearchEntries + updatesSearchEntries + tutorialSearchEntries + resetSearchEntries internal data class SettingsSearchDocument( diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt new file mode 100644 index 00000000..2156605b --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt @@ -0,0 +1,126 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.kairo.reader.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.kairo.reader.R +import com.kairo.reader.core.model.parseThemeColor +import com.kairo.reader.core.model.themeColorHex +import kotlin.math.roundToInt + +/** Changes update the saveable theme draft immediately, while incomplete hex input stays local. */ +@Composable +internal fun ThemeColorEditor( + role: ThemeColorRole, + initial: Int, + automatic: Boolean, + onChange: (Int?) -> Unit, + onValidityChange: (Boolean) -> Unit = {} +) { + var hex by rememberSaveable { mutableStateOf(themeColorHex(initial)) } + LaunchedEffect(hex, automatic) { onValidityChange(automatic || parseThemeColor(hex) != null) } + DisposableEffect(Unit) { onDispose { onValidityChange(true) } } + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(initial, hsv) + var hue by rememberSaveable { mutableFloatStateOf(hsv[0]) } + var saturation by rememberSaveable { mutableFloatStateOf(hsv[1]) } + var brightness by rememberSaveable { mutableFloatStateOf(hsv[2]) } + val update: (Int, Float) -> Unit = { channel, value -> + when (channel) { + 0 -> hue = value + 1 -> saturation = value + 2 -> brightness = value + } + val color = android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, brightness)) + hex = themeColorHex(color) + onChange(color) + } + val automaticLabel = stringResource(R.string.theme_automatic) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (role != ThemeColorRole.BACKGROUND) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.theme_automatic), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Switch( + checked = automatic, + onCheckedChange = { onChange(if (it) null else initial) }, + modifier = Modifier.semantics { contentDescription = automaticLabel } + ) + } + } + if (!automatic) { + ThemeColorSlider(stringResource(R.string.theme_hue), hue, 359f) { update(0, it) } + ThemeColorSlider(stringResource(R.string.theme_saturation), saturation, 1f) { update(1, it) } + ThemeColorSlider(stringResource(R.string.theme_lightness), brightness, 1f) { update(2, it) } + OutlinedTextField( + value = hex, + onValueChange = { value -> + if (value.length <= 7) { + hex = value + parseThemeColor(value)?.let { + android.graphics.Color.colorToHSV(it, hsv) + hue = hsv[0] + saturation = hsv[1] + brightness = hsv[2] + onChange(it) + } + } + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.theme_hex)) }, + singleLine = true, + isError = parseThemeColor(hex) == null, + supportingText = { if (parseThemeColor(hex) == null) Text(stringResource(R.string.theme_hex_error)) }, + ) + } else { + Text(stringResource(R.string.theme_automatic_help), style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun ThemeColorSlider(title: String, value: Float, maximum: Float, onChange: (Float) -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(0.3f)) + Slider( + value, + onValueChange = onChange, + valueRange = 0f..maximum, + modifier = Modifier.weight(0.55f).semantics { contentDescription = title } + ) + Text( + ( + if (maximum > + 1f + ) { + value + } else { + value * 100 + } + ).roundToInt().toString(), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.weight(0.15f) + ) + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt new file mode 100644 index 00000000..75cbc116 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt @@ -0,0 +1,86 @@ +@file:Suppress("MagicNumber") + +package com.kairo.reader.ui.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kairo.reader.R +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.ui.theme.composeFontFamily + +@Composable +internal fun ThemeLivePreview(reader: RsvpFontFamily, timed: RsvpFontFamily, section: ThemeStudioSection, custom: Boolean) { + var showContrast by rememberSaveable { mutableStateOf(false) } + val largeText = LocalDensity.current.fontScale > 1.3f + val scheme = MaterialTheme.colorScheme + Surface( + modifier = Modifier.testTag("theme-live-preview"), + color = scheme.background, + contentColor = scheme.onBackground, + shape = RoundedCornerShape(24.dp), + border = BorderStroke(1.dp, scheme.outlineVariant), + ) { + Column(Modifier.fillMaxWidth().padding(if (largeText) 16.dp else 20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!largeText) { + Text( + stringResource(R.string.theme_preview), + style = MaterialTheme.typography.labelMedium, + color = scheme.primary + ) + } + Text(stringResource(R.string.theme_live_sample), fontFamily = reader.composeFontFamily(), fontSize = 26.sp, lineHeight = 32.sp) + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.theme_preview_word), + fontFamily = timed.composeFontFamily(), + color = scheme.tertiary, + fontSize = 20.sp + ) + if (custom && section != ThemeStudioSection.FONTS) { + TextButton(onClick = { showContrast = true }) { + Text( + stringResource(R.string.theme_contrast_aa), + style = MaterialTheme.typography.labelSmall, + color = scheme.onBackground + ) + } + } + } + } + } + if (showContrast) { + AlertDialog( + onDismissRequest = { showContrast = false }, + title = { Text(stringResource(R.string.theme_contrast_aa)) }, + text = { Text(stringResource(R.string.theme_reading_contrast_help)) }, + confirmButton = { TextButton(onClick = { showContrast = false }) { Text(stringResource(R.string.theme_done)) } }, + ) + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt new file mode 100644 index 00000000..59875405 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt @@ -0,0 +1,101 @@ +@file:Suppress("MagicNumber", "MatchingDeclarationName") + +package com.kairo.reader.ui.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import com.kairo.reader.R +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.ui.theme.materialColorScheme + +internal enum class ThemeColorRole(val labelRes: Int) { + BACKGROUND(R.string.theme_color_background), + SURFACE(R.string.theme_color_surface), + TEXT(R.string.theme_color_text), + PRIMARY(R.string.theme_color_primary), + SECONDARY(R.string.theme_color_secondary), + TERTIARY(R.string.theme_color_tertiary), +} + +internal fun CustomTheme.roleValue(role: ThemeColorRole): Int? = when (role) { + ThemeColorRole.BACKGROUND -> background + ThemeColorRole.SURFACE -> surface + ThemeColorRole.TEXT -> text + ThemeColorRole.PRIMARY -> primary + ThemeColorRole.SECONDARY -> secondary + ThemeColorRole.TERTIARY -> tertiary +} + +internal fun CustomTheme.withRole(role: ThemeColorRole, value: Int?): CustomTheme = when (role) { + ThemeColorRole.BACKGROUND -> copy(background = value ?: background) + ThemeColorRole.SURFACE -> copy(surface = value) + ThemeColorRole.TEXT -> copy(text = value) + ThemeColorRole.PRIMARY -> copy(primary = value) + ThemeColorRole.SECONDARY -> copy(secondary = value) + ThemeColorRole.TERTIARY -> copy(tertiary = value) +} + +@Composable +internal fun ThemePaletteEditor(theme: CustomTheme, onValidityChange: (Boolean) -> Unit, onChange: (CustomTheme) -> Unit) { + var editing by rememberSaveable { mutableStateOf(ThemeColorRole.BACKGROUND) } + val scheme = remember(theme) { theme.materialColorScheme() } + val colors = listOf(scheme.background, scheme.surface, scheme.onBackground, scheme.primary, scheme.secondary, scheme.tertiary) + LazyRow(Modifier.fillMaxWidth().testTag("theme-role-list"), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + itemsIndexed(ThemeColorRole.entries, key = { _, role -> role.name }) { index, role -> + val stateLabel = stringResource(if (theme.roleValue(role) == null) R.string.theme_auto else R.string.theme_pinned) + Column( + Modifier.semantics { stateDescription = stateLabel }.selectable(editing == role, role = Role.RadioButton, onClick = { + editing = + role + }).padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Surface( + Modifier.size(48.dp), + shape = CircleShape, + color = colors[index], + border = BorderStroke(if (editing == role) 3.dp else 1.dp, MaterialTheme.colorScheme.outline) + ) {} + Text(stringResource(role.labelRes), style = MaterialTheme.typography.labelMedium) + } + } + } + val automatic = theme.roleValue(editing) == null + key(editing, automatic, theme.harmony) { + ThemeColorEditor( + role = editing, + initial = theme.roleValue(editing) ?: colors[editing.ordinal].toArgb(), + automatic = automatic, + onValidityChange = onValidityChange, + onChange = { onChange(theme.withRole(editing, it)) }, + ) + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeSearchEntries.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeSearchEntries.kt new file mode 100644 index 00000000..900eb983 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeSearchEntries.kt @@ -0,0 +1,8 @@ +package com.kairo.reader.ui.settings + +import com.kairo.reader.R + +internal val themeSearchEntries = listOf( + SettingsSearchEntry("theme.custom", SettingsSearchPage.THEME, R.string.theme_custom, R.string.theme_harmony_help), + SettingsSearchEntry("theme.fonts", SettingsSearchPage.THEME, R.string.theme_fonts, R.string.theme_fonts_help), +) diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt new file mode 100644 index 00000000..28ad55be --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt @@ -0,0 +1,143 @@ +@file:Suppress("LongMethod") + +package com.kairo.reader.ui.settings + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kairo.reader.R +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.UserPreferences +import com.kairo.reader.core.model.decodeCustomTheme +import com.kairo.reader.core.model.encode +import com.kairo.reader.ui.theme.KairoTheme +import com.kairo.reader.ui.theme.materialColorScheme + +@Composable +fun ThemeSettingsScreen( + preferences: UserPreferences, + onApply: (ReaderTheme, CustomTheme, RsvpFontFamily, RsvpFontFamily, RsvpFontFamily) -> Unit, + onBack: () -> Unit, +) { + var draft by rememberSaveable { mutableStateOf(preferences.customTheme.encode()) } + var selected by rememberSaveable { mutableStateOf(preferences.readerTheme) } + var readerFont by rememberSaveable { mutableStateOf(preferences.readerFontFamily) } + var interfaceFont by rememberSaveable { mutableStateOf(preferences.interfaceFontFamily) } + var timedFont by rememberSaveable { mutableStateOf(preferences.rsvpFontFamily) } + var revision by rememberSaveable { mutableIntStateOf(0) } + var validColor by rememberSaveable { mutableStateOf(true) } + var confirmDiscard by rememberSaveable { mutableStateOf(false) } + val custom = decodeCustomTheme(draft) + val dirty = draft != preferences.customTheme.encode() || + selected != preferences.readerTheme || + readerFont != preferences.readerFontFamily || + interfaceFont != preferences.interfaceFontFamily || + timedFont != preferences.rsvpFontFamily + val back = { if (dirty) confirmDiscard = true else onBack() } + BackHandler(onBack = back) + val searchTarget = LocalSettingsSearchTarget.current + var section by rememberSaveable { + mutableStateOf( + when { + searchTarget?.id == "theme.fonts" -> ThemeStudioSection.FONTS + searchTarget?.id == "theme.custom" || selected == ReaderTheme.CUSTOM -> ThemeStudioSection.COLOURS + else -> ThemeStudioSection.PALETTES + } + ) + } + val workingCustom = if (selected == ReaderTheme.CUSTOM) { + custom + } else { + CustomTheme(background = selected.materialColorScheme().background.toArgb(), harmony = custom.harmony) + } + SettingsScaffold( + title = stringResource(R.string.theme_settings_title), + onBack = back, + compactHeader = true, + maxContentWidth = 1040.dp + ) { modifier -> + Column(modifier.fillMaxSize()) { + ThemeStudioWorkspace( + modifier = Modifier.weight(1f), + section = section, + revision = revision, + onSectionChange = { section = it }, + preview = { + KairoTheme(selected, custom, readerFont, interfaceFont) { + ThemeLivePreview(readerFont, timedFont, section, selected == ReaderTheme.CUSTOM) + } + }, + ) { + when (section) { + ThemeStudioSection.PALETTES -> ThemePresetSelector(selected, custom) { + selected = it + revision++ + } + ThemeStudioSection.COLOURS -> { + HarmonySelector(workingCustom.harmony) { + draft = workingCustom.copy(harmony = it).encode() + selected = ReaderTheme.CUSTOM + } + ThemePaletteEditor(workingCustom, onValidityChange = { validColor = it }) { + draft = it.encode() + selected = ReaderTheme.CUSTOM + } + } + ThemeStudioSection.FONTS -> { + ThemeFontSelector(stringResource(R.string.theme_font_reader), readerFont) { readerFont = it } + ThemeFontSelector(stringResource(R.string.theme_font_interface), interfaceFont) { interfaceFont = it } + ThemeFontSelector(stringResource(R.string.theme_font_timed), timedFont) { timedFont = it } + } + } + } + Surface(tonalElevation = 3.dp) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = { + revision++ + draft = CustomTheme().encode() + selected = ReaderTheme.CUSTOM + readerFont = RsvpFontFamily.MERRIWEATHER + interfaceFont = RsvpFontFamily.SYSTEM_SANS + timedFont = RsvpFontFamily.INTER + }) { Text(stringResource(R.string.theme_start_again)) } + Button(onClick = { onApply(selected, custom, readerFont, interfaceFont, timedFont) }, enabled = validColor) { + Text(stringResource(R.string.theme_apply)) + } + } + } + } + } + if (confirmDiscard) { + AlertDialog( + onDismissRequest = { confirmDiscard = false }, + title = { Text(stringResource(R.string.theme_discard_title)) }, + text = { Text(stringResource(R.string.theme_discard_body)) }, + confirmButton = { TextButton(onClick = onBack) { Text(stringResource(R.string.theme_discard)) } }, + dismissButton = { TextButton(onClick = { confirmDiscard = false }) { Text(stringResource(R.string.theme_keep_editing)) } }, + ) + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioComponents.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioComponents.kt new file mode 100644 index 00000000..8b68d452 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioComponents.kt @@ -0,0 +1,164 @@ +@file:Suppress("MagicNumber") + +package com.kairo.reader.ui.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kairo.reader.R +import com.kairo.reader.core.model.ColorHarmony +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.ui.theme.composeFontFamily +import com.kairo.reader.ui.theme.materialColorScheme + +@Composable +internal fun ThemePresetSelector(selected: ReaderTheme, custom: CustomTheme, onSelect: (ReaderTheme) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.theme_palette), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.settingsSearchTarget(stringResource(R.string.theme_palette)) + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(ReaderTheme.entries, key = { it.name }) { theme -> + val scheme = remember(theme, custom) { + if (theme == + ReaderTheme.CUSTOM + ) { + custom.materialColorScheme() + } else { + theme.materialColorScheme() + } + } + Surface( + modifier = Modifier.width(112.dp).selectable(theme == selected, role = Role.RadioButton, onClick = { onSelect(theme) }), + color = scheme.background, + contentColor = scheme.onBackground, + shape = RoundedCornerShape(20.dp), + border = BorderStroke( + if (theme == + selected + ) { + 3.dp + } else { + 1.dp + }, + if (theme == selected) scheme.primary else scheme.outlineVariant + ), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Aa", fontSize = 30.sp, color = scheme.primary) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf(scheme.primary, scheme.secondary, scheme.tertiary).forEach { + Box(Modifier.size(12.dp).background(it, CircleShape)) + } + } + Text(stringResource(readerThemeLabelRes(theme)), style = MaterialTheme.typography.labelMedium, minLines = 2) + } + } + } + } + } +} + +@Composable +internal fun HarmonySelector(selected: ColorHarmony, onSelect: (ColorHarmony) -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ColorHarmony.entries.forEach { harmony -> + FilterChip(selected = harmony == selected, onClick = { + onSelect(harmony) + }, label = { Text(stringResource(harmony.labelRes())) }) + } + } + Text( + stringResource(selected.descriptionRes()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +internal fun ColorHarmony.labelRes(): Int = when (this) { + ColorHarmony.TONAL -> R.string.theme_harmony_tonal + ColorHarmony.ANALOGOUS -> R.string.theme_harmony_analogous + ColorHarmony.COMPLEMENTARY -> R.string.theme_harmony_complementary + ColorHarmony.TRIADIC -> R.string.theme_harmony_triadic +} + +private fun ColorHarmony.descriptionRes(): Int = when (this) { + ColorHarmony.TONAL -> R.string.theme_harmony_tonal_help + ColorHarmony.ANALOGOUS -> R.string.theme_harmony_analogous_help + ColorHarmony.COMPLEMENTARY -> R.string.theme_harmony_complementary_help + ColorHarmony.TRIADIC -> R.string.theme_harmony_triadic_help +} + +@Composable +internal fun ThemeFontSelector(title: String, selected: RsvpFontFamily, onSelect: (RsvpFontFamily) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Column(Modifier.settingsSearchTarget(title)) { + Text(title, style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(4.dp)) + Box { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(rsvpFontFamilyLabelRes(selected)), fontFamily = selected.composeFontFamily()) + Text("Aa", fontFamily = selected.composeFontFamily(), fontSize = 22.sp) + } + } + DropdownMenu(expanded, onDismissRequest = { expanded = false }) { + RsvpFontFamily.entries.forEach { family -> + DropdownMenuItem( + text = { Text(stringResource(rsvpFontFamilyLabelRes(family)), fontFamily = family.composeFontFamily()) }, + onClick = { + onSelect(family) + expanded = false + }, + modifier = Modifier.selectable(family == selected, role = Role.RadioButton, onClick = { + onSelect(family) + expanded = + false + }), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt new file mode 100644 index 00000000..c9ed932b --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt @@ -0,0 +1,83 @@ +@file:Suppress("MagicNumber", "MatchingDeclarationName") + +package com.kairo.reader.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kairo.reader.R + +internal enum class ThemeStudioSection(val titleRes: Int) { + PALETTES(R.string.theme_tab_palettes), + COLOURS(R.string.theme_tab_colours), + FONTS(R.string.theme_tab_fonts), +} + +/** Preview and controls have independent layout space; adjusting a control never scrolls the preview away. */ +@Composable +internal fun ThemeStudioWorkspace( + modifier: Modifier, + section: ThemeStudioSection, + revision: Int, + onSectionChange: (ThemeStudioSection) -> Unit, + preview: @Composable () -> Unit, + controls: @Composable ColumnScope.() -> Unit, +) { + BoxWithConstraints(modifier) { + val previewWidth = maxWidth * 0.4f + val previewHeight = minOf(maxHeight * 0.30f, 196.dp) + if (maxWidth >= 640.dp) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp)) { + Column(Modifier.width(previewWidth).verticalScroll(rememberScrollState())) { preview() } + Column(Modifier.weight(1f)) { StudioControls(section, revision, onSectionChange, controls) } + } + } else { + Column { + Column(Modifier.fillMaxWidth().height(previewHeight).verticalScroll(rememberScrollState()).padding(12.dp)) { + preview() + } + StudioControls(section, revision, onSectionChange, controls) + } + } + } +} + +@Composable +private fun ColumnScope.StudioControls( + section: ThemeStudioSection, + revision: Int, + onSectionChange: (ThemeStudioSection) -> Unit, + controls: @Composable ColumnScope.() -> Unit, +) { + val stateHolder = rememberSaveableStateHolder() + SecondaryScrollableTabRow(selectedTabIndex = section.ordinal, edgePadding = 12.dp, containerColor = MaterialTheme.colorScheme.surface) { + ThemeStudioSection.entries.forEach { item -> + Tab(selected = section == item, onClick = { onSectionChange(item) }, text = { Text(stringResource(item.titleRes)) }) + } + } + stateHolder.SaveableStateProvider("${section.name}:$revision") { + Column( + Modifier.weight(1f).fillMaxWidth().testTag("theme-controls").verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = controls, + ) + } +} From 206bb2bfad920b93f1162451ed0a718b3e32cad8 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sat, 12 Sep 2026 23:47:31 +0100 Subject: [PATCH 5/9] docs(theme): explain customisation and contrast coverage --- README.md | 3 ++- docs/custom-themes.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 docs/custom-themes.md diff --git a/README.md b/README.md index 743ba1dc..ce70dd01 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ The result is an Android reader that sits somewhere between an ebook app, a spee ### Personalization - Reader font size and text brightness controls -- Multiple reader themes including Light, Sepia, Dark, Nord, Cyberpunk, and Forest +- App-wide themes including Light, Sepia, Dark, Nord, Cyberpunk, and Forest +- A visual theme studio with coordinated colour harmonies, optional colour overrides, and eight offline font choices for the interface and reading modes - RSVP font family, font weight, brightness, and positioning controls - Focus mode settings, including optional status bar hiding and Do Not Disturb integration - Persistent preferences powered by DataStore diff --git a/docs/custom-themes.md b/docs/custom-themes.md new file mode 100644 index 00000000..ea905303 --- /dev/null +++ b/docs/custom-themes.md @@ -0,0 +1,36 @@ +# Theme studio + +Open **Settings → Theme & fonts**. Palette cards preview the built-in themes and the retained custom design. Changes stay in a saveable draft until **Apply theme** saves colours, the active theme, and all three font choices atomically. Leaving a changed draft offers **Keep editing** or **Discard**. Selecting a built-in palette preserves the custom design. **Start again** resets the draft only. + +The compact preview stays above the controls on phones and beside them on wider screens. Palettes, Colours, and Fonts have separate tabs; each retains its own position. Select a colour swatch to edit it inline with hue/saturation/brightness sliders or a six-digit hex value. Valid edits update the preview immediately, without a confirmation dialog. Incomplete hex input cannot change the palette and disables Apply while that editor is visible. **Follow palette** removes that role's override. Editing a colour or harmony selects the custom theme automatically. + +## Colour roles + +| Role | Controls | +| --- | --- | +| Background | Page and reading background; the palette seed | +| Surfaces | Cards, sheets, and successive surface containers | +| Text | Reading text and content on surfaces | +| Primary | Main actions, selection, and RSVP focus colour | +| Secondary | Supporting actions and accents | +| Tertiary | Additional accents and links | + +Tonal uses the background hue; analogous uses neighbouring hues; complementary uses opposing hues; triadic uses hues spaced 120 degrees apart. Neutral backgrounds use a stable warm seed hue. Accent saturation is reduced for supporting roles. + +The generator preserves the opaque background exactly. It derives a common readable surface range and independently resolves text on filled controls, error colours, inverse surfaces, outlines, and fixed roles. It moves requested colours toward black or white only when necessary to achieve at least 4.5:1 for generated text/accent pairs and 3:1 for outlines. Stored overrides remain the user's requested colours, so changing the background can resolve them differently. This uses deterministic HSL hue relationships and sRGB luminance; it does not claim perceptually uniform colour distances. + +The threshold follows [WCAG's minimum text contrast guidance](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). The generator checks both calculated colours and their final 8-bit sRGB values. For custom themes, Reader and RSVP body text also resolve the requested brightness against the background and retain at least 4.5:1 contrast. Bionic text and fixation colours are corrected against the card and active-frame backgrounds. The stored brightness preference is preserved; dimming stops where further dimming would break the contrast threshold. These checks cover palette roles and these reading paths, not a claim of whole-app WCAG conformance: other overlays, secondary contextual text, focus indicators, and interactions still need a broader accessibility audit. + +## Fonts and storage + +Reader, interface, and RSVP/Bionic font choices are independent. Existing installations retain Merriweather for Reader, system sans for the interface, and their existing timed-reading font. Font preferences remain independent of the chosen palette. + +The eight choices are Inter, Roboto, Merriweather, Lora, Lexend, system sans, system serif, and monospace. The five named fonts are bundled; system families use Android's installed fonts and glyph fallback. The new font files come from the Google Fonts [Lora](https://github.com/google/fonts/tree/main/ofl/lora) and [Lexend](https://github.com/google/fonts/tree/main/ofl/lexend) directories, retrieved on 2026-09-12. Their SIL Open Font Licences are packaged in `app/src/main/assets/licenses/`. + +`custom_theme_v1` contains a versioned colour-role encoding. Missing or malformed fields fall back individually; unknown encoding versions fall back to the default custom design. Existing `reader_theme` and `rsvp_font_family` keys retain their meanings. The draft survives activity recreation without writing preferences. + +## Verification + +- Unit tests cover colour encoding, malformed values, neutral/extreme/saturated backgrounds, all four harmonies, pinned roles, and 2,000 deterministic generated/overridden palettes. +- Device tests cover stored preference mapping for every font, draft recreation, applying colour/font changes together, discard behaviour, settings search, large-text control access, fixed preview placement while dragging, landscape layout, reset synchronisation, and tab transitions. +- Run `./gradlew qualityGate` for deterministic checks. The theme device tests are `ThemeSettingsDeviceTest` and `ThemePreferencesDeviceTest`. From e1b3b0ea4ffdf214a6989fdc31266c4e7ece6bb8 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sun, 13 Sep 2026 13:28:32 +0100 Subject: [PATCH 6/9] fix(reader): preserve contrast across highlighted text --- .../reader/ReaderThemeContrastDeviceTest.kt | 74 ++++++++++++ .../ReaderParagraphVisualContentBuilder.kt | 112 ++++++++++++++++++ .../reader/ui/reader/ReaderScreenParagraph.kt | 107 +++-------------- .../reader/ui/reader/ReaderSpanContrast.kt | 45 +++++++ .../ui/reader/ReaderSpanContrastTest.kt | 45 +++++++ 5 files changed, 295 insertions(+), 88 deletions(-) create mode 100644 app/src/androidTest/java/com/kairo/reader/ui/reader/ReaderThemeContrastDeviceTest.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/reader/ReaderParagraphVisualContentBuilder.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/reader/ReaderSpanContrast.kt create mode 100644 app/src/test/java/com/kairo/reader/ui/reader/ReaderSpanContrastTest.kt diff --git a/app/src/androidTest/java/com/kairo/reader/ui/reader/ReaderThemeContrastDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/reader/ReaderThemeContrastDeviceTest.kt new file mode 100644 index 00000000..09925e7d --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/reader/ReaderThemeContrastDeviceTest.kt @@ -0,0 +1,74 @@ +package com.kairo.reader.ui.reader + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.BookId +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.HighlightColor +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.SavedAnnotation +import com.kairo.reader.core.model.SavedAnnotationKind +import com.kairo.reader.core.model.TimedReadingMode +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.ui.saved.displayColor +import com.kairo.reader.ui.theme.KairoTheme +import com.kairo.reader.ui.theme.colorContrast +import com.kairo.reader.ui.theme.materialColorScheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReaderThemeContrastDeviceTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun renderedFocusedLinkWithOverlappingHighlightsUsesProtectedText() { + val custom = CustomTheme(background = 0xFF747474.toInt()) + val saved = SavedAnnotation( + "test", BookId("preview"), 0, 0, 0, "Remember", "", HighlightColor.YELLOW, + SavedAnnotationKind.HIGHLIGHT, 0L, 0L + ) + rule.setContent { + KairoTheme(readerTheme = ReaderTheme.CUSTOM, customTheme = custom) { + ParagraphText( + ParagraphTextState( + Paragraph(listOf(Token("Remember", TokenType.WORD, linkChapterIndex = 1)), 0), + 0, + 18f, + 0.88f, + TimedReadingMode.RSVP, + savedAnnotations = listOf(saved), + selectionRange = 0..0, + searchMatchRange = 0..0 + ), + ParagraphTextActions({}, {}), + ) + } + } + val results = mutableListOf() + rule.onNodeWithText("Remember").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + rule.runOnIdle { + val text = results.single().layoutInput.text + val foreground = text.spanStyles.last().item.color + val scheme = custom.materialColorScheme() + val background = readerSpanBackground( + scheme.background, + listOf( + HighlightColor.YELLOW.displayColor().copy(alpha = 0.18f), + scheme.tertiary.copy(alpha = 0.18f), + scheme.primary.copy(alpha = 0.22f), + scheme.primary.copy(alpha = 0.16f), + ) + ) + assertTrue(colorContrast(foreground, background) >= 4.5f) + } + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/reader/ReaderParagraphVisualContentBuilder.kt b/app/src/main/java/com/kairo/reader/ui/reader/ReaderParagraphVisualContentBuilder.kt new file mode 100644 index 00000000..646b0a7b --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/reader/ReaderParagraphVisualContentBuilder.kt @@ -0,0 +1,112 @@ +package com.kairo.reader.ui.reader + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.buildAnnotatedString +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.model.shouldInsertSpaceBeforeToken +import com.kairo.reader.ui.saved.displayColor + +/** Build spans and painted highlights together, preserving their shared token and character positions. */ +@Suppress("LongMethod") +internal fun buildReaderParagraphVisualContent( + state: ParagraphTextState, + localFocusIndex: Int, + spanStyles: ReaderSpanStyles, + paragraphIndent: ParagraphStyle, + spanContrast: ReaderSpanContrast?, +): ReaderParagraphVisualContent { + val paragraph = state.paragraph + val focusStyle = spanStyles.focus + val linkStyle = spanStyles.link + val primary = focusStyle.color + val tertiary = linkStyle.color + val inlineHighlights = mutableListOf() + val text = buildAnnotatedString { + paragraph.tokens.forEachIndexed { localIndex, token -> + if (token.type == TokenType.PARAGRAPH_BREAK || + token.type == TokenType.PAGE_BREAK + ) { + return@forEachIndexed + } + val globalIndex = paragraph.startIndex + localIndex + + val prevToken = if (localIndex > 0) paragraph.tokens[localIndex - 1] else null + val needsSpaceBefore = + shouldInsertSpaceBeforeToken(token, prevToken, localIndex) + + if (needsSpaceBefore) append(" ") + + val start = length + val highlightStart = if (needsSpaceBefore) start - 1 else start + append(token.text) + val end = length + + addStringAnnotation( + tag = "tokenIndex", + annotation = globalIndex.toString(), + start = start, + end = end + ) + + // Add link annotation if token has a link + val interactiveChapterLinkTarget = + resolveInteractiveChapterLinkTarget( + token = token, + nonInteractiveTargets = state.nonInteractiveChapterLinkTargets, + ) + if (interactiveChapterLinkTarget != null) { + addStringAnnotation( + tag = "chapterLink", + annotation = interactiveChapterLinkTarget.toString(), + start = start, + end = end + ) + addStyle(linkStyle, start, end) + } + + val overlays = if (spanContrast != null) mutableListOf() else null + state.savedAnnotations + .firstOrNull { globalIndex in it.tokenRange } + ?.let { annotation -> + overlays?.add(annotation.color.displayColor().copy(alpha = SAVED_HIGHLIGHT_ALPHA)) + inlineHighlights.addOrExtendInlineHighlight( + key = "saved:${annotation.id}", + start = highlightStart, + endExclusive = end, + color = annotation.color.displayColor().copy(alpha = SAVED_HIGHLIGHT_ALPHA), + ) + } + + if (localIndex == localFocusIndex) addStyle(focusStyle, start, end) + if (state.searchMatchRange?.contains(globalIndex) == true) { + overlays?.add(tertiary.copy(alpha = SEARCH_HIGHLIGHT_ALPHA)) + inlineHighlights.addOrExtendInlineHighlight( + key = SEARCH_HIGHLIGHT_KEY, + start = highlightStart, + endExclusive = end, + color = tertiary.copy(alpha = SEARCH_HIGHLIGHT_ALPHA), + ) + } + if (state.selectionRange?.contains(globalIndex) == true) { + overlays?.add(primary.copy(alpha = SELECTION_HIGHLIGHT_ALPHA)) + inlineHighlights.addOrExtendInlineHighlight( + key = SELECTION_HIGHLIGHT_KEY, + start = highlightStart, + endExclusive = end, + color = primary.copy(alpha = SELECTION_HIGHLIGHT_ALPHA), + ) + } + spanContrast?.style(overlays, spanStyles, localIndex == localFocusIndex, interactiveChapterLinkTarget != null) + ?.let { addStyle(it, start, end) } + } + addStyle(paragraphIndent, start = 0, end = length) + } + return ReaderParagraphVisualContent(text, inlineHighlights) +} + +private const val SAVED_HIGHLIGHT_ALPHA = 0.18f +private const val SEARCH_HIGHLIGHT_ALPHA = 0.18f +private const val SELECTION_HIGHLIGHT_ALPHA = 0.22f +private const val SEARCH_HIGHLIGHT_KEY = "search" +private const val SELECTION_HIGHLIGHT_KEY = "selection" diff --git a/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt b/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt index 924f8cb9..ab564379 100644 --- a/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt +++ b/app/src/main/java/com/kairo/reader/ui/reader/ReaderScreenParagraph.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextIndent @@ -40,9 +39,7 @@ import com.kairo.reader.R import com.kairo.reader.core.model.RsvpConfigConstraints import com.kairo.reader.core.model.SavedAnnotation import com.kairo.reader.core.model.TimedReadingMode -import com.kairo.reader.core.model.TokenType -import com.kairo.reader.core.model.shouldInsertSpaceBeforeToken -import com.kairo.reader.ui.saved.displayColor +import com.kairo.reader.ui.theme.LocalProtectReadingContrast import com.kairo.reader.ui.theme.LocalReaderFont import com.kairo.reader.ui.theme.composeFontFamily import com.kairo.reader.ui.theme.readingColor @@ -80,6 +77,9 @@ internal fun ParagraphText( textIndent = TextIndent(firstLine = (fontSizeSp * PARAGRAPH_INDENT_FACTOR).sp), ) } + val protectContrast = LocalProtectReadingContrast.current + val pageBackground = MaterialTheme.colorScheme.background + val spanContrast = remember(pageBackground) { ReaderSpanContrast(pageBackground) } val primary = MaterialTheme.colorScheme.primary val tertiary = MaterialTheme.colorScheme.tertiary val focusStyle = @@ -99,11 +99,10 @@ internal fun ParagraphText( } val localFocusIndex = remember(paragraph.startIndex, paragraph.tokens.size, focusIndex) { - (focusIndex - paragraph.startIndex) - .takeIf { localIndex -> localIndex in paragraph.tokens.indices } - ?: NO_PARAGRAPH_FOCUS + resolveParagraphFocusIndex(paragraph, focusIndex) } + val spanStyles = ReaderSpanStyles(baseStyle.color, focusStyle, linkStyle) val visualContent = remember( paragraph.tokens, @@ -116,83 +115,17 @@ internal fun ParagraphText( state.savedAnnotations, state.selectionRange, state.searchMatchRange, + protectContrast, + spanContrast, + baseStyle.color, ) { - val inlineHighlights = mutableListOf() - val text = buildAnnotatedString { - paragraph.tokens.forEachIndexed { localIndex, token -> - if (token.type == TokenType.PARAGRAPH_BREAK || - token.type == TokenType.PAGE_BREAK - ) { - return@forEachIndexed - } - val globalIndex = paragraph.startIndex + localIndex - - val prevToken = if (localIndex > 0) paragraph.tokens[localIndex - 1] else null - val needsSpaceBefore = - shouldInsertSpaceBeforeToken(token, prevToken, localIndex) - - if (needsSpaceBefore) append(" ") - - val start = length - val highlightStart = if (needsSpaceBefore) start - 1 else start - append(token.text) - val end = length - - addStringAnnotation( - tag = "tokenIndex", - annotation = globalIndex.toString(), - start = start, - end = end - ) - - // Add link annotation if token has a link - val interactiveChapterLinkTarget = - resolveInteractiveChapterLinkTarget( - token = token, - nonInteractiveTargets = state.nonInteractiveChapterLinkTargets, - ) - if (interactiveChapterLinkTarget != null) { - addStringAnnotation( - tag = "chapterLink", - annotation = interactiveChapterLinkTarget.toString(), - start = start, - end = end - ) - addStyle(linkStyle, start, end) - } - - state.savedAnnotations - .firstOrNull { globalIndex in it.tokenRange } - ?.let { annotation -> - inlineHighlights.addOrExtendInlineHighlight( - key = "saved:${annotation.id}", - start = highlightStart, - endExclusive = end, - color = annotation.color.displayColor().copy(alpha = SAVED_HIGHLIGHT_ALPHA), - ) - } - - if (localIndex == localFocusIndex) addStyle(focusStyle, start, end) - if (state.searchMatchRange?.contains(globalIndex) == true) { - inlineHighlights.addOrExtendInlineHighlight( - key = SEARCH_HIGHLIGHT_KEY, - start = highlightStart, - endExclusive = end, - color = tertiary.copy(alpha = SEARCH_HIGHLIGHT_ALPHA), - ) - } - if (state.selectionRange?.contains(globalIndex) == true) { - inlineHighlights.addOrExtendInlineHighlight( - key = SELECTION_HIGHLIGHT_KEY, - start = highlightStart, - endExclusive = end, - color = primary.copy(alpha = SELECTION_HIGHLIGHT_ALPHA), - ) - } - } - addStyle(paragraphIndent, start = 0, end = length) - } - ReaderParagraphVisualContent(text, inlineHighlights) + buildReaderParagraphVisualContent( + state, + localFocusIndex, + spanStyles, + paragraphIndent, + if (protectContrast) spanContrast else null + ) } var layoutResult by remember { mutableStateOf(null) } @@ -350,6 +283,9 @@ internal fun ParagraphText( ) } +private fun resolveParagraphFocusIndex(paragraph: Paragraph, focusIndex: Int): Int = + (focusIndex - paragraph.startIndex).takeIf { it in paragraph.tokens.indices } ?: NO_PARAGRAPH_FOCUS + private fun handleParagraphActivationKey( event: KeyEvent, focusIndex: Int, @@ -389,8 +325,3 @@ internal data class ParagraphTextActions( private const val PARAGRAPH_INDENT_FACTOR = 0.55f private const val NO_PARAGRAPH_FOCUS = -1 -private const val SAVED_HIGHLIGHT_ALPHA = 0.18f -private const val SEARCH_HIGHLIGHT_ALPHA = 0.18f -private const val SELECTION_HIGHLIGHT_ALPHA = 0.22f -private const val SEARCH_HIGHLIGHT_KEY = "search" -private const val SELECTION_HIGHLIGHT_KEY = "selection" diff --git a/app/src/main/java/com/kairo/reader/ui/reader/ReaderSpanContrast.kt b/app/src/main/java/com/kairo/reader/ui/reader/ReaderSpanContrast.kt new file mode 100644 index 00000000..a3745a18 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/reader/ReaderSpanContrast.kt @@ -0,0 +1,45 @@ +package com.kairo.reader.ui.reader + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.SpanStyle +import com.kairo.reader.ui.theme.contrastSafeReadingColor + +/** Matches drawing order: saved highlight, search, selection, then the focused word's span. */ +internal fun readerSpanBackground(page: Color, overlays: List): Color = + overlays.fold(page) { background, overlay -> overlay.compositeOver(background) } + +internal fun readerSpanColor(text: Color, page: Color, overlays: List): Color = + contrastSafeReadingColor(text, readerSpanBackground(page, overlays)) + +/** A paragraph can contain many tokens but only a handful of distinct highlight combinations. */ +internal class ReaderSpanContrast(private val page: Color) { + private val colors = mutableMapOf>, Color>() + + fun color(text: Color, overlays: List): Color { + if (colors.size >= MAX_CACHED_SPANS) colors.clear() + return colors.getOrPut(text to overlays) { readerSpanColor(text, page, overlays) } + } +} + +private const val MAX_CACHED_SPANS = 128 + +internal data class ReaderSpanStyles(val body: Color, val focus: SpanStyle, val link: SpanStyle) + +internal fun ReaderSpanContrast.style( + overlays: MutableList?, + styles: ReaderSpanStyles, + focused: Boolean, + linked: Boolean, +): SpanStyle? { + if (overlays == null || (overlays.isEmpty() && !focused)) return null + val requested = when { + focused -> { + overlays.add(styles.focus.background) + styles.focus.color + } + linked -> styles.link.color + else -> styles.body + } + return SpanStyle(color = color(requested, overlays)) +} diff --git a/app/src/test/java/com/kairo/reader/ui/reader/ReaderSpanContrastTest.kt b/app/src/test/java/com/kairo/reader/ui/reader/ReaderSpanContrastTest.kt new file mode 100644 index 00000000..883026df --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/reader/ReaderSpanContrastTest.kt @@ -0,0 +1,45 @@ +package com.kairo.reader.ui.reader + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.ui.theme.colorContrast +import com.kairo.reader.ui.theme.materialColorScheme +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderSpanContrastTest { + @Test + fun yellowHighlightOnMidGreyNoLongerMakesWhiteTextUnreadable() { + val page = Color(0xFF747474) + val overlays = listOf(Color(0xFFFFD54F).copy(alpha = 0.18f)) + val background = readerSpanBackground(page, overlays) + assertTrue(colorContrast(Color.White, background) < 4.5f) + assertTrue(colorContrast(readerSpanColor(Color.White, page, overlays), background) >= 4.5f) + } + + @Test + fun overlappingSavedSearchSelectionAndFocusRemainReadable() { + val random = Random(673) + repeat(150) { + val scheme = CustomTheme(background = random.nextInt(), text = random.nextInt()).materialColorScheme() + val overlays = listOf( + Color(0xFFF48FB1).copy(alpha = 0.18f), + scheme.tertiary.copy(alpha = 0.18f), + scheme.primary.copy(alpha = 0.22f), + scheme.primary.copy(alpha = 0.16f) + ) + for (count in 1..overlays.size) { + val active = overlays.take(count) + val background = readerSpanBackground(scheme.background, active) + listOf(scheme.onBackground, scheme.tertiary, scheme.primary.copy(alpha = 0.95f)).forEach { text -> + val result = readerSpanColor(text, scheme.background, active) + assertTrue(colorContrast(Color(result.toArgb()), Color(background.toArgb())) >= 4.5f) + assertEquals(1f, result.alpha) + } + } + } + } +} From 800d59750238716ad72010667d85c1c2696b252a Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sun, 13 Sep 2026 13:28:55 +0100 Subject: [PATCH 7/9] feat(theme): preserve preset colours and store named designs --- .../preferences/ThemePreferencesDeviceTest.kt | 5 ++ .../kairo/reader/core/model/CustomTheme.kt | 15 ++++-- .../com/kairo/reader/core/model/Models.kt | 1 + .../kairo/reader/core/model/ThemeDesign.kt | 35 +++++++++++++ .../kairo/reader/data/preferences/PrefKeys.kt | 1 + .../data/preferences/PreferencesRepository.kt | 1 + .../preferences/PreferencesRepositoryImpl.kt | 2 + .../data/preferences/ThemeLibraryCodec.kt | 49 +++++++++++++++++++ .../data/preferences/UserPreferencesMapper.kt | 1 + .../reader/ui/theme/CustomColorScheme.kt | 29 ++++++++--- .../com/kairo/reader/ui/theme/ThemeCopy.kt | 21 ++++++++ .../reader/core/model/CustomThemeTest.kt | 9 ++++ .../data/preferences/ThemeLibraryCodecTest.kt | 46 +++++++++++++++++ .../reader/ui/theme/CustomColorSchemeTest.kt | 20 ++++++++ 14 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/kairo/reader/core/model/ThemeDesign.kt create mode 100644 app/src/main/java/com/kairo/reader/data/preferences/ThemeLibraryCodec.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/theme/ThemeCopy.kt create mode 100644 app/src/test/java/com/kairo/reader/data/preferences/ThemeLibraryCodecTest.kt diff --git a/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt index b8b41596..b6d50dda 100644 --- a/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt @@ -7,6 +7,8 @@ import androidx.test.platform.app.InstrumentationRegistry import com.kairo.reader.core.model.CustomTheme import com.kairo.reader.core.model.ReaderTheme import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.core.model.ThemeDesign import com.kairo.reader.core.model.encode import org.junit.Assert.assertEquals import org.junit.Test @@ -22,8 +24,10 @@ class ThemePreferencesDeviceTest { fun customThemeAndAllFontChoicesLoadFromStoredPreferences() { val theme = CustomTheme(background = 0xFF172C29.toInt(), primary = 0xFFEDC892.toInt()) RsvpFontFamily.entries.forEach { font -> + val saved = listOf(SavedTheme("one", "Reading", ThemeDesign(custom = theme, readerFont = font))) val stored = mutablePreferencesOf( PrefKeys.customTheme to theme.encode(), + PrefKeys.savedThemes to ThemeLibraryCodec.encode(saved), PrefKeys.readerTheme to ReaderTheme.CUSTOM.name, PrefKeys.readerFontFamily to font.name, PrefKeys.interfaceFontFamily to font.name, @@ -31,6 +35,7 @@ class ThemePreferencesDeviceTest { ) val loaded = mapper.map(stored) assertEquals(theme, loaded.customTheme) + assertEquals(saved, loaded.savedThemes) assertEquals(ReaderTheme.CUSTOM, loaded.readerTheme) assertEquals(font, loaded.readerFontFamily) assertEquals(font, loaded.interfaceFontFamily) diff --git a/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt b/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt index dacd4902..5239fe08 100644 --- a/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt +++ b/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt @@ -11,6 +11,7 @@ data class CustomTheme( val primary: Int? = null, val secondary: Int? = null, val tertiary: Int? = null, + val sourcePreset: ReaderTheme? = null, ) enum class ColorHarmony { TONAL, ANALOGOUS, COMPLEMENTARY, TRIADIC } @@ -23,12 +24,18 @@ fun themeColorHex(value: Int): String = "#" + (value.toLong() and 0xFFFFFF).toSt /** Versioned, delimiter-safe encoding shared by persistence and the saveable editor draft. */ fun CustomTheme.encode(): String = - listOf("1", themeColorHex(background), harmony.name, surface, text, primary, secondary, tertiary) + listOf("2", themeColorHex(background), harmony.name, surface, text, primary, secondary, tertiary, sourcePreset?.name) .joinToString("|") { if (it is Int) themeColorHex(it) else it?.toString().orEmpty() } fun decodeCustomTheme(value: String?): CustomTheme { val parts = value?.split('|') ?: return CustomTheme() - if (parts.size != THEME_FIELD_COUNT || parts[0] != "1") return CustomTheme() + if (!( + (parts.size == LEGACY_THEME_FIELD_COUNT && parts[0] == "1") || + (parts.size == THEME_FIELD_COUNT && parts[0] == "2") + ) + ) { + return CustomTheme() + } val defaults = CustomTheme() return CustomTheme( background = parseThemeColor(parts[1]) ?: defaults.background, @@ -38,7 +45,9 @@ fun decodeCustomTheme(value: String?): CustomTheme { primary = parseThemeColor(parts[5]), secondary = parseThemeColor(parts[6]), tertiary = parseThemeColor(parts[7]), + sourcePreset = ReaderTheme.entries.find { it != ReaderTheme.CUSTOM && it.name == parts.getOrNull(8) }, ) } -private const val THEME_FIELD_COUNT = 8 +private const val THEME_FIELD_COUNT = 9 +private const val LEGACY_THEME_FIELD_COUNT = 8 diff --git a/app/src/main/java/com/kairo/reader/core/model/Models.kt b/app/src/main/java/com/kairo/reader/core/model/Models.kt index 0c4ccbe3..ef469717 100644 --- a/app/src/main/java/com/kairo/reader/core/model/Models.kt +++ b/app/src/main/java/com/kairo/reader/core/model/Models.kt @@ -325,6 +325,7 @@ data class UserPreferences( val readerFontSizeSp: Float = 18f, val readerTheme: ReaderTheme = ReaderTheme.LIGHT, val customTheme: CustomTheme = CustomTheme(), + val savedThemes: List = emptyList(), val readerFontFamily: RsvpFontFamily = RsvpFontFamily.MERRIWEATHER, val interfaceFontFamily: RsvpFontFamily = RsvpFontFamily.SYSTEM_SANS, val readerTextBrightness: Float = 0.88f, diff --git a/app/src/main/java/com/kairo/reader/core/model/ThemeDesign.kt b/app/src/main/java/com/kairo/reader/core/model/ThemeDesign.kt new file mode 100644 index 00000000..50cee46d --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/model/ThemeDesign.kt @@ -0,0 +1,35 @@ +@file:Suppress("MagicNumber") + +package com.kairo.reader.core.model + +/** A reusable appearance, independent of reading position, pacing, and text size. */ +data class ThemeDesign( + val theme: ReaderTheme = ReaderTheme.CUSTOM, + val custom: CustomTheme = CustomTheme(), + val readerFont: RsvpFontFamily = RsvpFontFamily.MERRIWEATHER, + val interfaceFont: RsvpFontFamily = RsvpFontFamily.SYSTEM_SANS, + val timedFont: RsvpFontFamily = RsvpFontFamily.INTER, +) { + fun encode(): String = listOf(theme.name, readerFont.name, interfaceFont.name, timedFont.name, custom.encode()).joinToString("~") +} + +fun UserPreferences.themeDesign(): ThemeDesign = + ThemeDesign(readerTheme, customTheme, readerFontFamily, interfaceFontFamily, rsvpFontFamily) + +fun decodeThemeDesign(value: String): ThemeDesign? { + val parts = value.split('~', limit = DESIGN_FIELD_COUNT) + if (parts.size != DESIGN_FIELD_COUNT) return null + return ThemeDesign( + theme = ReaderTheme.entries.find { it.name == parts[0] } ?: return null, + readerFont = RsvpFontFamily.entries.find { it.name == parts[1] } ?: return null, + interfaceFont = RsvpFontFamily.entries.find { it.name == parts[2] } ?: return null, + timedFont = RsvpFontFamily.entries.find { it.name == parts[3] } ?: return null, + custom = decodeCustomTheme(parts[4]), + ) +} + +data class SavedTheme(val id: String, val name: String, val design: ThemeDesign) + +const val MAX_SAVED_THEMES = 24 +const val MAX_THEME_NAME_LENGTH = 60 +private const val DESIGN_FIELD_COUNT = 5 diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt index d1f41480..57906635 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt @@ -78,6 +78,7 @@ internal object PrefKeys { val readerFontSize = floatPreferencesKey("reader_font_size") val readerTheme = stringPreferencesKey("reader_theme") val customTheme = stringPreferencesKey("custom_theme_v1") + val savedThemes = stringPreferencesKey("saved_themes") val readerFontFamily = stringPreferencesKey("reader_font_family") val interfaceFontFamily = stringPreferencesKey("interface_font_family") val readerTextBrightness = floatPreferencesKey("reader_text_brightness") diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt index e5d975b5..c1362846 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt @@ -40,6 +40,7 @@ interface PreferencesRepository { interfaceFont: RsvpFontFamily, timedFont: RsvpFontFamily, theme: com.kairo.reader.core.model.ReaderTheme, + savedThemes: List? = null, ) suspend fun updateInvertedScroll(enabled: Boolean) diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt index 0c7c5e81..71415290 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt @@ -306,9 +306,11 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo interfaceFont: RsvpFontFamily, timedFont: RsvpFontFamily, theme: ReaderTheme, + savedThemes: List?, ) { context.dataStore.edit { prefs -> prefs[keys.customTheme] = customTheme.encode() + if (savedThemes != null) prefs[keys.savedThemes] = ThemeLibraryCodec.encode(savedThemes) prefs[keys.readerFontFamily] = readerFont.name prefs[keys.interfaceFontFamily] = interfaceFont.name prefs[keys.rsvpFontFamily] = timedFont.name diff --git a/app/src/main/java/com/kairo/reader/data/preferences/ThemeLibraryCodec.kt b/app/src/main/java/com/kairo/reader/data/preferences/ThemeLibraryCodec.kt new file mode 100644 index 00000000..de7dff38 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/data/preferences/ThemeLibraryCodec.kt @@ -0,0 +1,49 @@ +package com.kairo.reader.data.preferences + +import com.kairo.reader.core.model.MAX_SAVED_THEMES +import com.kairo.reader.core.model.MAX_THEME_NAME_LENGTH +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.core.model.decodeThemeDesign +import org.json.JSONArray +import org.json.JSONObject + +/** Bounded, versioned storage; a damaged entry does not discard the remaining collection. */ +internal object ThemeLibraryCodec { + fun encode(themes: List): String = JSONObject().apply { + put("version", 1) + put( + "themes", + JSONArray().apply { + themes.distinctBy { it.id }.take(MAX_SAVED_THEMES).forEach { theme -> + put( + JSONObject().apply { + put("id", theme.id) + put("name", theme.name.trim().take(MAX_THEME_NAME_LENGTH)) + put("design", theme.design.encode()) + } + ) + } + } + ) + }.toString() + + fun decode(raw: String?): List { + if (raw.isNullOrBlank() || raw.length > MAX_LIBRARY_LENGTH) return emptyList() + return runCatching { + val root = JSONObject(raw) + if (root.optInt("version") != 1) return emptyList() + val entries = root.optJSONArray("themes") ?: return emptyList() + buildList { + for (index in 0 until minOf(entries.length(), MAX_SAVED_THEMES)) { + val entry = entries.optJSONObject(index) ?: continue + val id = entry.optString("id").take(MAX_THEME_NAME_LENGTH) + val name = entry.optString("name").trim().take(MAX_THEME_NAME_LENGTH) + val design = decodeThemeDesign(entry.optString("design")) ?: continue + if (id.isNotBlank() && name.isNotBlank() && none { it.id == id }) add(SavedTheme(id, name, design)) + } + } + }.getOrDefault(emptyList()) + } +} + +private const val MAX_LIBRARY_LENGTH = 64_000 diff --git a/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt b/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt index 55a9bcb8..8cffbc6b 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt @@ -74,6 +74,7 @@ internal class UserPreferencesMapper( readerFontSizeSp = prefs.readOrDefault(keys.readerFontSize, defaults.readerFontSizeSp), readerTheme = readerTheme, customTheme = com.kairo.reader.core.model.decodeCustomTheme(prefs[keys.customTheme]), + savedThemes = ThemeLibraryCodec.decode(prefs[keys.savedThemes]), readerFontFamily = RsvpFontFamily.entries.find { it.name == prefs[keys.readerFontFamily] } ?: defaults.readerFontFamily, interfaceFontFamily = RsvpFontFamily.entries.find { it.name == prefs[keys.interfaceFontFamily] diff --git a/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt b/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt index 220e352c..b2486e02 100644 --- a/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt +++ b/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import com.kairo.reader.core.model.ColorHarmony import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme import kotlin.math.max import kotlin.math.min @@ -39,13 +40,24 @@ private fun foreground(background: Color): Color = /** All surfaces stay on the same side of the text contrast boundary, even with a pinned surface. */ @Suppress("LongMethod") internal fun CustomTheme.materialColorScheme(): ColorScheme { + val source = sourcePreset?.takeUnless { it == ReaderTheme.CUSTOM }?.materialColorScheme() val bg = Color(background).copy(alpha = 1f) val ink = foreground(bg) val dark = ink == Color.White val end = if (dark) Color.Black else Color.White val baseSurface = contrastingColor(surface?.let(::Color) ?: lerp(bg, end, 0.12f), listOf(ink)) - val containers = listOf(0f, 0.12f, 0.24f, 0.36f, 0.48f).map { lerp(baseSurface, end, it) } - val surfaces = containers + bg + val containers = if (source != null && surface == source.surface.toArgb()) { + listOf( + source.surfaceContainerLowest, + source.surfaceContainerLow, + source.surfaceContainer, + source.surfaceContainerHigh, + source.surfaceContainerHighest + ).map { contrastingColor(it, listOf(ink)) } + } else { + listOf(0f, 0.12f, 0.24f, 0.36f, 0.48f).map { lerp(baseSurface, end, it) } + } + val surfaces = containers + bg + baseSurface val onSurface = contrastingColor(text?.let(::Color) ?: ink, surfaces) val hue = backgroundHue(bg) val offsets = when (harmony) { @@ -58,9 +70,9 @@ internal fun CustomTheme.materialColorScheme(): ColorScheme { val seed = override?.let(::Color) ?: Color.hsl((hue + offsets[index]) % 360f, 0.48f - index * 0.08f, 0.5f) contrastingColor(seed, surfaces) } - val primaryContainer = lerp(bg, accents[0], 0.18f) - val secondaryContainer = lerp(bg, accents[1], 0.14f) - val tertiaryContainer = lerp(bg, accents[2], 0.14f) + val primaryContainer = source?.primaryContainer ?: lerp(bg, accents[0], 0.18f) + val secondaryContainer = source?.secondaryContainer ?: lerp(bg, accents[1], 0.14f) + val tertiaryContainer = source?.tertiaryContainer ?: lerp(bg, accents[2], 0.14f) val error = contrastingColor(Color(0xFFBA1A1A), surfaces) val errorContainer = lerp(bg, error, 0.14f) val inverse = if (dark) Color(0xFFF1EEE8) else Color(0xFF262521) @@ -68,7 +80,12 @@ internal fun CustomTheme.materialColorScheme(): ColorScheme { return scheme.copy( background = bg, onBackground = onSurface, surface = baseSurface, onSurface = onSurface, - surfaceVariant = containers[2], onSurfaceVariant = onSurface, + surfaceVariant = if (source != null) containers[4] else containers[2], + onSurfaceVariant = if (source != null && text == source.onBackground.toArgb()) { + contrastingColor(source.onSurfaceVariant, surfaces) + } else { + onSurface + }, surfaceContainerLowest = containers[0], surfaceContainerLow = containers[1], surfaceContainer = containers[2], surfaceContainerHigh = containers[3], surfaceContainerHighest = containers[4], surfaceBright = if (dark) baseSurface else containers[4], surfaceDim = if (dark) containers[4] else baseSurface, diff --git a/app/src/main/java/com/kairo/reader/ui/theme/ThemeCopy.kt b/app/src/main/java/com/kairo/reader/ui/theme/ThemeCopy.kt new file mode 100644 index 00000000..5840fe1e --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/theme/ThemeCopy.kt @@ -0,0 +1,21 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.ui.graphics.toArgb +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme + +/** Pin editable colours and retain the preset's surfaces when starting a personal copy. */ +internal fun ReaderTheme.copyAsCustom(existing: CustomTheme): CustomTheme { + if (this == ReaderTheme.CUSTOM) return existing + val scheme = materialColorScheme() + return CustomTheme( + background = scheme.background.toArgb(), + harmony = existing.harmony, + surface = scheme.surface.toArgb(), + text = scheme.onBackground.toArgb(), + primary = scheme.primary.toArgb(), + secondary = scheme.secondary.toArgb(), + tertiary = scheme.tertiary.toArgb(), + sourcePreset = this, + ) +} diff --git a/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt b/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt index fe42b780..92c12033 100644 --- a/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt +++ b/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt @@ -28,6 +28,15 @@ class CustomThemeTest { assertEquals(0xFF445566.toInt(), recovered.primary) } + @Test + fun legacyThemesMigrateAndCopiedPresetMetadataSurvives() { + val old = decodeCustomTheme("1|#001122|TONAL||#FFFFFF|#AABBCC||") + assertEquals(old, decodeCustomTheme(old.encode())) + val copy = old.copy(sourcePreset = ReaderTheme.EMBER) + assertEquals(copy, decodeCustomTheme(copy.encode())) + assertNull(decodeCustomTheme(copy.encode().replace("EMBER", "CUSTOM")).sourcePreset) + } + @Test fun hexInputAcceptsSixDigitsOnlyAndIsAlwaysOpaque() { assertEquals(0xFFAABBCC.toInt(), parseThemeColor(" #aabbcc ")) diff --git a/app/src/test/java/com/kairo/reader/data/preferences/ThemeLibraryCodecTest.kt b/app/src/test/java/com/kairo/reader/data/preferences/ThemeLibraryCodecTest.kt new file mode 100644 index 00000000..88e71d09 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/data/preferences/ThemeLibraryCodecTest.kt @@ -0,0 +1,46 @@ +package com.kairo.reader.data.preferences + +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.MAX_SAVED_THEMES +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.core.model.ThemeDesign +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ThemeLibraryCodecTest { + @Test + fun namesColoursAndAllThreeFontsRoundTripWithoutDelimiterLoss() { + val design = ThemeDesign( + custom = CustomTheme(sourcePreset = ReaderTheme.EMBER), + readerFont = RsvpFontFamily.LEXEND, + interfaceFont = RsvpFontFamily.LORA, + timedFont = RsvpFontFamily.MONOSPACE + ) + val themes = + listOf(SavedTheme("one", "夜 | ~ \"Reading\"", design), SavedTheme("two", "Linen", ThemeDesign(theme = ReaderTheme.LINEN))) + assertEquals(themes, ThemeLibraryCodec.decode(ThemeLibraryCodec.encode(themes))) + } + + @Test + fun damagedAndDuplicateRowsDoNotEraseValidDesigns() { + val item = JSONObject().put("id", "one").put("name", "Valid").put("design", ThemeDesign().encode()) + val raw = JSONObject().put("version", 1).put( + "themes", + JSONArray().put("broken").put(item).put(item) + .put(JSONObject().put("id", "bad").put("name", "Broken").put("design", "bad")) + ).toString() + assertEquals(listOf(SavedTheme("one", "Valid", ThemeDesign())), ThemeLibraryCodec.decode(raw)) + listOf(null, "", "{", "{\"version\":2,\"themes\":[]}").forEach { assertTrue(ThemeLibraryCodec.decode(it).isEmpty()) } + } + + @Test + fun collectionSizeIsBounded() { + val themes = (0..MAX_SAVED_THEMES).map { SavedTheme("$it", "Theme $it", ThemeDesign()) } + assertEquals(MAX_SAVED_THEMES, ThemeLibraryCodec.decode(ThemeLibraryCodec.encode(themes)).size) + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt index 58fb24b9..0525c4dc 100644 --- a/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt @@ -2,8 +2,10 @@ package com.kairo.reader.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import com.kairo.reader.core.model.ColorHarmony import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme import kotlin.random.Random import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -52,6 +54,24 @@ class CustomColorSchemeTest { assertEquals(theme, theme.copy(harmony = ColorHarmony.TRIADIC).copy(harmony = theme.harmony)) } + @Test + fun editingAPresetPinsItsColoursAndKeepsContrastWhenEdited() { + ReaderTheme.entries.filterNot { it == ReaderTheme.CUSTOM }.forEach { preset -> + val original = preset.materialColorScheme() + val copy = preset.copyAsCustom(CustomTheme()) + assertEquals(original.background.toArgb(), copy.background) + assertEquals(original.surface.toArgb(), copy.surface) + assertEquals(original.onBackground.toArgb(), copy.text) + assertEquals(original.primary.toArgb(), copy.primary) + assertEquals(original.secondary.toArgb(), copy.secondary) + assertEquals(original.tertiary.toArgb(), copy.tertiary) + assertScheme(copy.materialColorScheme()) + listOf(0xFF747474, 0xFF000000, 0xFFFFFFFF, 0xFFFF0000).forEach { background -> + assertScheme(copy.copy(background = background.toInt()).materialColorScheme()) + } + } + } + private fun assertScheme(scheme: ColorScheme) { val surfaces = listOf( scheme.background, scheme.surface, scheme.surfaceVariant, scheme.surfaceContainerLowest, From c831e9234154831b5b8a2710028b51875a7d7bea Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sun, 13 Sep 2026 13:29:21 +0100 Subject: [PATCH 8/9] feat(settings): add saved themes, undo and richer previews --- .../ui/settings/ThemeLibraryDeviceTest.kt | 177 ++++++++++++++++++ .../ui/settings/ThemeSettingsDeviceTest.kt | 26 +-- .../reader/ui/navigation/SettingsRoutes.kt | 15 +- .../com/kairo/reader/ui/rsvp/RsvpScreenUi.kt | 2 +- .../reader/ui/settings/ThemeColorEditor.kt | 110 ++++++++--- .../reader/ui/settings/ThemeHistoryActions.kt | 68 +++++++ .../kairo/reader/ui/settings/ThemeLibrary.kt | 146 +++++++++++++++ .../reader/ui/settings/ThemeLivePreview.kt | 172 ++++++++++++++--- .../reader/ui/settings/ThemePaletteEditor.kt | 16 +- .../reader/ui/settings/ThemeSettingsScreen.kt | 153 +++++++++------ .../reader/ui/settings/ThemeStudioState.kt | 99 ++++++++++ .../ui/settings/ThemeStudioWorkspace.kt | 31 +-- app/src/main/res/values-de/theme_strings.xml | 38 +++- app/src/main/res/values-es/theme_strings.xml | 39 +++- app/src/main/res/values-fr/theme_strings.xml | 39 +++- app/src/main/res/values-ja/theme_strings.xml | 37 +++- .../main/res/values-pt-rBR/theme_strings.xml | 39 +++- .../main/res/values-zh-rCN/theme_strings.xml | 37 +++- app/src/main/res/values/theme_strings.xml | 38 +++- .../ui/settings/ThemeStudioStateTest.kt | 57 ++++++ 20 files changed, 1176 insertions(+), 163 deletions(-) create mode 100644 app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeLibraryDeviceTest.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeHistoryActions.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeLibrary.kt create mode 100644 app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioState.kt create mode 100644 app/src/test/java/com/kairo/reader/ui/settings/ThemeStudioStateTest.kt diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeLibraryDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeLibraryDeviceTest.kt new file mode 100644 index 00000000..5764fb37 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeLibraryDeviceTest.kt @@ -0,0 +1,177 @@ +package com.kairo.reader.ui.settings + +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.R +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.core.model.ThemeDesign +import com.kairo.reader.core.model.UserPreferences +import com.kairo.reader.ui.theme.KairoTheme +import java.io.File +import java.io.IOException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ThemeLibraryDeviceTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun savedDesignAndUndoSurviveRecreationThenApplyTogether() { + var saved: List? = null + val restoration = StateRestorationTester(rule) + restoration.setContent { + KairoTheme { + ThemeSettingsScreen( + UserPreferences(readerFontFamily = RsvpFontFamily.LORA), + onApply = { _, themes -> saved = themes }, + onBack = {} + ) + } + } + node(R.string.theme_save_new).performScrollTo().performClick() + node(R.string.theme_name).performTextReplacement("Evening") + node(R.string.theme_done).performClick() + restoration.emulateSavedInstanceStateRestore() + rule.onNodeWithText("Evening").performScrollTo().assertIsDisplayed() + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_manage_named, "Evening")).performClick() + node(R.string.theme_delete).performClick() + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_undo)).performClick() + rule.onNodeWithText("Evening").performScrollTo().assertIsDisplayed() + capture("theme-library") + node(R.string.theme_apply).performClick() + rule.runOnIdle { + assertEquals("Evening", saved?.single()?.name) + assertEquals(RsvpFontFamily.LORA, saved?.single()?.design?.readerFont) + } + } + + @Test + fun duplicateRenameAndColourResetKeepTheOriginalAndItsFonts() { + val original = SavedTheme( + "first", + "Original", + ThemeDesign( + custom = CustomTheme(background = 0xFF172C29.toInt()), + readerFont = RsvpFontFamily.LORA + ) + ) + var result: List? = null + var appearance: ThemeDesign? = null + rule.setContent { + KairoTheme { + ThemeSettingsScreen(UserPreferences(savedThemes = listOf(original)), { design, saved -> + appearance = design + result = saved + }, {}) + } + } + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_manage_named, "Original")).performScrollTo().performClick() + node(R.string.theme_duplicate).performClick() + node(R.string.theme_name).performTextReplacement("Second") + node(R.string.theme_done).performClick() + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_manage_named, "Second")).performScrollTo().performClick() + node(R.string.theme_rename).performClick() + node(R.string.theme_name).performTextReplacement("Renamed") + node(R.string.theme_done).performClick() + rule.onNodeWithText("Renamed").performScrollTo().performClick() + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_reset_options)).performClick() + node(R.string.theme_reset_colours).performClick() + node(R.string.theme_apply).performClick() + rule.runOnIdle { + assertEquals(listOf("Original", "Renamed"), result?.map { it.name }) + assertEquals(original, result?.first()) + assertEquals(CustomTheme(), appearance?.custom) + assertEquals(RsvpFontFamily.LORA, appearance?.readerFont) + } + } + + @Test + fun undoRestoresColourFieldAndPreviewTabsWork() { + rule.setContent { + KairoTheme { + ThemeSettingsScreen(UserPreferences(readerTheme = ReaderTheme.CUSTOM), { _, _ -> }, {}) + } + } + node(R.string.theme_hex).performScrollTo().performTextReplacement("#172C29") + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_undo)).performClick() + node(R.string.theme_hex).performScrollTo().assertTextContains("#F4EFE4") + rule.onNodeWithTag("theme-preview-RSVP").performClick() + node(R.string.theme_preview_paused).performScrollTo().assertIsDisplayed() + capture("theme-rsvp-preview") + rule.onNodeWithTag("theme-preview-INTERFACE").performScrollTo().performClick() + node(R.string.theme_preview_continue).performScrollTo().assertIsDisplayed() + capture("theme-interface-preview") + } + + @Test + fun failedSaveKeepsDraftOpenAndCanBeRetried() { + var fail = true + var closed = false + var applied: ThemeDesign? = null + rule.setContent { + KairoTheme { + ThemeSettingsScreen(UserPreferences(readerTheme = ReaderTheme.CUSTOM), { design, _ -> + if (fail) throw IOException("Test storage error") + applied = design + }, { closed = true }) + } + } + node(R.string.theme_hex).performScrollTo().performTextReplacement("#172C29") + node(R.string.theme_apply).performClick() + node(R.string.theme_save_error).assertIsDisplayed() + rule.runOnIdle { + assertFalse(closed) + fail = false + } + node(R.string.theme_apply).performClick() + rule.runOnIdle { assertEquals(0xFF172C29.toInt(), applied?.custom?.background) } + } + + @Test + fun invalidColourCannotBeAppliedFromAnotherTabAndRevertClearsIt() { + val restoration = StateRestorationTester(rule) + restoration.setContent { + KairoTheme { ThemeSettingsScreen(UserPreferences(readerTheme = ReaderTheme.CUSTOM), { _, _ -> }, {}) } + } + node(R.string.theme_hex).performScrollTo().performTextReplacement("#12") + node(R.string.theme_tab_fonts).performClick() + restoration.emulateSavedInstanceStateRestore() + node(R.string.theme_apply).assertIsNotEnabled() + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_reset_options)).performClick() + node(R.string.theme_revert).performClick() + node(R.string.theme_apply).assertIsEnabled() + node(R.string.theme_tab_colours).performClick() + node(R.string.theme_hex).performScrollTo().assertTextContains("#F4EFE4") + } + + private fun capture(name: String) { + val bitmap = rule.onRoot().captureToImage().asAndroidBitmap() + File(rule.activity.cacheDir, "$name.png").outputStream().use { + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + } + + private fun node(id: Int) = rule.onNodeWithText(rule.activity.getString(id)) +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt index 29a31328..f79629b4 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt @@ -54,13 +54,14 @@ class ThemeSettingsDeviceTest { val restoration = StateRestorationTester(rule) restoration.setContent { KairoTheme { - ThemeSettingsScreen(UserPreferences(), onApply = { theme, custom, reader, interfaceFont, timed -> + ThemeSettingsScreen(UserPreferences(), onApply = { design, saved -> applied = UserPreferences( - readerTheme = theme, - customTheme = custom, - readerFontFamily = reader, - interfaceFontFamily = interfaceFont, - rsvpFontFamily = timed + readerTheme = design.theme, + customTheme = design.custom, + savedThemes = saved, + readerFontFamily = design.readerFont, + interfaceFontFamily = design.interfaceFont, + rsvpFontFamily = design.timedFont ) }, onBack = {}) } @@ -91,7 +92,7 @@ class ThemeSettingsDeviceTest { var applied = false rule.setContent { KairoTheme { - ThemeSettingsScreen(UserPreferences(), onApply = { _, _, _, _, _ -> applied = true }, onBack = { closed = true }) + ThemeSettingsScreen(UserPreferences(), onApply = { _, _ -> applied = true }, onBack = { closed = true }) } } node(R.string.theme_tab_colours).performClick() @@ -120,7 +121,7 @@ class ThemeSettingsDeviceTest { readerTheme = ReaderTheme.CUSTOM, customTheme = CustomTheme(background = 0xFF172C29.toInt()) ), - onApply = { _, _, _, _, _ -> }, + onApply = { _, _ -> }, onBack = {} ) } @@ -161,7 +162,7 @@ class ThemeSettingsDeviceTest { @Test fun draggingKeepsPreviewAndControlsAnchored() { - rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _, _, _, _ -> }, {}) } } + rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _ -> }, {}) } } node(R.string.theme_tab_colours).performClick() val before = rule.onNodeWithTag("theme-controls").getUnclippedBoundsInRoot() rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress))[2] @@ -175,11 +176,12 @@ class ThemeSettingsDeviceTest { @Test fun incompleteHexBlocksApplyAndResetResetsEditorAsWellAsPreview() { - rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _, _, _, _ -> }, {}) } } + rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _ -> }, {}) } } node(R.string.theme_tab_colours).performClick() node(R.string.theme_hex).performScrollTo().performTextReplacement("#12") node(R.string.theme_apply).assertIsNotEnabled() - node(R.string.theme_start_again).performClick() + rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_reset_options)).performClick() + node(R.string.theme_reset_colours).performClick() node(R.string.theme_hex).performScrollTo().assertTextContains("#F4EFE4") node(R.string.theme_apply).assertIsEnabled() } @@ -193,7 +195,7 @@ class ThemeSettingsDeviceTest { fun landscapeKeepsPreviewBesideControls() { rule.activityRule.scenario.onActivity { it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE } rule.waitUntil(5_000) { rule.activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } - rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _, _, _, _ -> }, {}) } } + rule.setContent { KairoTheme { ThemeSettingsScreen(UserPreferences(), { _, _ -> }, {}) } } node(R.string.theme_tab_colours).performClick() node(R.string.theme_hex).performScrollTo().assertIsDisplayed() val preview = rule.onNodeWithTag("theme-live-preview").getUnclippedBoundsInRoot() diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt index a6319bfb..cfc62032 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt @@ -79,14 +79,17 @@ internal fun NavGraphBuilder.settingsRoutes(dependencies: SettingsRouteDependenc } settingsSearchDestination(KairoRoutes.SETTINGS_THEME) { - val scope = rememberCoroutineScope() com.kairo.reader.ui.settings.ThemeSettingsScreen( preferences = dependencies.prefs, - onApply = { theme, custom, reader, interfaceFont, timed -> - scope.launch { - dependencies.container.preferencesRepository.updateAppearance(custom, reader, interfaceFont, timed, theme) - dependencies.navController.popBackStack() - } + onApply = { design, saved -> + dependencies.container.preferencesRepository.updateAppearance( + design.custom, + design.readerFont, + design.interfaceFont, + design.timedFont, + design.theme, + saved, + ) }, onBack = { dependencies.navController.popBackStack() }, ) diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt index d55ebe6b..e12cd067 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt @@ -243,7 +243,7 @@ internal fun rememberRsvpDisplayedSpeed( } @Composable -private fun rememberRsvpTextColors( +internal fun rememberRsvpTextColors( textBrightness: Float, config: RsvpConfig, ): OrpColors { diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt index 2156605b..5af13796 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt @@ -2,17 +2,25 @@ package com.kairo.reader.ui.settings +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -21,6 +29,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -37,11 +47,13 @@ internal fun ThemeColorEditor( initial: Int, automatic: Boolean, onChange: (Int?) -> Unit, - onValidityChange: (Boolean) -> Unit = {} + onValidityChange: (Boolean) -> Unit = {}, + effective: Int = initial, + onEditStart: () -> Unit = {}, + onEditFinished: () -> Unit = {}, ) { var hex by rememberSaveable { mutableStateOf(themeColorHex(initial)) } LaunchedEffect(hex, automatic) { onValidityChange(automatic || parseThemeColor(hex) != null) } - DisposableEffect(Unit) { onDispose { onValidityChange(true) } } val hsv = FloatArray(3) android.graphics.Color.colorToHSV(initial, hsv) var hue by rememberSaveable { mutableFloatStateOf(hsv[0]) } @@ -55,6 +67,7 @@ internal fun ThemeColorEditor( } val color = android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, brightness)) hex = themeColorHex(color) + onEditStart() onChange(color) } val automaticLabel = stringResource(R.string.theme_automatic) @@ -64,15 +77,36 @@ internal fun ThemeColorEditor( Text(stringResource(R.string.theme_automatic), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) Switch( checked = automatic, - onCheckedChange = { onChange(if (it) null else initial) }, + onCheckedChange = { + onEditFinished() + onChange(if (it) null else initial) + }, modifier = Modifier.semantics { contentDescription = automaticLabel } ) } } if (!automatic) { - ThemeColorSlider(stringResource(R.string.theme_hue), hue, 359f) { update(0, it) } - ThemeColorSlider(stringResource(R.string.theme_saturation), saturation, 1f) { update(1, it) } - ThemeColorSlider(stringResource(R.string.theme_lightness), brightness, 1f) { update(2, it) } + ThemeColorSlider( + stringResource(R.string.theme_hue), + hue, + 359f, + (0..6).map { Color.hsv((it * 60f).coerceAtMost(359f), 1f, 1f) }, + onEditFinished + ) { update(0, it) } + ThemeColorSlider( + stringResource(R.string.theme_saturation), + saturation, + 1f, + listOf(Color.hsv(hue, 0f, brightness), Color.hsv(hue, 1f, brightness)), + onEditFinished + ) { update(1, it) } + ThemeColorSlider( + stringResource(R.string.theme_lightness), + brightness, + 1f, + listOf(Color.Black, Color.hsv(hue, saturation, 1f)), + onEditFinished + ) { update(2, it) } OutlinedTextField( value = hex, onValueChange = { value -> @@ -83,6 +117,7 @@ internal fun ThemeColorEditor( hue = hsv[0] saturation = hsv[1] brightness = hsv[2] + onEditFinished() onChange(it) } } @@ -93,6 +128,8 @@ internal fun ThemeColorEditor( isError = parseThemeColor(hex) == null, supportingText = { if (parseThemeColor(hex) == null) Text(stringResource(R.string.theme_hex_error)) }, ) + val requested = parseThemeColor(hex) + if (requested != null && requested != effective) ThemeAdjustedColor(requested, effective) } else { Text(stringResource(R.string.theme_automatic_help), style = MaterialTheme.typography.bodySmall) } @@ -100,27 +137,52 @@ internal fun ThemeColorEditor( } @Composable -private fun ThemeColorSlider(title: String, value: Float, maximum: Float, onChange: (Float) -> Unit) { +private fun ThemeColorSlider( + title: String, + value: Float, + maximum: Float, + colors: List, + onFinished: () -> Unit, + onChange: (Float) -> Unit, +) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Text(title, style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(0.3f)) - Slider( - value, - onValueChange = onChange, - valueRange = 0f..maximum, - modifier = Modifier.weight(0.55f).semantics { contentDescription = title } - ) + Box(Modifier.weight(0.55f), contentAlignment = Alignment.Center) { + Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp).height(8.dp).background(Brush.horizontalGradient(colors), CircleShape)) + Slider( + value, + onValueChange = onChange, + onValueChangeFinished = onFinished, + valueRange = 0f..maximum, + colors = SliderDefaults.colors(activeTrackColor = Color.Transparent, inactiveTrackColor = Color.Transparent), + modifier = Modifier.semantics { contentDescription = title }, + ) + } Text( - ( - if (maximum > - 1f - ) { - value - } else { - value * 100 - } - ).roundToInt().toString(), + (if (maximum > 1f) value else value * 100).roundToInt().toString(), style = MaterialTheme.typography.labelSmall, - modifier = Modifier.weight(0.15f) + modifier = Modifier.weight(0.15f), ) } } + +@Composable +private fun ThemeAdjustedColor(requested: Int, effective: Int) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.theme_adjusted_help), style = MaterialTheme.typography.bodySmall) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + listOf(R.string.theme_requested to requested, R.string.theme_displayed to effective).forEach { (label, color) -> + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Surface( + Modifier.size(32.dp), + color = Color(color), + shape = CircleShape, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + ) {} + Text(stringResource(label), style = MaterialTheme.typography.labelMedium) + Text(themeColorHex(color), style = MaterialTheme.typography.bodySmall) + } + } + } + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeHistoryActions.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeHistoryActions.kt new file mode 100644 index 00000000..71740c28 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeHistoryActions.kt @@ -0,0 +1,68 @@ +package com.kairo.reader.ui.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Undo +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import com.kairo.reader.R +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.ThemeDesign + +@Composable +internal fun ThemeHistoryActions(state: ThemeStudioState, onRevert: () -> Unit) { + val focus = LocalFocusManager.current + var expanded by remember { mutableStateOf(false) } + Row { + IconButton(onClick = { + focus.clearFocus() + state.undo() + }, enabled = state.canUndo) { + Icon(Icons.AutoMirrored.Outlined.Undo, stringResource(R.string.theme_undo)) + } + Box { + IconButton(onClick = { + focus.clearFocus() + expanded = true + }, enabled = !state.locked) { + Icon(Icons.Outlined.MoreVert, stringResource(R.string.theme_reset_options)) + } + DropdownMenu(expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.theme_revert)) }, onClick = { + expanded = false + onRevert() + }) + DropdownMenuItem(text = { Text(stringResource(R.string.theme_reset_colours)) }, onClick = { + expanded = false + state.reset(state.draft.copy(design = state.draft.design.copy(theme = ReaderTheme.CUSTOM, custom = CustomTheme()))) + }) + DropdownMenuItem(text = { Text(stringResource(R.string.theme_reset_fonts)) }, onClick = { + expanded = false + val defaults = ThemeDesign() + state.reset( + state.draft.copy( + design = state.draft.design.copy( + readerFont = defaults.readerFont, + interfaceFont = defaults.interfaceFont, + timedFont = defaults.timedFont, + ) + ) + ) + }) + } + } + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLibrary.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLibrary.kt new file mode 100644 index 00000000..ce44ea31 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLibrary.kt @@ -0,0 +1,146 @@ +@file:Suppress("LongMethod") + +package com.kairo.reader.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kairo.reader.R +import com.kairo.reader.core.model.MAX_SAVED_THEMES +import com.kairo.reader.core.model.MAX_THEME_NAME_LENGTH +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.ui.theme.materialColorScheme +import java.util.UUID + +@Composable +internal fun ThemeLibrary(state: ThemeStudioState, validColor: Boolean, onSelect: () -> Unit) { + var naming by rememberSaveable { mutableStateOf(null) } + var targetId by rememberSaveable { mutableStateOf(null) } + var name by rememberSaveable { mutableStateOf("") } + val saved = state.draft.saved + val room = saved.size < MAX_SAVED_THEMES + Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.theme_library_help), style = MaterialTheme.typography.bodySmall) + OutlinedButton(onClick = { + naming = "save" + targetId = null + name = "" + }, enabled = validColor && room && !state.locked) { + Text(stringResource(R.string.theme_save_new)) + } + if (!room) { + Text( + pluralStringResource(R.plurals.theme_library_full, MAX_SAVED_THEMES, MAX_SAVED_THEMES), + style = MaterialTheme.typography.bodySmall + ) + } + val copySuffix = stringResource(R.string.theme_copy_suffix) + saved.forEach { entry -> + ThemeLibraryCard(entry, room, onLoad = { + state.design(entry.design, resetEditor = true) + onSelect() + }, onAction = { action -> + if (action == "delete") { + state.change(state.draft.copy(saved = saved.filterNot { it.id == entry.id })) + } else { + targetId = entry.id + naming = action + name = if (action == "duplicate") "${entry.name} $copySuffix".take(MAX_THEME_NAME_LENGTH) else entry.name + } + }) + } + if (naming != null) { + val trimmed = name.trim() + val duplicate = saved.any { it.name.equals(trimmed, ignoreCase = true) && (naming != "rename" || it.id != targetId) } + AlertDialog( + onDismissRequest = { naming = null }, + title = { Text(stringResource(if (naming == "rename") R.string.theme_rename else R.string.theme_save_new)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it.take(MAX_THEME_NAME_LENGTH) }, + singleLine = true, + label = { Text(stringResource(R.string.theme_name)) }, + isError = duplicate, + supportingText = { if (duplicate) Text(stringResource(R.string.theme_name_exists)) }, + ) + }, + confirmButton = { + TextButton(enabled = trimmed.isNotEmpty() && !duplicate && !state.locked, onClick = { + val source = saved.find { it.id == targetId } + val next = if (naming == "rename") { + saved.map { if (it.id == targetId) it.copy(name = trimmed) else it } + } else { + saved + SavedTheme(UUID.randomUUID().toString(), trimmed, source?.design ?: state.draft.design) + } + state.change(state.draft.copy(saved = next)) + naming = null + }) { Text(stringResource(R.string.theme_done)) } + }, + dismissButton = { TextButton(onClick = { naming = null }) { Text(stringResource(R.string.theme_cancel)) } }, + ) + } +} + +@Composable +private fun ThemeLibraryCard(entry: SavedTheme, canDuplicate: Boolean, onLoad: () -> Unit, onAction: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val scheme = remember(entry.design) { entry.design.theme.materialColorScheme(entry.design.custom) } + Surface(onClick = onLoad, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainer) { + Row(Modifier.fillMaxWidth().padding(start = 12.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf(scheme.background, scheme.onBackground, scheme.primary).forEach { + Box(Modifier.size(20.dp).background(it, CircleShape)) + } + } + Text(entry.name, modifier = Modifier.weight(1f).padding(horizontal = 12.dp), style = MaterialTheme.typography.titleSmall) + Box { + IconButton(onClick = { expanded = true }) { + Icon(Icons.Outlined.MoreVert, stringResource(R.string.theme_manage_named, entry.name)) + } + DropdownMenu(expanded, onDismissRequest = { expanded = false }) { + listOf("rename" to R.string.theme_rename, "duplicate" to R.string.theme_duplicate, "delete" to R.string.theme_delete) + .forEach { (action, label) -> + DropdownMenuItem( + text = { Text(stringResource(label)) }, + enabled = action != "duplicate" || canDuplicate, + onClick = { + expanded = false + onAction(action) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt index 75cbc116..d15ef4b2 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt @@ -1,4 +1,4 @@ -@file:Suppress("MagicNumber") +@file:Suppress("MagicNumber", "MatchingDeclarationName") package com.kairo.reader.ui.settings @@ -9,10 +9,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Surface +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -20,21 +23,44 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kairo.reader.R -import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.HighlightColor +import com.kairo.reader.core.model.ReaderTheme +import com.kairo.reader.core.model.ThemeDesign +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.model.UserPreferences +import com.kairo.reader.ui.reader.readerSpanColor +import com.kairo.reader.ui.rsvp.OrpAlignedText +import com.kairo.reader.ui.rsvp.OrpTextLayout +import com.kairo.reader.ui.rsvp.OrpTypography +import com.kairo.reader.ui.rsvp.rememberRsvpTextColors +import com.kairo.reader.ui.rsvp.resolveFontWeight +import com.kairo.reader.ui.saved.displayColor import com.kairo.reader.ui.theme.composeFontFamily +import com.kairo.reader.ui.theme.readingColor + +internal enum class ThemePreviewKind(val label: Int) { + READER(R.string.theme_font_reader), + RSVP(R.string.theme_preview_rsvp), + INTERFACE(R.string.theme_font_interface), +} @Composable -internal fun ThemeLivePreview(reader: RsvpFontFamily, timed: RsvpFontFamily, section: ThemeStudioSection, custom: Boolean) { +internal fun ThemeLivePreview(preferences: UserPreferences, design: ThemeDesign) { + val focus = LocalFocusManager.current + var kind by rememberSaveable { mutableStateOf(ThemePreviewKind.READER) } var showContrast by rememberSaveable { mutableStateOf(false) } - val largeText = LocalDensity.current.fontScale > 1.3f val scheme = MaterialTheme.colorScheme Surface( modifier = Modifier.testTag("theme-live-preview"), @@ -43,35 +69,40 @@ internal fun ThemeLivePreview(reader: RsvpFontFamily, timed: RsvpFontFamily, sec shape = RoundedCornerShape(24.dp), border = BorderStroke(1.dp, scheme.outlineVariant), ) { - Column(Modifier.fillMaxWidth().padding(if (largeText) 16.dp else 20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - if (!largeText) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryScrollableTabRow(selectedTabIndex = kind.ordinal, edgePadding = 0.dp, containerColor = scheme.background) { + ThemePreviewKind.entries.forEach { item -> + Tab( + selected = item == kind, + onClick = { + focus.clearFocus() + kind = item + }, + text = { Text(stringResource(item.label)) }, + modifier = Modifier.testTag("theme-preview-${item.name}") + ) + } + } + if (design.theme == ReaderTheme.CUSTOM) { + TextButton(onClick = { showContrast = true }) { + Text( + stringResource(R.string.theme_contrast_aa), + style = MaterialTheme.typography.labelSmall, + color = scheme.onBackground + ) + } + } else { Text( stringResource(R.string.theme_preview), - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.heightIn(min = 48.dp).wrapContentHeight().padding(horizontal = 12.dp), color = scheme.primary ) } - Text(stringResource(R.string.theme_live_sample), fontFamily = reader.composeFontFamily(), fontSize = 26.sp, lineHeight = 32.sp) - Row( - Modifier.fillMaxWidth().heightIn(min = 48.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(R.string.theme_preview_word), - fontFamily = timed.composeFontFamily(), - color = scheme.tertiary, - fontSize = 20.sp - ) - if (custom && section != ThemeStudioSection.FONTS) { - TextButton(onClick = { showContrast = true }) { - Text( - stringResource(R.string.theme_contrast_aa), - style = MaterialTheme.typography.labelSmall, - color = scheme.onBackground - ) - } - } + when (kind) { + ThemePreviewKind.READER -> ThemeReaderPreview(preferences, design) + ThemePreviewKind.RSVP -> ThemeRsvpPreview(preferences, design) + ThemePreviewKind.INTERFACE -> ThemeInterfacePreview() } } } @@ -84,3 +115,84 @@ internal fun ThemeLivePreview(reader: RsvpFontFamily, timed: RsvpFontFamily, sec ) } } + +@Composable +private fun ThemeReaderPreview(preferences: UserPreferences, design: ThemeDesign) { + val scheme = MaterialTheme.colorScheme + val body = readingColor(scheme.onBackground.copy(alpha = preferences.readerTextBrightness)) + val highlight = HighlightColor.YELLOW.displayColor().copy(alpha = 0.18f) + val highlightedText = if (design.theme == ReaderTheme.CUSTOM) readerSpanColor(body, scheme.background, listOf(highlight)) else body + val sample = buildAnnotatedString { + append(stringResource(R.string.theme_preview_paragraph)) + append("\n") + withStyle(SpanStyle(background = highlight, color = highlightedText)) { append(stringResource(R.string.theme_preview_highlight)) } + append(" ") + withStyle(SpanStyle(color = scheme.tertiary, textDecoration = TextDecoration.Underline)) { + append(stringResource(R.string.theme_preview_link)) + } + } + Text( + sample, + fontFamily = design.readerFont.composeFontFamily(), + fontSize = preferences.readerFontSizeSp.sp, + lineHeight = (preferences.readerFontSizeSp * 1.5f).sp, + color = body, + modifier = Modifier.testTag("theme-reader-sample") + ) +} + +@Composable +private fun ThemeRsvpPreview(preferences: UserPreferences, design: ThemeDesign) { + val sample = stringResource(R.string.theme_preview_word) + val config = preferences.rsvpConfig + Column(Modifier.fillMaxWidth().heightIn(min = 112.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.theme_preview_paused), style = MaterialTheme.typography.labelSmall) + OrpAlignedText( + tokens = listOf(Token(sample, TokenType.WORD, orpIndex = (sample.length / 3).coerceAtMost(sample.lastIndex))), + typography = OrpTypography( + preferences.rsvpFontSizeSp, + design.timedFont.composeFontFamily(), + resolveFontWeight(preferences.rsvpFontWeight) + ), + colors = rememberRsvpTextColors(preferences.rsvpTextBrightness, config), + layout = OrpTextLayout( + 0f, + false, + false, + false, + false, + config.orpGuideEnabled, + config.orpHighlightEnabled, + config.orpGuideThickness.toFloat() + ), + ) + } +} + +@Composable +private fun ThemeInterfacePreview() { + val scheme = MaterialTheme.colorScheme + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.theme_live_sample), style = MaterialTheme.typography.titleLarge) + Surface(color = scheme.surfaceContainer, contentColor = scheme.onSurfaceVariant, shape = MaterialTheme.shapes.medium) { + Text(stringResource(R.string.theme_preview_paragraph), Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Surface(color = scheme.primary, contentColor = scheme.onPrimary, shape = CircleShapeForPreview) { + Text( + stringResource(R.string.theme_preview_continue), + Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + style = MaterialTheme.typography.labelLarge + ) + } + Text( + stringResource(R.string.theme_preview_link), + Modifier.padding(vertical = 10.dp), + color = scheme.tertiary, + style = MaterialTheme.typography.labelLarge + ) + } + } +} + +private val CircleShapeForPreview = RoundedCornerShape(50) diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt index 59875405..be573d36 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -63,7 +64,14 @@ internal fun CustomTheme.withRole(role: ThemeColorRole, value: Int?): CustomThem } @Composable -internal fun ThemePaletteEditor(theme: CustomTheme, onValidityChange: (Boolean) -> Unit, onChange: (CustomTheme) -> Unit) { +internal fun ThemePaletteEditor( + theme: CustomTheme, + onValidityChange: (Boolean) -> Unit, + onEditStart: () -> Unit = {}, + onEditFinished: () -> Unit = {}, + onChange: (CustomTheme) -> Unit, +) { + val focus = LocalFocusManager.current var editing by rememberSaveable { mutableStateOf(ThemeColorRole.BACKGROUND) } val scheme = remember(theme) { theme.materialColorScheme() } val colors = listOf(scheme.background, scheme.surface, scheme.onBackground, scheme.primary, scheme.secondary, scheme.tertiary) @@ -72,6 +80,8 @@ internal fun ThemePaletteEditor(theme: CustomTheme, onValidityChange: (Boolean) val stateLabel = stringResource(if (theme.roleValue(role) == null) R.string.theme_auto else R.string.theme_pinned) Column( Modifier.semantics { stateDescription = stateLabel }.selectable(editing == role, role = Role.RadioButton, onClick = { + focus.clearFocus() + onEditFinished() editing = role }).padding(4.dp), @@ -85,6 +95,7 @@ internal fun ThemePaletteEditor(theme: CustomTheme, onValidityChange: (Boolean) border = BorderStroke(if (editing == role) 3.dp else 1.dp, MaterialTheme.colorScheme.outline) ) {} Text(stringResource(role.labelRes), style = MaterialTheme.typography.labelMedium) + Text(stateLabel, style = MaterialTheme.typography.labelSmall) } } } @@ -94,6 +105,9 @@ internal fun ThemePaletteEditor(theme: CustomTheme, onValidityChange: (Boolean) role = editing, initial = theme.roleValue(editing) ?: colors[editing.ordinal].toArgb(), automatic = automatic, + effective = colors[editing.ordinal].toArgb(), + onEditStart = onEditStart, + onEditFinished = onEditFinished, onValidityChange = onValidityChange, onChange = { onChange(theme.withRole(editing, it)) }, ) diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt index 28ad55be..0a7a4678 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt @@ -15,62 +15,68 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.kairo.reader.R -import com.kairo.reader.core.model.CustomTheme import com.kairo.reader.core.model.ReaderTheme -import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.core.model.ThemeDesign import com.kairo.reader.core.model.UserPreferences -import com.kairo.reader.core.model.decodeCustomTheme -import com.kairo.reader.core.model.encode +import com.kairo.reader.core.model.themeDesign import com.kairo.reader.ui.theme.KairoTheme -import com.kairo.reader.ui.theme.materialColorScheme +import com.kairo.reader.ui.theme.copyAsCustom +import java.io.IOException +import kotlinx.coroutines.launch @Composable fun ThemeSettingsScreen( preferences: UserPreferences, - onApply: (ReaderTheme, CustomTheme, RsvpFontFamily, RsvpFontFamily, RsvpFontFamily) -> Unit, + onApply: suspend (ThemeDesign, List) -> Unit, onBack: () -> Unit, ) { - var draft by rememberSaveable { mutableStateOf(preferences.customTheme.encode()) } - var selected by rememberSaveable { mutableStateOf(preferences.readerTheme) } - var readerFont by rememberSaveable { mutableStateOf(preferences.readerFontFamily) } - var interfaceFont by rememberSaveable { mutableStateOf(preferences.interfaceFontFamily) } - var timedFont by rememberSaveable { mutableStateOf(preferences.rsvpFontFamily) } - var revision by rememberSaveable { mutableIntStateOf(0) } + val focus = LocalFocusManager.current + val applied = ThemeStudioDraft(preferences.themeDesign(), preferences.savedThemes) + val state = rememberSaveable(saver = ThemeStudioState.Saver) { ThemeStudioState(applied) } + val design = state.draft.design var validColor by rememberSaveable { mutableStateOf(true) } + var validityRevision by remember { mutableIntStateOf(state.revision) } + LaunchedEffect(state.revision) { + if (validityRevision != state.revision) { + validColor = true + validityRevision = state.revision + } + } var confirmDiscard by rememberSaveable { mutableStateOf(false) } - val custom = decodeCustomTheme(draft) - val dirty = draft != preferences.customTheme.encode() || - selected != preferences.readerTheme || - readerFont != preferences.readerFontFamily || - interfaceFont != preferences.interfaceFontFamily || - timedFont != preferences.rsvpFontFamily - val back = { if (dirty) confirmDiscard = true else onBack() } + var saveFailed by rememberSaveable { mutableStateOf(false) } + val dirty = state.draft != applied + val back = { + if (!state.locked) { + if (dirty || !validColor) confirmDiscard = true else onBack() + } + } BackHandler(onBack = back) + val scope = rememberCoroutineScope() val searchTarget = LocalSettingsSearchTarget.current var section by rememberSaveable { mutableStateOf( when { searchTarget?.id == "theme.fonts" -> ThemeStudioSection.FONTS - searchTarget?.id == "theme.custom" || selected == ReaderTheme.CUSTOM -> ThemeStudioSection.COLOURS + searchTarget?.id == "theme.custom" || design.theme == ReaderTheme.CUSTOM -> ThemeStudioSection.COLOURS else -> ThemeStudioSection.PALETTES } ) } - val workingCustom = if (selected == ReaderTheme.CUSTOM) { - custom - } else { - CustomTheme(background = selected.materialColorScheme().background.toArgb(), harmony = custom.harmony) - } + val workingCustom = design.theme.copyAsCustom(design.custom) SettingsScaffold( title = stringResource(R.string.theme_settings_title), onBack = back, @@ -81,51 +87,86 @@ fun ThemeSettingsScreen( ThemeStudioWorkspace( modifier = Modifier.weight(1f), section = section, - revision = revision, - onSectionChange = { section = it }, + revision = state.revision, + onSectionChange = { + focus.clearFocus() + state.endEdit() + section = it + }, preview = { - KairoTheme(selected, custom, readerFont, interfaceFont) { - ThemeLivePreview(readerFont, timedFont, section, selected == ReaderTheme.CUSTOM) + KairoTheme(design.theme, design.custom, design.readerFont, design.interfaceFont) { + ThemeLivePreview(preferences, design) } }, ) { when (section) { - ThemeStudioSection.PALETTES -> ThemePresetSelector(selected, custom) { - selected = it - revision++ + ThemeStudioSection.PALETTES -> { + ThemePresetSelector(design.theme, design.custom) { + state.design(design.copy(theme = it), resetEditor = true) + } + ThemeLibrary(state, validColor, onSelect = { section = ThemeStudioSection.COLOURS }) } ThemeStudioSection.COLOURS -> { - HarmonySelector(workingCustom.harmony) { - draft = workingCustom.copy(harmony = it).encode() - selected = ReaderTheme.CUSTOM + if (design.theme != ReaderTheme.CUSTOM) { + Text( + stringResource(R.string.theme_copy_help), + style = androidx.compose.material3.MaterialTheme.typography.bodySmall + ) + Button(onClick = { + state.design(design.copy(theme = ReaderTheme.CUSTOM, custom = workingCustom), resetEditor = true) + }) { Text(stringResource(R.string.theme_edit_copy)) } } - ThemePaletteEditor(workingCustom, onValidityChange = { validColor = it }) { - draft = it.encode() - selected = ReaderTheme.CUSTOM + HarmonySelector(workingCustom.harmony) { + state.design(design.copy(theme = ReaderTheme.CUSTOM, custom = workingCustom.copy(harmony = it))) } + ThemePaletteEditor( + theme = workingCustom, + onValidityChange = { validColor = it }, + onEditStart = state::beginEdit, + onEditFinished = state::endEdit, + onChange = { state.design(design.copy(custom = it, theme = ReaderTheme.CUSTOM)) }, + ) } ThemeStudioSection.FONTS -> { - ThemeFontSelector(stringResource(R.string.theme_font_reader), readerFont) { readerFont = it } - ThemeFontSelector(stringResource(R.string.theme_font_interface), interfaceFont) { interfaceFont = it } - ThemeFontSelector(stringResource(R.string.theme_font_timed), timedFont) { timedFont = it } + ThemeFontSelector(stringResource(R.string.theme_font_reader), design.readerFont) { + state.design(design.copy(readerFont = it)) + } + ThemeFontSelector(stringResource(R.string.theme_font_interface), design.interfaceFont) { + state.design(design.copy(interfaceFont = it)) + } + ThemeFontSelector(stringResource(R.string.theme_font_timed), design.timedFont) { + state.design(design.copy(timedFont = it)) + } } } } Surface(tonalElevation = 3.dp) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton(onClick = { - revision++ - draft = CustomTheme().encode() - selected = ReaderTheme.CUSTOM - readerFont = RsvpFontFamily.MERRIWEATHER - interfaceFont = RsvpFontFamily.SYSTEM_SANS - timedFont = RsvpFontFamily.INTER - }) { Text(stringResource(R.string.theme_start_again)) } - Button(onClick = { onApply(selected, custom, readerFont, interfaceFont, timedFont) }, enabled = validColor) { - Text(stringResource(R.string.theme_apply)) + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + if (saveFailed) Text(stringResource(R.string.theme_save_error)) + if (!validColor && section != ThemeStudioSection.COLOURS) { + TextButton(onClick = { section = ThemeStudioSection.COLOURS }) { Text(stringResource(R.string.theme_hex_error)) } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + ThemeHistoryActions(state, onRevert = { state.reset(applied) }) + Button( + onClick = { + state.endEdit() + state.locked = true + saveFailed = false + val snapshot = state.draft + scope.launch { + try { + onApply(snapshot.design, snapshot.saved) + onBack() + } catch (_: IOException) { + saveFailed = true + } finally { + state.locked = false + } + } + }, + enabled = validColor && !state.locked, + ) { Text(stringResource(if (state.locked) R.string.theme_saving else R.string.theme_apply)) } } } } diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioState.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioState.kt new file mode 100644 index 00000000..1e2a5b88 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioState.kt @@ -0,0 +1,99 @@ +package com.kairo.reader.ui.settings + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.setValue +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.core.model.ThemeDesign +import com.kairo.reader.core.model.decodeThemeDesign +import com.kairo.reader.data.preferences.ThemeLibraryCodec +import org.json.JSONObject + +internal data class ThemeStudioDraft(val design: ThemeDesign, val saved: List) { + fun encode(): String = JSONObject().apply { + put("design", design.encode()) + put("saved", ThemeLibraryCodec.encode(saved)) + }.toString() +} + +@Stable +internal class ThemeStudioState(initial: ThemeStudioDraft, previous: List = emptyList(), initialRevision: Int = 0,) { + var draft by mutableStateOf(initial) + private set + var revision by mutableIntStateOf(initialRevision) + private set + var locked by mutableStateOf(false) + private val history = mutableStateListOf().apply { addAll(previous.takeLast(MAX_UNDO)) } + private var continuousEdit = false + private var recordedEdit = false + val canUndo: Boolean get() = history.isNotEmpty() && !locked + + fun beginEdit() { + continuousEdit = true + } + fun endEdit() { + continuousEdit = false + recordedEdit = false + } + + fun change(next: ThemeStudioDraft, resetEditor: Boolean = false) { + if (locked) return + if (next == draft) { + if (resetEditor) { + endEdit() + revision++ + } + return + } + if (!continuousEdit || !recordedEdit) { + if (history.size == MAX_UNDO) history.removeAt(0) + history.add(draft) + } + recordedEdit = continuousEdit + draft = next + if (resetEditor) { + endEdit() + revision++ + } + } + + fun design(next: ThemeDesign, resetEditor: Boolean = false) = change(draft.copy(design = next), resetEditor) + + fun undo() { + if (!canUndo) return + draft = history.removeAt(history.lastIndex) + endEdit() + revision++ + } + + /** Also clears incomplete local colour input, even if the persisted draft did not change. */ + fun reset(next: ThemeStudioDraft) { + if (locked) return + change(next) + endEdit() + revision++ + } + + companion object { + val Saver = listSaver( + save = { listOf(it.revision.toString(), it.draft.encode()) + it.history.map(ThemeStudioDraft::encode) }, + restore = { saved -> + val revision = saved.firstOrNull()?.toIntOrNull() ?: 0 + val drafts = saved.drop(1).mapNotNull(::decodeDraft) + drafts.firstOrNull()?.let { ThemeStudioState(it, drafts.drop(1), revision) } + }, + ) + } +} + +private fun decodeDraft(raw: String): ThemeStudioDraft? = runCatching { + val obj = JSONObject(raw) + val design = decodeThemeDesign(obj.getString("design")) ?: return null + ThemeStudioDraft(design, ThemeLibraryCodec.decode(obj.optString("saved"))) +}.getOrNull() + +private const val MAX_UNDO = 16 diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt index c9ed932b..e085ab69 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -43,7 +44,7 @@ internal fun ThemeStudioWorkspace( ) { BoxWithConstraints(modifier) { val previewWidth = maxWidth * 0.4f - val previewHeight = minOf(maxHeight * 0.30f, 196.dp) + val previewHeight = minOf(maxHeight * 0.40f, 244.dp) if (maxWidth >= 640.dp) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp)) { Column(Modifier.width(previewWidth).verticalScroll(rememberScrollState())) { preview() } @@ -67,17 +68,23 @@ private fun ColumnScope.StudioControls( onSectionChange: (ThemeStudioSection) -> Unit, controls: @Composable ColumnScope.() -> Unit, ) { - val stateHolder = rememberSaveableStateHolder() - SecondaryScrollableTabRow(selectedTabIndex = section.ordinal, edgePadding = 12.dp, containerColor = MaterialTheme.colorScheme.surface) { - ThemeStudioSection.entries.forEach { item -> - Tab(selected = section == item, onClick = { onSectionChange(item) }, text = { Text(stringResource(item.titleRes)) }) + key(revision) { + val stateHolder = rememberSaveableStateHolder() + SecondaryScrollableTabRow( + selectedTabIndex = section.ordinal, + edgePadding = 12.dp, + containerColor = MaterialTheme.colorScheme.surface + ) { + ThemeStudioSection.entries.forEach { item -> + Tab(selected = section == item, onClick = { onSectionChange(item) }, text = { Text(stringResource(item.titleRes)) }) + } + } + stateHolder.SaveableStateProvider(section.name) { + Column( + Modifier.weight(1f).fillMaxWidth().testTag("theme-controls").verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = controls, + ) } - } - stateHolder.SaveableStateProvider("${section.name}:$revision") { - Column( - Modifier.weight(1f).fillMaxWidth().testTag("theme-controls").verticalScroll(rememberScrollState()).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - content = controls, - ) } } diff --git a/app/src/main/res/values-de/theme_strings.xml b/app/src/main/res/values-de/theme_strings.xml index ea46017d..fb4a6d1e 100644 --- a/app/src/main/res/values-de/theme_strings.xml +++ b/app/src/main/res/values-de/theme_strings.xml @@ -10,9 +10,8 @@ Oberfläche RSVP & Bionic Design anwenden - Neu beginnen Diesen Entwurf verlassen? - Nicht angewendete Farben und Schriften werden verworfen. + Nicht angewendete Änderungen an Farben, Schriften und Sammlung werden verworfen. Verwerfen Weiter bearbeiten DEIN LESEZIMMER @@ -54,5 +53,38 @@ Schriften Raum zum Lesen. Textkontrast AA - Eigene Designs halten den Lesetextkontrast bei mindestens 4,5:1, auch bei reduzierter Helligkeit. + Eigene Designs schützen den Textkontrast mit mindestens 4,5:1, auch bei gedimmtem Text und hervorgehobenen Passagen. + Erstelle eine eigene Kopie mit diesen fixierten Farben. Aktiviere „Palette folgen“, um einzelne Farben automatisch zu erzeugen. + Kopie bearbeiten + Meine Designs + Farben und Schriften gemeinsam speichern. Änderungen an der Sammlung werden beim Anwenden gespeichert. + Neues Design speichern + + Deine Sammlung bietet Platz für %1$d Design. Lösche es, um Platz zu schaffen. + Deine Sammlung bietet Platz für %1$d Designs. Lösche eines, um Platz zu schaffen. + + Kopie + Umbenennen + Duplizieren + Löschen + %1$s verwalten + Designname + Wähle einen anderen Namen. + Abbrechen + Rückgängig + Zurücksetzen + Angewendetes wiederherstellen + Farben zurücksetzen + Schriften zurücksetzen + Für bessere Lesbarkeit angepasst. Deine Farbe bleibt gespeichert; die angezeigte Farbe kann sich mit dem Hintergrund ändern. + Gewählt + Angezeigt + RSVP + Im Raum wurde es still. Hinter dem Fenster lag das letzte Licht in den Bäumen. + Eine besondere Textstelle. + Kapiteldetails + Angehaltene Vorschau · aktuelle Texteinstellungen + Weiterlesen + Designs konnten nicht gespeichert werden. Dein Entwurf bleibt erhalten. Versuche es erneut. + Speichern… diff --git a/app/src/main/res/values-es/theme_strings.xml b/app/src/main/res/values-es/theme_strings.xml index e84bda81..4bc53b5d 100644 --- a/app/src/main/res/values-es/theme_strings.xml +++ b/app/src/main/res/values-es/theme_strings.xml @@ -10,9 +10,8 @@ Interfaz RSVP y Bionic Aplicar tema - Empezar de nuevo ¿Salir de este diseño? - Se descartarán los colores y las fuentes que no hayas aplicado. + Se descartarán los cambios sin aplicar en colores, fuentes y colección. Descartar Seguir editando TU RINCÓN DE LECTURA @@ -54,5 +53,39 @@ Fuentes Espacio para leer. Contraste de texto AA - Los temas personalizados mantienen un contraste de texto de al menos 4,5:1, incluso al reducir el brillo. + Los temas personalizados mantienen un contraste de texto de al menos 4,5:1, incluso en texto atenuado y pasajes resaltados. + Crea una copia personal con estos colores fijados. Activa Seguir paleta para generar cualquier color automáticamente. + Editar una copia + Mis temas + Guarda colores y fuentes juntos. Los cambios de la colección se guardan al aplicar el tema. + Guardar nuevo tema + + Tu colección admite hasta %1$d tema. Elimínalo para hacer espacio. + Tu colección admite hasta %1$d temas. Elimina uno para hacer espacio. + Tu colección admite hasta %1$d temas. Elimina uno para hacer espacio. + + copia + Renombrar + Duplicar + Eliminar + Gestionar %1$s + Nombre del tema + Elige otro nombre. + Cancelar + Deshacer + Opciones de restablecimiento + Volver al tema aplicado + Restablecer colores + Restablecer fuentes + Ajustado para facilitar la lectura. Se conserva el color elegido; el color mostrado puede cambiar con el fondo. + Elegido + Mostrado + RSVP + La habitación quedó en silencio. Tras la ventana, la última luz permanecía entre los árboles. + Un pasaje para recordar. + Detalles del capítulo + Vista previa en pausa · ajustes de texto actuales + Seguir leyendo + No se pudieron guardar los temas. Tu borrador sigue aquí; inténtalo de nuevo. + Guardando… diff --git a/app/src/main/res/values-fr/theme_strings.xml b/app/src/main/res/values-fr/theme_strings.xml index d09a0e8e..23229b9d 100644 --- a/app/src/main/res/values-fr/theme_strings.xml +++ b/app/src/main/res/values-fr/theme_strings.xml @@ -10,9 +10,8 @@ Interface RSVP et Bionic Appliquer le thème - Recommencer Quitter cette création ? - Les couleurs et polices non appliquées seront abandonnées. + Les modifications non appliquées aux couleurs, polices et à la collection seront abandonnées. Abandonner Continuer VOTRE COIN LECTURE @@ -54,5 +53,39 @@ Polices Une place pour lire. Contraste du texte AA - Les thèmes personnalisés maintiennent un contraste du texte d’au moins 4,5:1, même avec une luminosité réduite. + Les thèmes personnalisés maintiennent un contraste du texte de 4,5:1 ou plus, même pour le texte atténué et les passages surlignés. + Créez une copie personnelle avec ces couleurs fixées. Activez Suivre la palette pour générer une couleur automatiquement. + Modifier une copie + Mes thèmes + Enregistrez couleurs et polices ensemble. Les modifications de la collection sont enregistrées en appliquant le thème. + Enregistrer un nouveau thème + + Votre collection peut contenir %1$d thème. Supprimez-le pour libérer une place. + Votre collection peut contenir %1$d thèmes. Supprimez-en un pour libérer une place. + Votre collection peut contenir %1$d thèmes. Supprimez-en un pour libérer une place. + + copie + Renommer + Dupliquer + Supprimer + Gérer %1$s + Nom du thème + Choisissez un autre nom. + Annuler + Annuler la modification + Options de réinitialisation + Revenir au thème appliqué + Réinitialiser les couleurs + Réinitialiser les polices + Ajustée pour la lisibilité. La couleur choisie est conservée ; la couleur affichée peut changer avec le fond. + Choisie + Affichée + RSVP + La pièce devint silencieuse. Au-delà de la fenêtre, la dernière lumière restait dans les arbres. + Un passage à retenir. + Détails du chapitre + Aperçu en pause · réglages de texte actuels + Continuer la lecture + Impossible de sauvegarder les thèmes. Votre brouillon est conservé ; réessayez. + Enregistrement… diff --git a/app/src/main/res/values-ja/theme_strings.xml b/app/src/main/res/values-ja/theme_strings.xml index 4f51ebba..187ca439 100644 --- a/app/src/main/res/values-ja/theme_strings.xml +++ b/app/src/main/res/values-ja/theme_strings.xml @@ -10,9 +10,8 @@ 画面 RSVPとBionic テーマを適用 - 最初から作る このデザインを終了しますか? - 適用していない色とフォントは破棄されます。 + 未適用の色、フォント、コレクションの変更は破棄されます。 破棄 編集を続ける あなたの読書空間 @@ -54,5 +53,37 @@ フォント 読むための余白。 文字コントラスト AA - カスタムテーマでは、明るさを下げても読書用の文字のコントラストを4.5:1以上に保ちます。 + カスタムテーマは、暗くした文字やハイライト部分を含め、文字のコントラスト比を4.5:1以上に保ちます。 + これらの色を固定した個人用コピーを作成します。色を自動生成するには「パレットに従う」をオンにしてください。 + コピーを編集 + マイテーマ + 色とフォントをまとめて保存します。コレクションの変更はテーマ適用時に保存されます。 + 新しいテーマを保存 + + 保存できるテーマは%1$d件までです。空きを作るには1件削除してください。 + + コピー + 名前を変更 + 複製 + 削除 + %1$sを管理 + テーマ名 + 別の名前を選んでください。 + キャンセル + 元に戻す + リセット設定 + 適用中の状態に戻す + 色をリセット + フォントをリセット + 読みやすさのために調整されています。選んだ色は保持され、表示色は背景に応じて変化することがあります。 + 選択した色 + 表示色 + RSVP + 部屋は静かになった。窓の向こうでは、最後の光が木々の間に残っていた。 + 心に留めたい一節。 + 章の詳細 + 一時停止中のプレビュー · 現在の文字設定 + 読書を続ける + テーマを保存できませんでした。下書きは保持されています。もう一度お試しください。 + 保存中… diff --git a/app/src/main/res/values-pt-rBR/theme_strings.xml b/app/src/main/res/values-pt-rBR/theme_strings.xml index 12b489e0..d710f3bc 100644 --- a/app/src/main/res/values-pt-rBR/theme_strings.xml +++ b/app/src/main/res/values-pt-rBR/theme_strings.xml @@ -10,9 +10,8 @@ Interface RSVP e Bionic Aplicar tema - Recomeçar Sair deste design? - As cores e fontes não aplicadas serão descartadas. + As alterações não aplicadas às cores, fontes e à coleção serão descartadas. Descartar Continuar editando SEU CANTO DE LEITURA @@ -54,5 +53,39 @@ Fontes Espaço para ler. Contraste de texto AA - Temas personalizados mantêm o contraste do texto em pelo menos 4,5:1, mesmo com brilho reduzido. + Os temas personalizados mantêm contraste de texto de pelo menos 4,5:1, incluindo texto atenuado e trechos destacados. + Crie uma cópia pessoal com estas cores fixadas. Ative Seguir paleta para gerar qualquer cor automaticamente. + Editar uma cópia + Meus temas + Salve cores e fontes juntas. As alterações na coleção são salvas ao aplicar o tema. + Salvar novo tema + + Sua coleção comporta até %1$d tema. Exclua-o para liberar espaço. + Sua coleção comporta até %1$d temas. Exclua um para liberar espaço. + Sua coleção comporta até %1$d temas. Exclua um para liberar espaço. + + cópia + Renomear + Duplicar + Excluir + Gerenciar %1$s + Nome do tema + Escolha outro nome. + Cancelar + Desfazer + Opções de redefinição + Voltar ao tema aplicado + Redefinir cores + Redefinir fontes + Ajustada para facilitar a leitura. A cor escolhida é mantida; a cor exibida pode mudar com o fundo. + Escolhida + Exibida + RSVP + O quarto ficou em silêncio. Além da janela, a última luz permanecia nas árvores. + Um trecho para lembrar. + Detalhes do capítulo + Prévia pausada · ajustes de texto atuais + Continuar lendo + Não foi possível salvar os temas. Seu rascunho foi mantido; tente novamente. + Salvando… diff --git a/app/src/main/res/values-zh-rCN/theme_strings.xml b/app/src/main/res/values-zh-rCN/theme_strings.xml index 274c5947..b3621e65 100644 --- a/app/src/main/res/values-zh-rCN/theme_strings.xml +++ b/app/src/main/res/values-zh-rCN/theme_strings.xml @@ -10,9 +10,8 @@ 界面 RSVP 与 Bionic 应用主题 - 重新开始 离开此设计? - 尚未应用的颜色和字体将被舍弃。 + 尚未应用的颜色、字体和主题集合更改将被丢弃。 舍弃 继续编辑 你的阅读空间 @@ -54,5 +53,37 @@ 字体 留白阅读。 文字对比度 AA - 自定义主题可将阅读文字对比度保持在至少4.5:1,即使降低亮度也不例外。 + 自定义主题将阅读文字对比度保持在4.5:1或以上,包括调暗文字和高亮段落。 + 创建个人副本并固定这些颜色。开启“跟随配色”即可自动生成相应颜色。 + 编辑副本 + 我的主题 + 一起保存颜色与字体。主题应用时会保存主题集合的更改。 + 保存新主题 + + 最多可保存%1$d个主题。请删除一个以腾出空间。 + + 副本 + 重命名 + 复制 + 删除 + 管理%1$s + 主题名称 + 请选择其他名称。 + 取消 + 撤销 + 重置选项 + 恢复已应用设置 + 重置颜色 + 重置字体 + 已为可读性调整。您选择的颜色会被保留;显示颜色可能随背景变化。 + 所选颜色 + 显示颜色 + RSVP + 房间渐渐安静下来。窗外,最后一缕光仍停留在树影间。 + 值得记住的段落。 + 章节详情 + 已暂停预览 · 当前文字设置 + 继续阅读 + 无法保存主题。草稿仍已保留,请重试。 + 正在保存… diff --git a/app/src/main/res/values/theme_strings.xml b/app/src/main/res/values/theme_strings.xml index 7a4585f4..a36e202f 100644 --- a/app/src/main/res/values/theme_strings.xml +++ b/app/src/main/res/values/theme_strings.xml @@ -10,9 +10,8 @@ Interface RSVP & Bionic Apply theme - Start again Leave this design? - Your unapplied colours and fonts will be discarded. + Your unapplied colours, fonts, and collection changes will be discarded. Discard Keep editing YOUR READING ROOM @@ -54,5 +53,38 @@ Fonts Room to read. Text contrast AA - Custom themes protect reading text contrast at 4.5:1 or higher, including when brightness is reduced. + Custom themes protect reading text contrast at 4.5:1 or higher, including dimmed text and highlighted passages. + Create a personal copy with these colours pinned. Turn on Follow palette for any colour you want to generate automatically. + Edit a copy + My themes + Save colours and fonts together. Collection changes are saved when you apply the theme. + Save new theme + + Your collection holds up to %1$d theme. Delete it to make room. + Your collection holds up to %1$d themes. Delete one to make room. + + copy + Rename + Duplicate + Delete + Manage %1$s + Theme name + Choose a different name. + Cancel + Undo + Reset options + Revert to applied + Reset colours + Reset fonts + Adjusted for readability. Your chosen colour is kept; the displayed colour may change with its background. + Requested + Displayed + RSVP + The room grew quiet. Beyond the window, the last light lingered in the trees. + A passage to remember. + Chapter details + Paused preview · your current text settings + Continue reading + Could not save your themes. Your draft is still here; please try again. + Saving… diff --git a/app/src/test/java/com/kairo/reader/ui/settings/ThemeStudioStateTest.kt b/app/src/test/java/com/kairo/reader/ui/settings/ThemeStudioStateTest.kt new file mode 100644 index 00000000..4deb8083 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/settings/ThemeStudioStateTest.kt @@ -0,0 +1,57 @@ +package com.kairo.reader.ui.settings + +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.RsvpFontFamily +import com.kairo.reader.core.model.SavedTheme +import com.kairo.reader.core.model.ThemeDesign +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ThemeStudioStateTest { + private val original = ThemeStudioDraft(ThemeDesign(readerFont = RsvpFontFamily.LORA), emptyList()) + + @Test + fun aWholeSliderGestureIsOneUndoStepAndRetainsFonts() { + val state = ThemeStudioState(original) + state.beginEdit() + repeat(40) { state.design(state.draft.design.copy(custom = CustomTheme(background = it))) } + state.endEdit() + state.undo() + assertEquals(original, state.draft) + assertFalse(state.canUndo) + } + + @Test + fun collectionDeletionAndRevertAreUndoableWithoutLosingDesigns() { + val saved = SavedTheme("one", "Evening", original.design) + val state = ThemeStudioState(original.copy(saved = listOf(saved))) + state.change(state.draft.copy(saved = emptyList())) + state.undo() + assertEquals(listOf(saved), state.draft.saved) + state.reset(original) + assertTrue(state.canUndo) + state.undo() + assertEquals(listOf(saved), state.draft.saved) + } + + @Test + fun resetRefreshesIncompleteEditorEvenWithUnchangedDesign() { + val state = ThemeStudioState(original) + state.reset(original) + assertEquals(1, state.revision) + assertFalse(state.canUndo) + } + + @Test + fun savingLocksMutationAndHistory() { + val state = ThemeStudioState(original) + state.design(ThemeDesign()) + state.locked = true + val snapshot = state.draft + state.undo() + state.reset(original) + assertEquals(snapshot, state.draft) + } +} From b40c0fffec83e203b650c5f279b7d2e86612bbc5 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Sun, 13 Sep 2026 13:29:39 +0100 Subject: [PATCH 9/9] docs(theme): describe saved designs and editor controls --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ce70dd01..4ca04d39 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,10 @@ The result is an Android reader that sits somewhere between an ebook app, a spee - Reader font size and text brightness controls - App-wide themes including Light, Sepia, Dark, Nord, Cyberpunk, and Forest -- A visual theme studio with coordinated colour harmonies, optional colour overrides, and eight offline font choices for the interface and reading modes +- A visual theme studio with coordinated colour harmonies, gradient colour controls, and eight offline font choices +- Reader, RSVP, and interface previews using your reading settings, with visible feedback when colours are adjusted for readability +- Up to 24 named themes with colours and fonts, duplication, renaming, deletion, and undo; collection edits are saved with **Apply theme** +- Editable copies of built-in palettes, separate colour/font resets, and **Revert to applied**; custom reading contrast also accounts for highlights, selection, and focused words - RSVP font family, font weight, brightness, and positioning controls - Focus mode settings, including optional status bar hiding and Do Not Disturb integration - Persistent preferences powered by DataStore