diff --git a/README.md b/README.md index 743ba1dc..4ca04d39 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,11 @@ 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, 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 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..b6d50dda --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/data/preferences/ThemePreferencesDeviceTest.kt @@ -0,0 +1,64 @@ +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.SavedTheme +import com.kairo.reader.core.model.ThemeDesign +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 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, + PrefKeys.rsvpFontFamily to font.name, + ) + 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) + 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/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/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/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 new file mode 100644 index 00000000..f79629b4 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/ThemeSettingsDeviceTest.kt @@ -0,0 +1,215 @@ +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 = { design, saved -> + applied = UserPreferences( + readerTheme = design.theme, + customTheme = design.custom, + savedThemes = saved, + readerFontFamily = design.readerFont, + interfaceFontFamily = design.interfaceFont, + rsvpFontFamily = design.timedFont + ) + }, 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() + 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() + } + + @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/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/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/CustomTheme.kt b/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt new file mode 100644 index 00000000..5239fe08 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/model/CustomTheme.kt @@ -0,0 +1,53 @@ +@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, + val sourcePreset: ReaderTheme? = 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("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 == 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, + 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]), + sourcePreset = ReaderTheme.entries.find { it != ReaderTheme.CUSTOM && it.name == parts.getOrNull(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 3002dace..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 @@ -324,6 +324,10 @@ data class UserPreferences( val hasSeenStartingTutorial: Boolean = false, 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, val invertedScroll: Boolean = false, // RSVP-specific font settings (decoupled from reader) @@ -365,6 +369,7 @@ data class UserPreferences( ) enum class ReaderTheme { + CUSTOM, LIGHT, LINEN, MIST, @@ -379,7 +384,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/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 744dd9eb..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 @@ -77,6 +77,10 @@ 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 savedThemes = stringPreferencesKey("saved_themes") + 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..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 @@ -34,6 +34,15 @@ 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, + savedThemes: List? = null, + ) + 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..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 @@ -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,24 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo } } + override suspend fun updateAppearance( + customTheme: CustomTheme, + readerFont: RsvpFontFamily, + 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 + 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/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 7e46c229..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 @@ -73,6 +73,12 @@ internal class UserPreferencesMapper( return copy( 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] + } ?: 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/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..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 @@ -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,23 @@ internal fun NavGraphBuilder.settingsRoutes(dependencies: SettingsRouteDependenc ) } + settingsSearchDestination(KairoRoutes.SETTINGS_THEME) { + com.kairo.reader.ui.settings.ThemeSettingsScreen( + preferences = dependencies.prefs, + onApply = { design, saved -> + dependencies.container.preferencesRepository.updateAppearance( + design.custom, + design.readerFont, + design.interfaceFont, + design.timedFont, + design.theme, + saved, + ) + }, + 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/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 607f4afe..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,10 +39,10 @@ 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.MerriweatherFontFamily +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 // Rich text spans, focus semantics, and tap geometry must be built in one AnnotatedString layout pass. @Suppress("LongMethod") @@ -58,16 +57,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) { @@ -75,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 = @@ -94,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, @@ -111,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) } @@ -345,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, @@ -384,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/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt index 0d7b2e5d..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 @@ -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 @@ -243,7 +243,7 @@ internal fun rememberRsvpDisplayedSpeed( } @Composable -private fun rememberRsvpTextColors( +internal fun rememberRsvpTextColors( textBrightness: Float, config: RsvpConfig, ): OrpColors { @@ -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/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/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/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/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/settings/ThemeColorEditor.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt new file mode 100644 index 00000000..5af13796 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeColorEditor.kt @@ -0,0 +1,188 @@ +@file:Suppress("MagicNumber", "LongMethod") + +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.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.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 +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 = {}, + effective: Int = initial, + onEditStart: () -> Unit = {}, + onEditFinished: () -> Unit = {}, +) { + var hex by rememberSaveable { mutableStateOf(themeColorHex(initial)) } + LaunchedEffect(hex, automatic) { onValidityChange(automatic || parseThemeColor(hex) != null) } + 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) + onEditStart() + 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 = { + onEditFinished() + onChange(if (it) null else initial) + }, + modifier = Modifier.semantics { contentDescription = automaticLabel } + ) + } + } + if (!automatic) { + 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 -> + if (value.length <= 7) { + hex = value + parseThemeColor(value)?.let { + android.graphics.Color.colorToHSV(it, hsv) + hue = hsv[0] + saturation = hsv[1] + brightness = hsv[2] + onEditFinished() + 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)) }, + ) + val requested = parseThemeColor(hex) + if (requested != null && requested != effective) ThemeAdjustedColor(requested, effective) + } else { + Text(stringResource(R.string.theme_automatic_help), style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +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)) + 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(), + style = MaterialTheme.typography.labelSmall, + 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 new file mode 100644 index 00000000..d15ef4b2 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt @@ -0,0 +1,198 @@ +@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.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 +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.Modifier +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.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(preferences: UserPreferences, design: ThemeDesign) { + val focus = LocalFocusManager.current + var kind by rememberSaveable { mutableStateOf(ThemePreviewKind.READER) } + var showContrast by rememberSaveable { mutableStateOf(false) } + 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(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.labelSmall, + modifier = Modifier.heightIn(min = 48.dp).wrapContentHeight().padding(horizontal = 12.dp), + color = scheme.primary + ) + } + when (kind) { + ThemePreviewKind.READER -> ThemeReaderPreview(preferences, design) + ThemePreviewKind.RSVP -> ThemeRsvpPreview(preferences, design) + ThemePreviewKind.INTERFACE -> ThemeInterfacePreview() + } + } + } + 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)) } }, + ) + } +} + +@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 new file mode 100644 index 00000000..be573d36 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemePaletteEditor.kt @@ -0,0 +1,115 @@ +@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.LocalFocusManager +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, + 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) + 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 = { + focus.clearFocus() + onEditFinished() + 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) + Text(stateLabel, style = MaterialTheme.typography.labelSmall) + } + } + } + val automatic = theme.roleValue(editing) == null + key(editing, automatic, theme.harmony) { + ThemeColorEditor( + 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/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..0a7a4678 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeSettingsScreen.kt @@ -0,0 +1,184 @@ +@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.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.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.ReaderTheme +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.themeDesign +import com.kairo.reader.ui.theme.KairoTheme +import com.kairo.reader.ui.theme.copyAsCustom +import java.io.IOException +import kotlinx.coroutines.launch + +@Composable +fun ThemeSettingsScreen( + preferences: UserPreferences, + onApply: suspend (ThemeDesign, List) -> Unit, + onBack: () -> Unit, +) { + 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) } + 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" || design.theme == ReaderTheme.CUSTOM -> ThemeStudioSection.COLOURS + else -> ThemeStudioSection.PALETTES + } + ) + } + val workingCustom = design.theme.copyAsCustom(design.custom) + 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 = state.revision, + onSectionChange = { + focus.clearFocus() + state.endEdit() + section = it + }, + preview = { + KairoTheme(design.theme, design.custom, design.readerFont, design.interfaceFont) { + ThemeLivePreview(preferences, design) + } + }, + ) { + when (section) { + ThemeStudioSection.PALETTES -> { + ThemePresetSelector(design.theme, design.custom) { + state.design(design.copy(theme = it), resetEditor = true) + } + ThemeLibrary(state, validColor, onSelect = { section = ThemeStudioSection.COLOURS }) + } + ThemeStudioSection.COLOURS -> { + 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)) } + } + 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), 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) { + 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)) } + } + } + } + } + } + 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/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 new file mode 100644 index 00000000..e085ab69 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeStudioWorkspace.kt @@ -0,0 +1,90 @@ +@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.key +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.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() } + 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, +) { + 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, + ) + } + } +} 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/CustomColorScheme.kt b/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt new file mode 100644 index 00000000..b2486e02 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/theme/CustomColorScheme.kt @@ -0,0 +1,125 @@ +@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 com.kairo.reader.core.model.ReaderTheme +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 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 = 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) { + 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 = 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) + val scheme = if (dark) darkColorScheme() else lightColorScheme() + return scheme.copy( + background = bg, onBackground = onSurface, + surface = baseSurface, onSurface = 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, + 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/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/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/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/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/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/main/res/font/lexend.ttf b/app/src/main/res/font/lexend.ttf new file mode 100644 index 00000000..f7099910 Binary files /dev/null and b/app/src/main/res/font/lexend.ttf differ diff --git a/app/src/main/res/font/lora.ttf b/app/src/main/res/font/lora.ttf new file mode 100644 index 00000000..ee1914c3 Binary files /dev/null and b/app/src/main/res/font/lora.ttf differ diff --git a/app/src/main/res/values-de/theme_strings.xml b/app/src/main/res/values-de/theme_strings.xml new file mode 100644 index 00000000..fb4a6d1e --- /dev/null +++ b/app/src/main/res/values-de/theme_strings.xml @@ -0,0 +1,90 @@ + + + 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 + Diesen Entwurf verlassen? + Nicht angewendete Änderungen an Farben, Schriften und Sammlung 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 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 new file mode 100644 index 00000000..4bc53b5d --- /dev/null +++ b/app/src/main/res/values-es/theme_strings.xml @@ -0,0 +1,91 @@ + + + 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 + ¿Salir de este diseño? + Se descartarán los cambios sin aplicar en colores, fuentes y colección. + 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 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 new file mode 100644 index 00000000..23229b9d --- /dev/null +++ b/app/src/main/res/values-fr/theme_strings.xml @@ -0,0 +1,91 @@ + + + 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 + Quitter cette création ? + Les modifications non appliquées aux couleurs, polices et à la collection 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 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 new file mode 100644 index 00000000..187ca439 --- /dev/null +++ b/app/src/main/res/values-ja/theme_strings.xml @@ -0,0 +1,89 @@ + + + テーマとフォント + 自分らしい読書空間に + テーマをカスタマイズ + まず背景を選びましょう。他の色は自動で調和します。 + 書体 + 画面、リーダー、RSVP / Bionicのフォントを選べます。8種類すべてオフラインで使えます。 + リーダー + 画面 + RSVPとBionic + テーマを適用 + このデザインを終了しますか? + 未適用の色、フォント、コレクションの変更は破棄されます。 + 破棄 + 編集を続ける + あなたの読書空間 + ひと呼吸 + 同系色 + 類似色 + 補色 + 三色配色 + ひとつの色相を、穏やかな濃淡で。 + 隣り合う色相で、穏やかにつながる配色に。 + 反対の色相で、印象的なコントラストに。 + 等間隔の3つの色相で、バランスのよい彩りに。 + 背景 + サーフェス + 文字 + メイン + サブ + アクセント + 自動 + 固定 + パレットに追従 + 背景と配色の調和に合わせて、この色を変更します。 + 16進カラー + #A8B5A2のように6桁の16進数を入力してください。 + 色相 + 彩度 + 明度 + 完了 + Merriweather + Lora + Lexend + システムゴシック + システム明朝 + 等幅 + パレット + カスタム + パレット + 色 + フォント + 読むための余白。 + 文字コントラスト AA + カスタムテーマは、暗くした文字やハイライト部分を含め、文字のコントラスト比を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 new file mode 100644 index 00000000..d710f3bc --- /dev/null +++ b/app/src/main/res/values-pt-rBR/theme_strings.xml @@ -0,0 +1,91 @@ + + + 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 + Sair deste design? + As alterações não aplicadas às cores, fontes e à coleção 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 + 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 new file mode 100644 index 00000000..b3621e65 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/theme_strings.xml @@ -0,0 +1,89 @@ + + + 主题与字体 + 打造自己的阅读空间 + 自定义主题 + 先选一种背景色,让其他颜色自然相配。 + 字体设计 + 为界面、阅读器和 RSVP / Bionic 选择字体。八种字体均可离线使用。 + 阅读器 + 界面 + RSVP 与 Bionic + 应用主题 + 离开此设计? + 尚未应用的颜色、字体和主题集合更改将被丢弃。 + 舍弃 + 继续编辑 + 你的阅读空间 + 呼吸 + 同色系 + 邻近色 + 互补色 + 三等分色 + 以同一色相呈现柔和的深浅变化。 + 用相邻色相营造宁静流畅的配色。 + 用相对色相形成更鲜明的对比。 + 用等距的三种色相增添均衡的色彩。 + 背景 + 表面 + 文字 + 主色 + 辅色 + 第三色 + 自动 + 固定 + 跟随配色 + 让此颜色随背景与配色方式变化。 + 十六进制颜色 + 请输入六位十六进制数字,如 #A8B5A2。 + 色相 + 饱和度 + 明度 + 完成 + Merriweather + Lora + Lexend + 系统无衬线 + 系统衬线 + 等宽 + 配色 + 自定义 + 配色 + 颜色 + 字体 + 留白阅读。 + 文字对比度 AA + 自定义主题将阅读文字对比度保持在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 new file mode 100644 index 00000000..a36e202f --- /dev/null +++ b/app/src/main/res/values/theme_strings.xml @@ -0,0 +1,90 @@ + + + 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 + Leave this design? + Your unapplied colours, fonts, and collection changes 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 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/core/model/CustomThemeTest.kt b/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt new file mode 100644 index 00000000..92c12033 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/model/CustomThemeTest.kt @@ -0,0 +1,47 @@ +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 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 ")) + 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/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/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) + } + } + } + } +} 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) + } +} 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..0525c4dc --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/theme/CustomColorSchemeTest.kt @@ -0,0 +1,108 @@ +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 +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)) + } + + @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, + 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/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( 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) + } + } + } + } +} 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`.