Skip to content

Commit 8be8673

Browse files
authored
R554: add custom theme accent controls (#567)
Co-authored-by: ryacub <ryacub@users.noreply.github.com>
1 parent 5669d3b commit 8be8673

12 files changed

Lines changed: 744 additions & 3 deletions

File tree

app/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ dependencies {
246246
implementation(compose.animation)
247247
implementation(compose.animation.graphics)
248248
debugImplementation(compose.ui.tooling)
249+
debugImplementation("androidx.compose.ui:ui-test-manifest")
249250
implementation(compose.ui.tooling.preview)
250251
implementation(compose.ui.util)
251252

@@ -346,6 +347,9 @@ dependencies {
346347
// Tests
347348
testImplementation(libs.bundles.test)
348349
testImplementation("androidx.lifecycle:lifecycle-runtime-testing:2.8.7")
350+
androidTestImplementation(platform(compose.bom))
351+
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
352+
androidTestImplementation("androidx.test.ext:junit:1.2.1")
349353

350354
// For detecting memory leaks; see https://square.github.io/leakcanary/
351355
// debugImplementation(libs.leakcanary.android)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package eu.kanade.presentation.more.settings.widget
2+
3+
import androidx.activity.ComponentActivity
4+
import androidx.compose.material3.MaterialTheme
5+
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableIntStateOf
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.setValue
9+
import androidx.compose.ui.semantics.SemanticsActions
10+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
11+
import androidx.compose.ui.test.onNodeWithContentDescription
12+
import androidx.compose.ui.test.onNodeWithText
13+
import androidx.compose.ui.test.performClick
14+
import androidx.compose.ui.test.performSemanticsAction
15+
import androidx.test.ext.junit.runners.AndroidJUnit4
16+
import eu.kanade.domain.ui.UiPreferences
17+
import eu.kanade.presentation.more.settings.screen.nextCustomAccentPickerSession
18+
import eu.kanade.presentation.more.settings.screen.resolveInitialCustomAccentPickerSeed
19+
import org.junit.Rule
20+
import org.junit.Test
21+
import org.junit.runner.RunWith
22+
23+
@RunWith(AndroidJUnit4::class)
24+
class CustomThemeAccentPreferenceWidgetAndroidTest {
25+
26+
@get:Rule
27+
val composeRule = createAndroidComposeRule<ComponentActivity>()
28+
29+
@Test
30+
fun customAccentFlow_swatch_pickerCancel_pickerApply_reset_reopen() {
31+
composeRule.setContent {
32+
var selectedAccentSeed by mutableIntStateOf(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET)
33+
var showPicker by mutableStateOf(false)
34+
var pickerSession by mutableIntStateOf(0)
35+
var pickerSeed by mutableIntStateOf(
36+
resolveInitialCustomAccentPickerSeed(selectedAccentSeed),
37+
)
38+
39+
MaterialTheme {
40+
CustomThemeAccentPreferenceWidget(
41+
selectedAccentSeed = selectedAccentSeed,
42+
onSwatchClick = { selectedAccentSeed = normalizeAccentSeed(it) },
43+
onOpenPicker = {
44+
pickerSeed = resolveInitialCustomAccentPickerSeed(selectedAccentSeed)
45+
pickerSession = nextCustomAccentPickerSession(pickerSession)
46+
showPicker = true
47+
},
48+
onReset = { selectedAccentSeed = UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET },
49+
)
50+
if (showPicker) {
51+
CustomThemeColorPickerDialog(
52+
sessionKey = pickerSession,
53+
initialSeed = pickerSeed,
54+
onDismiss = { showPicker = false },
55+
onApply = { selectedAccentSeed = normalizeAccentSeed(it) },
56+
)
57+
}
58+
}
59+
}
60+
61+
composeRule.onNodeWithContentDescription("Accent swatch #E53935").performClick()
62+
composeRule.onNodeWithText("Selected accent: #E53935").assertExists()
63+
64+
composeRule.onNodeWithText("Custom color…").performClick()
65+
composeRule.onNodeWithContentDescription("Hue").performSemanticsAction(SemanticsActions.SetProgress) {
66+
it(240f)
67+
}
68+
composeRule.onNodeWithText("Cancel").performClick()
69+
composeRule.onNodeWithText("Selected accent: #E53935").assertExists()
70+
71+
composeRule.onNodeWithText("Custom color…").performClick()
72+
composeRule.onNodeWithContentDescription("Saturation").performSemanticsAction(SemanticsActions.SetProgress) {
73+
it(0f)
74+
}
75+
composeRule.onNodeWithText("Apply").performClick()
76+
composeRule.onNodeWithText("Selected accent: #E53935").assertDoesNotExist()
77+
composeRule.onNodeWithText("Selected accent: #FFFFFF").assertExists()
78+
79+
composeRule.onNodeWithText("Reset accent").performClick()
80+
composeRule.onNodeWithText("Using default accent fallback").assertExists()
81+
82+
composeRule.onNodeWithText("Custom color…").performClick()
83+
composeRule.onNodeWithText("Cancel").performClick()
84+
composeRule.onNodeWithText("Using default accent fallback").assertExists()
85+
}
86+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package eu.kanade.presentation.more.settings.widget
2+
3+
import androidx.activity.ComponentActivity
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.material3.MaterialTheme
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableIntStateOf
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.setValue
10+
import androidx.compose.ui.semantics.SemanticsActions
11+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
12+
import androidx.compose.ui.test.onNodeWithContentDescription
13+
import androidx.compose.ui.test.onNodeWithText
14+
import androidx.compose.ui.test.performClick
15+
import androidx.compose.ui.test.performSemanticsAction
16+
import androidx.test.ext.junit.runners.AndroidJUnit4
17+
import eu.kanade.domain.ui.UiPreferences
18+
import eu.kanade.domain.ui.model.AppTheme
19+
import eu.kanade.presentation.more.settings.screen.nextCustomAccentPickerSession
20+
import eu.kanade.presentation.more.settings.screen.resolveInitialCustomAccentPickerSeed
21+
import org.junit.Rule
22+
import org.junit.Test
23+
import org.junit.runner.RunWith
24+
25+
@RunWith(AndroidJUnit4::class)
26+
class ThemeAppearanceFlowAndroidTest {
27+
28+
@get:Rule
29+
val composeRule = createAndroidComposeRule<ComponentActivity>()
30+
31+
@Test
32+
fun themeSection_customThemeFlow_endToEnd() {
33+
composeRule.setContent {
34+
var appTheme by mutableStateOf(AppTheme.DEFAULT)
35+
var selectedAccentSeed by mutableIntStateOf(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET)
36+
var showPicker by mutableStateOf(false)
37+
var pickerSession by mutableIntStateOf(0)
38+
var pickerSeed by mutableIntStateOf(resolveInitialCustomAccentPickerSeed(selectedAccentSeed))
39+
40+
MaterialTheme {
41+
Column {
42+
AppThemePreferenceWidget(
43+
value = appTheme,
44+
amoled = false,
45+
onItemClick = { appTheme = it },
46+
)
47+
48+
if (appTheme == AppTheme.CUSTOM) {
49+
CustomThemeAccentPreferenceWidget(
50+
selectedAccentSeed = selectedAccentSeed,
51+
onSwatchClick = { selectedAccentSeed = normalizeAccentSeed(it) },
52+
onOpenPicker = {
53+
pickerSeed = resolveInitialCustomAccentPickerSeed(selectedAccentSeed)
54+
pickerSession = nextCustomAccentPickerSession(pickerSession)
55+
showPicker = true
56+
},
57+
onReset = { selectedAccentSeed = UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET },
58+
)
59+
}
60+
61+
if (showPicker) {
62+
CustomThemeColorPickerDialog(
63+
sessionKey = pickerSession,
64+
initialSeed = pickerSeed,
65+
onDismiss = { showPicker = false },
66+
onApply = { selectedAccentSeed = normalizeAccentSeed(it) },
67+
)
68+
}
69+
}
70+
}
71+
}
72+
73+
composeRule.onNodeWithText("Custom accent").assertDoesNotExist()
74+
75+
composeRule.onNodeWithText("Custom").performClick()
76+
composeRule.onNodeWithText("Custom accent").assertExists()
77+
78+
composeRule.onNodeWithContentDescription("Accent swatch #E53935").performClick()
79+
composeRule.onNodeWithText("Selected accent: #E53935").assertExists()
80+
81+
composeRule.onNodeWithText("Custom color…").performClick()
82+
composeRule.onNodeWithContentDescription("Hue").performSemanticsAction(SemanticsActions.SetProgress) {
83+
it(240f)
84+
}
85+
composeRule.onNodeWithText("Cancel").performClick()
86+
composeRule.onNodeWithText("Selected accent: #E53935").assertExists()
87+
88+
composeRule.onNodeWithText("Custom color…").performClick()
89+
composeRule.onNodeWithContentDescription("Saturation").performSemanticsAction(SemanticsActions.SetProgress) {
90+
it(0f)
91+
}
92+
composeRule.onNodeWithText("Apply").performClick()
93+
composeRule.onNodeWithText("Selected accent: #FFFFFF").assertExists()
94+
95+
composeRule.onNodeWithText("Reset accent").performClick()
96+
composeRule.onNodeWithText("Using default accent fallback").assertExists()
97+
98+
composeRule.onNodeWithText("Custom color…").performClick()
99+
composeRule.onNodeWithText("Cancel").performClick()
100+
composeRule.onNodeWithText("Using default accent fallback").assertExists()
101+
}
102+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import tachiyomi.i18n.aniyomi.AYMR
66

77
enum class AppTheme(val titleRes: StringResource?) {
88
DEFAULT(MR.strings.label_default),
9-
CUSTOM(null),
9+
CUSTOM(MR.strings.theme_custom),
1010
MONET(MR.strings.theme_monet),
1111
CLOUDFLARE(AYMR.strings.theme_cloudflare),
1212
COTTONCANDY(AYMR.strings.theme_cottoncandy),

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ import androidx.compose.foundation.layout.Column
55
import androidx.compose.runtime.Composable
66
import androidx.compose.runtime.ReadOnlyComposable
77
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.mutableIntStateOf
9+
import androidx.compose.runtime.mutableStateOf
810
import androidx.compose.runtime.remember
11+
import androidx.compose.runtime.saveable.rememberSaveable
12+
import androidx.compose.runtime.setValue
913
import androidx.compose.ui.platform.LocalContext
1014
import androidx.core.app.ActivityCompat
1115
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1216
import cafe.adriel.voyager.navigator.LocalNavigator
1317
import cafe.adriel.voyager.navigator.currentOrThrow
1418
import eu.kanade.domain.ui.UiPreferences
19+
import eu.kanade.domain.ui.model.AppTheme
1520
import eu.kanade.domain.ui.model.NavStyle
1621
import eu.kanade.domain.ui.model.StartScreen
1722
import eu.kanade.domain.ui.model.TabletUiMode
@@ -21,6 +26,9 @@ import eu.kanade.presentation.more.settings.Preference
2126
import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
2227
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
2328
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
29+
import eu.kanade.presentation.more.settings.widget.CustomThemeAccentPreferenceWidget
30+
import eu.kanade.presentation.more.settings.widget.CustomThemeColorPickerDialog
31+
import eu.kanade.presentation.more.settings.widget.normalizeAccentSeed
2432
import eu.kanade.tachiyomi.util.system.toast
2533
import kotlinx.collections.immutable.persistentListOf
2634
import kotlinx.collections.immutable.toImmutableMap
@@ -35,6 +43,8 @@ import java.time.LocalDate
3543

3644
object SettingsAppearanceScreen : SearchableSettings {
3745

46+
internal const val DEFAULT_CUSTOM_ACCENT_PICKER_SEED = 0xFF1E88E5.toInt()
47+
3848
@ReadOnlyComposable
3949
@Composable
4050
override fun getTitleRes() = MR.strings.pref_category_appearance
@@ -61,9 +71,20 @@ object SettingsAppearanceScreen : SearchableSettings {
6171
val appThemePref = uiPreferences.appTheme()
6272
val appTheme by appThemePref.collectAsStateWithLifecycle()
6373

74+
val customThemeAccentSeedPref = uiPreferences.customThemeAccentSeed()
75+
val customThemeAccentSeed by customThemeAccentSeedPref.collectAsStateWithLifecycle()
76+
6477
val amoledPref = uiPreferences.themeDarkAmoled()
6578
val amoled by amoledPref.collectAsStateWithLifecycle()
6679

80+
var showCustomAccentPicker by rememberSaveable { mutableStateOf(false) }
81+
var customAccentPickerSeed by rememberSaveable { mutableIntStateOf(DEFAULT_CUSTOM_ACCENT_PICKER_SEED) }
82+
var customAccentPickerSession by rememberSaveable { mutableIntStateOf(0) }
83+
84+
fun recreateForThemeChange() {
85+
(context as? Activity)?.let { ActivityCompat.recreate(it) }
86+
}
87+
6788
return Preference.PreferenceGroup(
6889
title = stringResource(MR.strings.pref_category_theme),
6990
preferenceItems = persistentListOf(
@@ -84,6 +105,37 @@ object SettingsAppearanceScreen : SearchableSettings {
84105
amoled = amoled,
85106
onItemClick = { appThemePref.set(it) },
86107
)
108+
109+
if (appTheme == AppTheme.CUSTOM) {
110+
CustomThemeAccentPreferenceWidget(
111+
selectedAccentSeed = customThemeAccentSeed,
112+
onSwatchClick = { selectedSeed ->
113+
customThemeAccentSeedPref.set(normalizeAccentSeed(selectedSeed))
114+
recreateForThemeChange()
115+
},
116+
onOpenPicker = {
117+
customAccentPickerSeed = resolveInitialCustomAccentPickerSeed(customThemeAccentSeed)
118+
customAccentPickerSession = nextCustomAccentPickerSession(customAccentPickerSession)
119+
showCustomAccentPicker = true
120+
},
121+
onReset = {
122+
customThemeAccentSeedPref.set(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET)
123+
recreateForThemeChange()
124+
},
125+
)
126+
}
127+
128+
if (showCustomAccentPicker) {
129+
CustomThemeColorPickerDialog(
130+
sessionKey = customAccentPickerSession,
131+
initialSeed = customAccentPickerSeed,
132+
onDismiss = { showCustomAccentPicker = false },
133+
onApply = { pickedSeed ->
134+
customThemeAccentSeedPref.set(normalizeAccentSeed(pickedSeed))
135+
recreateForThemeChange()
136+
},
137+
)
138+
}
87139
}
88140
},
89141
Preference.PreferenceItem.SwitchPreference(
@@ -179,6 +231,16 @@ object SettingsAppearanceScreen : SearchableSettings {
179231
}
180232
}
181233

234+
internal fun resolveInitialCustomAccentPickerSeed(currentAccentSeed: Int): Int {
235+
return if (currentAccentSeed == UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET) {
236+
SettingsAppearanceScreen.DEFAULT_CUSTOM_ACCENT_PICKER_SEED
237+
} else {
238+
normalizeAccentSeed(currentAccentSeed)
239+
}
240+
}
241+
242+
internal fun nextCustomAccentPickerSession(currentSession: Int): Int = currentSession + 1
243+
182244
private val DateFormats = listOf(
183245
"", // Default
184246
"MM/dd/yy",

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ private fun AppThemesList(
8181
) {
8282
val context = LocalContext.current
8383
val appThemes = remember {
84-
AppTheme.entries
85-
.filterNot { it.titleRes == null || (it == AppTheme.MONET && !DeviceUtil.isDynamicColorAvailable) }
84+
availableAppThemes(DeviceUtil.isDynamicColorAvailable)
8685
}
8786
LazyRow(
8887
contentPadding = PaddingValues(horizontal = PrefsHorizontalPadding),
@@ -127,6 +126,11 @@ private fun AppThemesList(
127126
}
128127
}
129128

129+
internal fun availableAppThemes(isDynamicColorAvailable: Boolean): List<AppTheme> {
130+
return AppTheme.entries
131+
.filterNot { it.titleRes == null || (it == AppTheme.MONET && !isDynamicColorAvailable) }
132+
}
133+
130134
@Composable
131135
fun AppThemePreviewItem(
132136
selected: Boolean,

0 commit comments

Comments
 (0)