Skip to content

Commit d689083

Browse files
authored
feat: advanced custom accent workflow with apply-only recents (R572) (#591)
* r572: add advanced custom accent route with apply-only recents * r572: clarify custom accent recents constant names * r572: replace inline color hex masks with named constants --------- Co-authored-by: ryacub <ryacub@users.noreply.github.com>
1 parent f9aaa4c commit d689083

9 files changed

Lines changed: 384 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
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
1919
- **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
20+
- **Advanced custom accent workflow** — Appearance settings now includes an advanced color editor route and recent custom accent recall (last 5 applied colors)
2021
- **Theme instrumentation coverage** — added Android instrumentation tests for custom accent persistence, reset behavior, and unset-seed fallback to default `TachiyomiColorScheme`
2122
- **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
2223
- **Persist dialog/form state across rotation** — PIN setup, PIN change (step/value/error), and enrichment chooser source selection now survive configuration changes via `rememberSaveable`

app/src/androidTest/java/eu/kanade/presentation/more/settings/widget/CustomThemeAccentPreferenceWidgetAndroidTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,15 @@ class CustomThemeAccentPreferenceWidgetAndroidTest {
3939
MaterialTheme {
4040
CustomThemeAccentPreferenceWidget(
4141
selectedAccentSeed = selectedAccentSeed,
42+
recentAccentSeeds = emptyList(),
4243
onSwatchClick = { selectedAccentSeed = normalizeAccentSeed(it) },
44+
onRecentColorClick = { selectedAccentSeed = normalizeAccentSeed(it) },
4345
onOpenPicker = {
4446
pickerSeed = resolveInitialCustomAccentPickerSeed(selectedAccentSeed)
4547
pickerSession = nextCustomAccentPickerSession(pickerSession)
4648
showPicker = true
4749
},
50+
onOpenAdvancedEditor = {},
4851
onReset = { selectedAccentSeed = UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET },
4952
)
5053
if (showPicker) {

app/src/androidTest/java/eu/kanade/presentation/more/settings/widget/ThemeAppearanceFlowAndroidTest.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
55
import androidx.compose.material3.MaterialTheme
66
import androidx.compose.runtime.getValue
77
import androidx.compose.runtime.mutableIntStateOf
8+
import androidx.compose.runtime.mutableStateListOf
89
import androidx.compose.runtime.mutableStateOf
910
import androidx.compose.runtime.setValue
1011
import androidx.compose.ui.semantics.SemanticsActions
@@ -36,6 +37,7 @@ class ThemeAppearanceFlowAndroidTest {
3637
var showPicker by mutableStateOf(false)
3738
var pickerSession by mutableIntStateOf(0)
3839
var pickerSeed by mutableIntStateOf(resolveInitialCustomAccentPickerSeed(selectedAccentSeed))
40+
val recentAccentSeeds = mutableStateListOf<Int>()
3941

4042
MaterialTheme {
4143
Column {
@@ -50,12 +52,15 @@ class ThemeAppearanceFlowAndroidTest {
5052
if (appTheme == AppTheme.CUSTOM) {
5153
CustomThemeAccentPreferenceWidget(
5254
selectedAccentSeed = selectedAccentSeed,
55+
recentAccentSeeds = recentAccentSeeds.toList(),
5356
onSwatchClick = { selectedAccentSeed = normalizeAccentSeed(it) },
57+
onRecentColorClick = { selectedAccentSeed = normalizeAccentSeed(it) },
5458
onOpenPicker = {
5559
pickerSeed = resolveInitialCustomAccentPickerSeed(selectedAccentSeed)
5660
pickerSession = nextCustomAccentPickerSession(pickerSession)
5761
showPicker = true
5862
},
63+
onOpenAdvancedEditor = {},
5964
onReset = { selectedAccentSeed = UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET },
6065
)
6166
}
@@ -65,7 +70,14 @@ class ThemeAppearanceFlowAndroidTest {
6570
sessionKey = pickerSession,
6671
initialSeed = pickerSeed,
6772
onDismiss = { showPicker = false },
68-
onApply = { selectedAccentSeed = normalizeAccentSeed(it) },
73+
onApply = {
74+
selectedAccentSeed = normalizeAccentSeed(it)
75+
recentAccentSeeds.remove(selectedAccentSeed)
76+
recentAccentSeeds.add(0, selectedAccentSeed)
77+
if (recentAccentSeeds.size > 5) {
78+
recentAccentSeeds.removeAt(recentAccentSeeds.lastIndex)
79+
}
80+
},
6981
)
7082
}
7183
}
@@ -100,5 +112,7 @@ class ThemeAppearanceFlowAndroidTest {
100112
composeRule.onNodeWithText("Custom color…").performClick()
101113
composeRule.onNodeWithText("Cancel").performClick()
102114
composeRule.onNodeWithText("Using default accent fallback").assertExists()
115+
116+
composeRule.onNodeWithText("Recent colors").assertExists()
103117
}
104118
}

app/src/main/java/eu/kanade/domain/ui/UiPreferences.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,32 @@ class UiPreferences(
1919

2020
companion object {
2121
const val CUSTOM_THEME_ACCENT_SEED_UNSET = Int.MIN_VALUE
22+
private const val PREF_CUSTOM_THEME_RECENT_ACCENT_SEEDS = "pref_custom_theme_recent_accent_seeds"
23+
private const val MAX_RECENT_CUSTOM_THEME_ACCENT_SEEDS = 5
24+
private const val OPAQUE_ALPHA_MASK = 0xFF000000.toInt()
2225
fun dateFormat(format: String): DateTimeFormatter = when (format) {
2326
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
2427
else -> DateTimeFormatter.ofPattern(format, Locale.getDefault())
2528
}
29+
30+
internal fun normalizeRecentAccentSeeds(seeds: List<Int>): List<Int> {
31+
return seeds
32+
.asSequence()
33+
.map { it or OPAQUE_ALPHA_MASK }
34+
.filter { it != CUSTOM_THEME_ACCENT_SEED_UNSET }
35+
.distinct()
36+
.take(MAX_RECENT_CUSTOM_THEME_ACCENT_SEEDS)
37+
.toList()
38+
}
39+
40+
internal fun upsertRecentAccentSeed(
41+
existing: List<Int>,
42+
appliedSeed: Int,
43+
): List<Int> {
44+
if (appliedSeed == CUSTOM_THEME_ACCENT_SEED_UNSET) return normalizeRecentAccentSeeds(existing)
45+
val normalizedApplied = appliedSeed or OPAQUE_ALPHA_MASK
46+
return normalizeRecentAccentSeeds(listOf(normalizedApplied) + existing)
47+
}
2648
}
2749

2850
fun themeMode() = preferenceStore.getEnum("pref_theme_mode_key", ThemeMode.SYSTEM)
@@ -43,6 +65,41 @@ class UiPreferences(
4365
CUSTOM_THEME_ACCENT_SEED_UNSET,
4466
)
4567

68+
fun customThemeRecentAccentSeeds() = preferenceStore.getObject(
69+
key = PREF_CUSTOM_THEME_RECENT_ACCENT_SEEDS,
70+
defaultValue = emptyList(),
71+
serializer = { seeds ->
72+
normalizeRecentAccentSeeds(seeds).joinToString(separator = ",") { normalized ->
73+
normalized.toUInt().toString(16).padStart(8, '0')
74+
}
75+
},
76+
deserializer = { encoded ->
77+
if (encoded.isBlank()) {
78+
emptyList()
79+
} else {
80+
normalizeRecentAccentSeeds(
81+
encoded.split(",")
82+
.mapNotNull { token ->
83+
token.trim()
84+
.takeIf { it.isNotEmpty() }
85+
?.toUIntOrNull(16)
86+
?.toInt()
87+
},
88+
)
89+
}
90+
},
91+
)
92+
93+
fun addCustomThemeRecentAccentSeed(seed: Int) {
94+
val recentsPref = customThemeRecentAccentSeeds()
95+
recentsPref.set(
96+
upsertRecentAccentSeed(
97+
existing = recentsPref.get(),
98+
appliedSeed = seed,
99+
),
100+
)
101+
}
102+
46103
fun relativeTime() = preferenceStore.getBoolean("relative_time_v2", true)
47104

48105
fun dateFormat() = preferenceStore.getString("app_date_format", "")

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import eu.kanade.domain.ui.model.TabletUiMode
2323
import eu.kanade.domain.ui.model.ThemeMode
2424
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
2525
import eu.kanade.presentation.more.settings.Preference
26+
import eu.kanade.presentation.more.settings.screen.appearance.AdvancedCustomAccentScreen
2627
import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
2728
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
2829
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
@@ -64,6 +65,7 @@ object SettingsAppearanceScreen : SearchableSettings {
6465
uiPreferences: UiPreferences,
6566
): Preference.PreferenceGroup {
6667
val context = LocalContext.current
68+
val navigator = LocalNavigator.currentOrThrow
6769

6870
val themeModePref = uiPreferences.themeMode()
6971
val themeMode by themeModePref.collectAsStateWithLifecycle()
@@ -75,6 +77,8 @@ object SettingsAppearanceScreen : SearchableSettings {
7577

7678
val customThemeAccentSeedPref = uiPreferences.customThemeAccentSeed()
7779
val customThemeAccentSeed by customThemeAccentSeedPref.collectAsStateWithLifecycle()
80+
val recentAccentSeedsPref = uiPreferences.customThemeRecentAccentSeeds()
81+
val recentAccentSeeds by recentAccentSeedsPref.collectAsStateWithLifecycle()
7882

7983
val amoledPref = uiPreferences.themeDarkAmoled()
8084
val amoled by amoledPref.collectAsStateWithLifecycle()
@@ -113,15 +117,21 @@ object SettingsAppearanceScreen : SearchableSettings {
113117
if (appTheme == AppTheme.CUSTOM) {
114118
CustomThemeAccentPreferenceWidget(
115119
selectedAccentSeed = customThemeAccentSeed,
120+
recentAccentSeeds = recentAccentSeeds,
116121
onSwatchClick = { selectedSeed ->
117122
customThemeAccentSeedPref.set(normalizeAccentSeed(selectedSeed))
118123
recreateForThemeChange()
119124
},
125+
onRecentColorClick = { selectedSeed ->
126+
customThemeAccentSeedPref.set(normalizeAccentSeed(selectedSeed))
127+
recreateForThemeChange()
128+
},
120129
onOpenPicker = {
121130
customAccentPickerSeed = resolveInitialCustomAccentPickerSeed(customThemeAccentSeed)
122131
customAccentPickerSession = nextCustomAccentPickerSession(customAccentPickerSession)
123132
showCustomAccentPicker = true
124133
},
134+
onOpenAdvancedEditor = { navigator.push(AdvancedCustomAccentScreen()) },
125135
onReset = {
126136
customThemeAccentSeedPref.set(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET)
127137
recreateForThemeChange()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package eu.kanade.presentation.more.settings.screen.appearance
2+
3+
import android.app.Activity
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.padding
6+
import androidx.compose.material3.Button
7+
import androidx.compose.material3.Text
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.getValue
10+
import androidx.compose.runtime.mutableStateOf
11+
import androidx.compose.runtime.remember
12+
import androidx.compose.runtime.saveable.rememberSaveable
13+
import androidx.compose.runtime.setValue
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.platform.LocalContext
16+
import androidx.compose.ui.unit.dp
17+
import androidx.core.app.ActivityCompat
18+
import cafe.adriel.voyager.navigator.LocalNavigator
19+
import cafe.adriel.voyager.navigator.currentOrThrow
20+
import eu.kanade.domain.ui.UiPreferences
21+
import eu.kanade.presentation.components.AppBar
22+
import eu.kanade.presentation.more.settings.widget.CustomThemeAccentEditor
23+
import eu.kanade.presentation.more.settings.widget.accentSeedToHex
24+
import eu.kanade.presentation.more.settings.widget.hsvToAccentSeed
25+
import eu.kanade.presentation.more.settings.widget.normalizeAccentSeed
26+
import eu.kanade.presentation.more.settings.widget.seedToHsv
27+
import eu.kanade.presentation.util.Screen
28+
import tachiyomi.i18n.MR
29+
import tachiyomi.presentation.core.components.material.Scaffold
30+
import tachiyomi.presentation.core.i18n.stringResource
31+
import uy.kohesive.injekt.Injekt
32+
import uy.kohesive.injekt.api.get
33+
34+
private const val DEFAULT_ADVANCED_ACCENT_SEED = 0xFF1E88E5.toInt()
35+
36+
class AdvancedCustomAccentScreen : Screen() {
37+
38+
@Composable
39+
override fun Content() {
40+
val context = LocalContext.current
41+
val navigator = LocalNavigator.currentOrThrow
42+
val uiPreferences = remember { Injekt.get<UiPreferences>() }
43+
val customAccentSeedPref = remember { uiPreferences.customThemeAccentSeed() }
44+
val currentSeed = remember(customAccentSeedPref) { customAccentSeedPref.get() }
45+
val initialSeed = remember(currentSeed) {
46+
if (currentSeed == UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET) {
47+
DEFAULT_ADVANCED_ACCENT_SEED
48+
} else {
49+
normalizeAccentSeed(currentSeed)
50+
}
51+
}
52+
53+
val initialHsv = remember(initialSeed) { seedToHsv(initialSeed) }
54+
var hue by rememberSaveable(initialSeed) { mutableStateOf(initialHsv.hue) }
55+
var saturation by rememberSaveable(initialSeed) { mutableStateOf(initialHsv.saturation) }
56+
var value by rememberSaveable(initialSeed) { mutableStateOf(initialHsv.value) }
57+
58+
val previewSeed = hsvToAccentSeed(hue, saturation, value)
59+
val previewHex = accentSeedToHex(previewSeed)
60+
val previewDescription = stringResource(
61+
MR.strings.pref_custom_theme_picker_preview_content_description,
62+
previewHex,
63+
)
64+
65+
Scaffold(
66+
topBar = { scrollBehavior ->
67+
AppBar(
68+
title = stringResource(MR.strings.pref_custom_theme_advanced_editor),
69+
navigateUp = navigator::pop,
70+
scrollBehavior = scrollBehavior,
71+
)
72+
},
73+
) { contentPadding ->
74+
Column(
75+
modifier = Modifier.padding(contentPadding),
76+
) {
77+
CustomThemeAccentEditor(
78+
previewSeed = previewSeed,
79+
previewHex = previewHex,
80+
previewDescription = previewDescription,
81+
hue = hue,
82+
onHueChange = { hue = it },
83+
hueDescription = stringResource(MR.strings.pref_custom_theme_picker_hue),
84+
saturation = saturation,
85+
onSaturationChange = { saturation = it },
86+
saturationDescription = stringResource(MR.strings.pref_custom_theme_picker_saturation),
87+
value = value,
88+
onValueChange = { value = it },
89+
valueDescription = stringResource(MR.strings.pref_custom_theme_picker_value),
90+
)
91+
92+
Button(
93+
onClick = {
94+
val appliedSeed = normalizeAccentSeed(previewSeed)
95+
customAccentSeedPref.set(appliedSeed)
96+
uiPreferences.addCustomThemeRecentAccentSeed(appliedSeed)
97+
(context as? Activity)?.let { ActivityCompat.recreate(it) }
98+
navigator.pop()
99+
},
100+
modifier = Modifier.padding(top = 16.dp),
101+
) {
102+
Text(text = stringResource(MR.strings.action_apply))
103+
}
104+
Button(
105+
onClick = { navigator.pop() },
106+
modifier = Modifier.padding(top = 8.dp),
107+
) {
108+
Text(text = stringResource(MR.strings.action_cancel))
109+
}
110+
}
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)