Skip to content

Commit f3f562f

Browse files
authored
558: harden theming fallback tests and custom accent UX (#568)
Co-authored-by: ryacub <ryacub@users.noreply.github.com>
1 parent 1f0df04 commit f3f562f

12 files changed

Lines changed: 214 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
1616

1717
- **Custom accent color theme** — Material 3 app-wide theming from a user-selected accent seed; generates light/dark color schemes with Android 14 contrast-awareness and readability guardrails (contrast clamp + fallback)
1818
- **Download crash notification** — notifies the user when the anime or manga download job crashes repeatedly (threshold: 3 consecutive crashes), with a tap-to-open link to the download manager
19+
- **Custom app theme accent controls** — custom app theme is now selectable in Appearance settings with curated accent swatches and one-tap reset to default palette
1920
- **LightNovelPluginManager unit tests** — 37 tests covering install flow, manifest validation, update policy, APK download/checksum verification, install launch, in-flight mutex deduplication, error recovery, and orphaned APK cleanup
2021
- **Persist dialog/form state across rotation** — PIN setup, PIN change (step/value/error), and enrichment chooser source selection now survive configuration changes via `rememberSaveable`
2122
- **PIN error feedback** — shows an error message when saving a new PIN fails (e.g., storage write error), instead of silently closing the dialog
@@ -46,6 +47,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
4647
### Other
4748

4849
- Unit tests for `PlayerFileLoadedHandler` (29 tests) and `PlayerMpvInitializer` (16 tests); adds `MPVLibProxy` abstraction to both classes to enable JNI-safe unit testing
50+
- Documented theming migration and fallback behavior (custom accent unset fallback, custom mode night-mode mapping, enum fallback safety, and lowercase legacy migration normalization) in README and inline code comments
4951
- Compose stack upgrade: BOM → 2026.03.00, `activity-compose` → 1.13.0
5052
- AndroidX step-up: `core-ktx` → 1.18.0, Lifecycle → 2.10.0, Paging → 3.4.2, WorkManager → 2.11.1, media/mediarouter bumps
5153
- Core runtime dependency upgrade: jsoup → 1.22.1, Coil → 3.4.0, Material → 1.13.0, OkIO → 3.17.0

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Fork of Aniyomi with anime/manga/light novel tracking, reading, and watching.
2525
- **Library de-duplication** — detect and merge duplicate entries from different sources, preserving read progress, categories, history, and tracker data
2626
- **Source health badges** — see broken sources at a glance; broken sources hidden by default
2727
- **Dynamic cover theming** — entry screens tinted from cover art with contrast-checked fallbacks
28+
- **Custom app theme accents** — choose a curated accent swatch for the custom theme, with one-tap reset to default palette
2829
- **Discover feed** — aggregated tracker-based recommendations ranked by multi-tracker affinity, recent activity, and score
2930
- **Categories with search** — organize that 500-entry library
3031

@@ -38,6 +39,13 @@ Fork of Aniyomi with anime/manga/light novel tracking, reading, and watching.
3839
- Schedule library updates
3940
- Dark/light themes
4041

42+
### Theming Notes
43+
44+
- `ThemeMode.CUSTOM` uses the custom app palette while still following system day/night mode.
45+
- If custom accent seed is unset, the app falls back to the default Tachiyomi color scheme.
46+
- Legacy lowercase theme-mode values are normalized during preference split migration.
47+
- Invalid stored theme-mode enum values safely fall back to the default (`SYSTEM`).
48+
4149
## Contributing
4250

4351
[Code of conduct](./CODE_OF_CONDUCT.md) · [Contributing guide](./CONTRIBUTING.md)

app/src/main/java/eu/kanade/domain/ui/model/ThemeMode.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ fun setAppCompatDelegateThemeMode(themeMode: ThemeMode) {
1616
fun ThemeMode.toAppCompatDelegateMode(): Int = when (this) {
1717
ThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
1818
ThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
19+
// Custom app palette still follows the system day/night mode.
1920
ThemeMode.SYSTEM, ThemeMode.CUSTOM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
2021
}

app/src/main/java/eu/kanade/presentation/more/onboarding/ThemeStep.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ internal class ThemeStep : OnboardingStep {
2424

2525
val appThemePref = uiPreferences.appTheme()
2626
val appTheme by appThemePref.collectAsState()
27+
val customAccentSeedPref = uiPreferences.customThemeAccentSeed()
28+
val customAccentSeed by customAccentSeedPref.collectAsState()
2729

2830
val amoledPref = uiPreferences.themeDarkAmoled()
2931
val amoled by amoledPref.collectAsState()
@@ -40,7 +42,9 @@ internal class ThemeStep : OnboardingStep {
4042
AppThemePreferenceWidget(
4143
value = appTheme,
4244
amoled = amoled,
45+
customAccentSeed = customAccentSeed,
4346
onItemClick = { appThemePref.set(it) },
47+
onCustomAccentSeedChange = { customAccentSeedPref.set(it) },
4448
)
4549
}
4650
}

app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ object SettingsAppearanceScreen : SearchableSettings {
7070

7171
val appThemePref = uiPreferences.appTheme()
7272
val appTheme by appThemePref.collectAsStateWithLifecycle()
73+
val customAccentSeedPref = uiPreferences.customThemeAccentSeed()
74+
val customAccentSeed by customAccentSeedPref.collectAsStateWithLifecycle()
7375

7476
val customThemeAccentSeedPref = uiPreferences.customThemeAccentSeed()
7577
val customThemeAccentSeed by customThemeAccentSeedPref.collectAsStateWithLifecycle()
@@ -103,7 +105,9 @@ object SettingsAppearanceScreen : SearchableSettings {
103105
AppThemePreferenceWidget(
104106
value = appTheme,
105107
amoled = amoled,
108+
customAccentSeed = customAccentSeed,
106109
onItemClick = { appThemePref.set(it) },
110+
onCustomAccentSeedChange = { customAccentSeedPref.set(it) },
107111
)
108112

109113
if (appTheme == AppTheme.CUSTOM) {

app/src/main/java/eu/kanade/presentation/more/settings/widget/AppThemePreferenceWidget.kt

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.material3.Icon
2828
import androidx.compose.material3.MaterialTheme
2929
import androidx.compose.material3.Surface
3030
import androidx.compose.material3.Text
31+
import androidx.compose.material3.TextButton
3132
import androidx.compose.runtime.Composable
3233
import androidx.compose.runtime.getValue
3334
import androidx.compose.runtime.mutableStateOf
@@ -60,14 +61,18 @@ import uy.kohesive.injekt.api.fullType
6061
internal fun AppThemePreferenceWidget(
6162
value: AppTheme,
6263
amoled: Boolean,
64+
customAccentSeed: Int,
6365
onItemClick: (AppTheme) -> Unit,
66+
onCustomAccentSeedChange: (Int) -> Unit,
6467
) {
6568
BasePreferenceWidget(
6669
subcomponent = {
6770
AppThemesList(
6871
currentTheme = value,
6972
amoled = amoled,
73+
customAccentSeed = customAccentSeed,
7074
onItemClick = onItemClick,
75+
onCustomAccentSeedChange = onCustomAccentSeedChange,
7176
)
7277
},
7378
)
@@ -77,7 +82,9 @@ internal fun AppThemePreferenceWidget(
7782
private fun AppThemesList(
7883
currentTheme: AppTheme,
7984
amoled: Boolean,
85+
customAccentSeed: Int,
8086
onItemClick: (AppTheme) -> Unit,
87+
onCustomAccentSeedChange: (Int) -> Unit,
8188
) {
8289
val context = LocalContext.current
8390
val appThemes = remember {
@@ -124,6 +131,97 @@ private fun AppThemesList(
124131
}
125132
}
126133
}
134+
135+
if (currentTheme == AppTheme.CUSTOM) {
136+
Text(
137+
text = stringResource(MR.strings.pref_custom_theme_accent),
138+
modifier = Modifier
139+
.fillMaxWidth()
140+
.padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp),
141+
style = MaterialTheme.typography.bodyLarge,
142+
)
143+
Text(
144+
text = stringResource(MR.strings.pref_custom_theme_accent_summary),
145+
modifier = Modifier
146+
.fillMaxWidth()
147+
.padding(horizontal = PrefsHorizontalPadding)
148+
.secondaryItemAlpha(),
149+
style = MaterialTheme.typography.bodyMedium,
150+
)
151+
LazyRow(
152+
contentPadding = PaddingValues(horizontal = PrefsHorizontalPadding, vertical = 8.dp),
153+
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
154+
) {
155+
items(
156+
items = customThemeAccentSeeds,
157+
key = { it },
158+
) { seed ->
159+
CustomThemeAccentSwatch(
160+
seed = seed,
161+
selected = seed == customAccentSeed,
162+
onClick = {
163+
if (seed != customAccentSeed) {
164+
onCustomAccentSeedChange(seed)
165+
(context as? Activity)?.let { ActivityCompat.recreate(it) }
166+
}
167+
},
168+
)
169+
}
170+
}
171+
TextButton(
172+
onClick = {
173+
if (customAccentSeed != UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET) {
174+
onCustomAccentSeedChange(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET)
175+
(context as? Activity)?.let { ActivityCompat.recreate(it) }
176+
}
177+
},
178+
modifier = Modifier
179+
.padding(start = PrefsHorizontalPadding),
180+
) {
181+
Text(text = stringResource(MR.strings.action_reset))
182+
}
183+
}
184+
}
185+
186+
internal val customThemeAccentSeeds = listOf(
187+
0xFF4285F4.toInt(),
188+
0xFFEF6C00.toInt(),
189+
0xFF2E7D32.toInt(),
190+
0xFF8E24AA.toInt(),
191+
0xFFD81B60.toInt(),
192+
0xFF00695C.toInt(),
193+
0xFFF9A825.toInt(),
194+
0xFF455A64.toInt(),
195+
)
196+
197+
@Composable
198+
private fun CustomThemeAccentSwatch(
199+
seed: Int,
200+
selected: Boolean,
201+
onClick: () -> Unit,
202+
) {
203+
Box(
204+
modifier = Modifier
205+
.size(36.dp)
206+
.clip(CircleShape)
207+
.background(color = androidx.compose.ui.graphics.Color(seed))
208+
.border(
209+
width = if (selected) 3.dp else 1.dp,
210+
color = if (selected) MaterialTheme.colorScheme.onSurface else DividerDefaults.color,
211+
shape = CircleShape,
212+
)
213+
.clickable(onClick = onClick),
214+
contentAlignment = Alignment.Center,
215+
) {
216+
if (selected) {
217+
Icon(
218+
imageVector = Icons.Filled.CheckCircle,
219+
contentDescription = stringResource(MR.strings.selected),
220+
tint = MaterialTheme.colorScheme.surface,
221+
modifier = Modifier.size(18.dp),
222+
)
223+
}
224+
}
127225
}
128226

129227
internal fun availableAppThemes(isDynamicColorAvailable: Boolean): List<AppTheme> {
@@ -271,7 +369,9 @@ private fun AppThemesListPreview() {
271369
AppThemesList(
272370
currentTheme = appTheme,
273371
amoled = false,
372+
customAccentSeed = UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET,
274373
onItemClick = { appTheme = it },
374+
onCustomAccentSeedChange = { },
275375
)
276376
}
277377
}

app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ internal fun resolveBaseColorScheme(
9595
return when (appTheme) {
9696
AppTheme.MONET -> MonetColorScheme(context)
9797
AppTheme.CUSTOM -> {
98+
// Unset custom accent intentionally falls back to the baseline Tachiyomi palette.
9899
if (customAccentSeed == UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET) {
99100
TachiyomiColorScheme
100101
} else {

app/src/main/java/mihon/core/migration/migrations/SplitPreferencesMigration.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.preference.PreferenceManager
66
import eu.kanade.domain.ui.UiPreferences
77
import mihon.core.migration.Migration
88
import mihon.core.migration.MigrationContext
9+
import java.util.Locale
910

1011
class SplitPreferencesMigration : Migration {
1112
override val version = 86f
@@ -19,10 +20,15 @@ class SplitPreferencesMigration : Migration {
1920
if (uiPreferences.themeMode().isSet()) {
2021
prefs.edit {
2122
val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit
22-
putString(uiPreferences.themeMode().key(), themeMode.uppercase())
23+
// Legacy installs may store lowercase enum values; normalize to current enum names.
24+
putString(uiPreferences.themeMode().key(), normalizeThemeModeValue(themeMode))
2325
}
2426
}
2527

2628
return true
2729
}
2830
}
31+
32+
internal fun normalizeThemeModeValue(themeMode: String): String {
33+
return themeMode.uppercase(Locale.ROOT)
34+
}

app/src/test/java/eu/kanade/domain/ui/UiPreferencesTest.kt

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package eu.kanade.domain.ui
22

33
import eu.kanade.domain.ui.model.AppTheme
4+
import eu.kanade.domain.ui.model.ThemeMode
45
import eu.kanade.presentation.more.settings.widget.normalizeAccentSeed
56
import eu.kanade.presentation.more.settings.widget.resolvePickerResultSeed
67
import kotlinx.coroutines.CoroutineScope
@@ -83,6 +84,29 @@ class UiPreferencesTest {
8384
)
8485
}
8586

87+
@Test
88+
fun `custom theme accent seed reset uses unset sentinel`() {
89+
val preferences = UiPreferences(MutablePreferenceStore())
90+
91+
preferences.customThemeAccentSeed().set(0xFF4285F4.toInt())
92+
preferences.customThemeAccentSeed().set(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET)
93+
94+
assertEquals(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET, preferences.customThemeAccentSeed().get())
95+
}
96+
97+
@Test
98+
fun `theme mode falls back to default when stored enum value is invalid`() {
99+
val preferences = UiPreferences(
100+
MutablePreferenceStore(
101+
initialValues = mapOf(
102+
"pref_theme_mode_key" to "NOT_A_REAL_THEME_MODE",
103+
),
104+
),
105+
)
106+
107+
assertEquals(ThemeMode.SYSTEM, preferences.themeMode().get())
108+
}
109+
86110
private class MutablePreferenceStore(initialValues: Map<String, Any?> = emptyMap()) : PreferenceStore {
87111
private val data = initialValues.toMutableMap()
88112

@@ -115,7 +139,12 @@ class UiPreferencesTest {
115139
defaultValue: T,
116140
serializer: (T) -> String,
117141
deserializer: (String) -> T,
118-
): Preference<T> = MutablePreference(key, defaultValue)
142+
): Preference<T> = SerializedObjectPreference(
143+
key = key,
144+
defaultValue = defaultValue,
145+
serializer = serializer,
146+
deserializer = deserializer,
147+
)
119148

120149
override fun getAll(): Map<String, *> = data
121150

@@ -150,5 +179,39 @@ class UiPreferencesTest {
150179

151180
override fun stateIn(scope: CoroutineScope): StateFlow<T> = state.asStateFlow()
152181
}
182+
183+
private inner class SerializedObjectPreference<T>(
184+
private val key: String,
185+
private val defaultValue: T,
186+
private val serializer: (T) -> String,
187+
private val deserializer: (String) -> T,
188+
) : Preference<T> {
189+
private val state = MutableStateFlow(get())
190+
191+
override fun key(): String = key
192+
193+
override fun get(): T {
194+
val raw = data[key] as? String ?: return defaultValue
195+
return deserializer(raw)
196+
}
197+
198+
override fun set(value: T) {
199+
data[key] = serializer(value)
200+
state.value = value
201+
}
202+
203+
override fun isSet(): Boolean = data.containsKey(key)
204+
205+
override fun delete() {
206+
data.remove(key)
207+
state.value = defaultValue
208+
}
209+
210+
override fun defaultValue(): T = defaultValue
211+
212+
override fun changes(): Flow<T> = state.asStateFlow()
213+
214+
override fun stateIn(scope: CoroutineScope): StateFlow<T> = state.asStateFlow()
215+
}
153216
}
154217
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package mihon.core.migration.migrations
2+
3+
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Test
5+
import java.util.Locale
6+
7+
class SplitPreferencesMigrationTest {
8+
9+
@Test
10+
fun `normalizeThemeModeValue uses locale independent uppercase`() {
11+
val previousLocale = Locale.getDefault()
12+
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
13+
try {
14+
assertEquals("LIGHT", normalizeThemeModeValue("light"))
15+
assertEquals("SYSTEM", normalizeThemeModeValue("system"))
16+
} finally {
17+
Locale.setDefault(previousLocale)
18+
}
19+
}
20+
}

0 commit comments

Comments
 (0)