mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Full Compose settings (#8201)
* Uses Voyager for navigation. * Replaces every screen inside settings except category editor screen since it's called from several places.
This commit is contained in:
		| @@ -23,4 +23,6 @@ class BasePreferences( | ||||
|         "extension_installer", | ||||
|         if (DeviceUtil.isMiui) PreferenceValues.ExtensionInstaller.LEGACY else PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER, | ||||
|     ) | ||||
|  | ||||
|     fun acraEnabled() = preferenceStore.getBoolean("acra.enable", true) | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,168 @@ | ||||
| package eu.kanade.presentation.more.settings | ||||
|  | ||||
| import androidx.compose.animation.AnimatedVisibility | ||||
| import androidx.compose.animation.expandVertically | ||||
| import androidx.compose.animation.fadeIn | ||||
| import androidx.compose.animation.fadeOut | ||||
| import androidx.compose.animation.shrinkVertically | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.CompositionLocalProvider | ||||
| import androidx.compose.runtime.compositionLocalOf | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.rememberCoroutineScope | ||||
| import androidx.compose.runtime.structuralEqualityPolicy | ||||
| import eu.kanade.domain.track.service.TrackPreferences | ||||
| import eu.kanade.domain.ui.UiPreferences | ||||
| import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget | ||||
| import eu.kanade.presentation.more.settings.widget.EditTextPreferenceWidget | ||||
| import eu.kanade.presentation.more.settings.widget.ListPreferenceWidget | ||||
| import eu.kanade.presentation.more.settings.widget.MultiSelectListPreferenceWidget | ||||
| import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget | ||||
| import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget | ||||
| import eu.kanade.presentation.more.settings.widget.TrackingPreferenceWidget | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.core.preference.PreferenceStore | ||||
| import kotlinx.coroutines.launch | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false } | ||||
|  | ||||
| @Composable | ||||
| fun StatusWrapper( | ||||
|     item: Preference.PreferenceItem<*>, | ||||
|     highlightKey: String?, | ||||
|     content: @Composable () -> Unit, | ||||
| ) { | ||||
|     val enabled = item.enabled | ||||
|     val highlighted = item.title == highlightKey | ||||
|     AnimatedVisibility( | ||||
|         visible = enabled, | ||||
|         enter = expandVertically() + fadeIn(), | ||||
|         exit = shrinkVertically() + fadeOut(), | ||||
|         content = { | ||||
|             CompositionLocalProvider( | ||||
|                 LocalPreferenceHighlighted provides highlighted, | ||||
|                 content = content, | ||||
|             ) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| internal fun PreferenceItem( | ||||
|     item: Preference.PreferenceItem<*>, | ||||
|     highlightKey: String?, | ||||
| ) { | ||||
|     val scope = rememberCoroutineScope() | ||||
|     StatusWrapper( | ||||
|         item = item, | ||||
|         highlightKey = highlightKey, | ||||
|     ) { | ||||
|         when (item) { | ||||
|             is Preference.PreferenceItem.SwitchPreference -> { | ||||
|                 val value by item.pref.collectAsState() | ||||
|                 SwitchPreferenceWidget( | ||||
|                     title = item.title, | ||||
|                     subtitle = item.subtitle, | ||||
|                     icon = item.icon, | ||||
|                     checked = value, | ||||
|                     onCheckedChanged = { newValue -> | ||||
|                         scope.launch { | ||||
|                             if (item.onValueChanged(newValue)) { | ||||
|                                 item.pref.set(newValue) | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.ListPreference<*> -> { | ||||
|                 val value by item.pref.collectAsState() | ||||
|                 ListPreferenceWidget( | ||||
|                     value = value, | ||||
|                     title = item.title, | ||||
|                     subtitle = item.subtitle, | ||||
|                     icon = item.icon, | ||||
|                     entries = item.entries, | ||||
|                     onValueChange = { newValue -> | ||||
|                         scope.launch { | ||||
|                             if (item.internalOnValueChanged(newValue!!)) { | ||||
|                                 item.internalSet(newValue) | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.BasicListPreference -> { | ||||
|                 ListPreferenceWidget( | ||||
|                     value = item.value, | ||||
|                     title = item.title, | ||||
|                     subtitle = item.subtitle, | ||||
|                     icon = item.icon, | ||||
|                     entries = item.entries, | ||||
|                     onValueChange = { scope.launch { item.onValueChanged(it) } }, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.MultiSelectListPreference -> { | ||||
|                 val values by item.pref.collectAsState() | ||||
|                 MultiSelectListPreferenceWidget( | ||||
|                     preference = item, | ||||
|                     values = values, | ||||
|                     onValuesChange = { newValues -> | ||||
|                         scope.launch { | ||||
|                             if (item.onValueChanged(newValues)) { | ||||
|                                 item.pref.set(newValues.toMutableSet()) | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.TextPreference -> { | ||||
|                 TextPreferenceWidget( | ||||
|                     title = item.title, | ||||
|                     subtitle = item.subtitle, | ||||
|                     icon = item.icon, | ||||
|                     onPreferenceClick = item.onClick, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.EditTextPreference -> { | ||||
|                 val values by item.pref.collectAsState() | ||||
|                 EditTextPreferenceWidget( | ||||
|                     title = item.title, | ||||
|                     subtitle = item.subtitle, | ||||
|                     icon = item.icon, | ||||
|                     value = values, | ||||
|                     onConfirm = { | ||||
|                         val accepted = item.onValueChanged(it) | ||||
|                         if (accepted) item.pref.set(it) | ||||
|                         accepted | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.AppThemePreference -> { | ||||
|                 val value by item.pref.collectAsState() | ||||
|                 val amoled by Injekt.get<UiPreferences>().themeDarkAmoled().collectAsState() | ||||
|                 AppThemePreferenceWidget( | ||||
|                     title = item.title, | ||||
|                     value = value, | ||||
|                     amoled = amoled, | ||||
|                     onItemClick = { scope.launch { item.pref.set(it) } }, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.TrackingPreference -> { | ||||
|                 val uName by Injekt.get<PreferenceStore>() | ||||
|                     .getString(TrackPreferences.trackUsername(item.service.id)) | ||||
|                     .collectAsState() | ||||
|                 item.service.run { | ||||
|                     TrackingPreferenceWidget( | ||||
|                         title = item.title, | ||||
|                         logoRes = getLogo(), | ||||
|                         logoColor = getLogoColor(), | ||||
|                         checked = uName.isNotEmpty(), | ||||
|                         onClick = { if (isLogged) item.logout() else item.login() }, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,146 @@ | ||||
| package eu.kanade.presentation.more.settings | ||||
|  | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.Info | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import eu.kanade.domain.ui.model.AppTheme | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.core.preference.Preference as PreferenceData | ||||
|  | ||||
| sealed class Preference { | ||||
|     abstract val title: String | ||||
|     abstract val enabled: Boolean | ||||
|  | ||||
|     sealed class PreferenceItem<T> : Preference() { | ||||
|         abstract val subtitle: String? | ||||
|         abstract val icon: ImageVector? | ||||
|         abstract val onValueChanged: suspend (newValue: T) -> Boolean | ||||
|  | ||||
|         /** | ||||
|          * A basic [PreferenceItem] that only displays texts. | ||||
|          */ | ||||
|         data class TextPreference( | ||||
|             override val title: String, | ||||
|             override val subtitle: String? = null, | ||||
|             override val icon: ImageVector? = null, | ||||
|             override val enabled: Boolean = true, | ||||
|             override val onValueChanged: suspend (newValue: String) -> Boolean = { true }, | ||||
|  | ||||
|             val onClick: (() -> Unit)? = null, | ||||
|         ) : PreferenceItem<String>() | ||||
|  | ||||
|         /** | ||||
|          * A [PreferenceItem] that provides a two-state toggleable option. | ||||
|          */ | ||||
|         data class SwitchPreference( | ||||
|             val pref: PreferenceData<Boolean>, | ||||
|             override val title: String, | ||||
|             override val subtitle: String? = null, | ||||
|             override val icon: ImageVector? = null, | ||||
|             override val enabled: Boolean = true, | ||||
|             override val onValueChanged: suspend (newValue: Boolean) -> Boolean = { true }, | ||||
|         ) : PreferenceItem<Boolean>() | ||||
|  | ||||
|         /** | ||||
|          * A [PreferenceItem] that displays a list of entries as a dialog. | ||||
|          */ | ||||
|         @Suppress("UNCHECKED_CAST") | ||||
|         data class ListPreference<T>( | ||||
|             val pref: PreferenceData<T>, | ||||
|             override val title: String, | ||||
|             override val subtitle: String? = "%s", | ||||
|             override val icon: ImageVector? = null, | ||||
|             override val enabled: Boolean = true, | ||||
|             override val onValueChanged: suspend (newValue: T) -> Boolean = { true }, | ||||
|  | ||||
|             val entries: Map<T, String>, | ||||
|         ) : PreferenceItem<T>() { | ||||
|             internal fun internalSet(newValue: Any) = pref.set(newValue as T) | ||||
|             internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * [ListPreference] but with no connection to a [PreferenceData] | ||||
|          */ | ||||
|         data class BasicListPreference( | ||||
|             val value: String, | ||||
|             override val title: String, | ||||
|             override val subtitle: String? = "%s", | ||||
|             override val icon: ImageVector? = null, | ||||
|             override val enabled: Boolean = true, | ||||
|             override val onValueChanged: suspend (newValue: String) -> Boolean = { true }, | ||||
|  | ||||
|             val entries: Map<String, String>, | ||||
|         ) : PreferenceItem<String>() | ||||
|  | ||||
|         /** | ||||
|          * A [PreferenceItem] that displays a list of entries as a dialog. | ||||
|          * Multiple entries can be selected at the same time. | ||||
|          */ | ||||
|         data class MultiSelectListPreference( | ||||
|             val pref: PreferenceData<Set<String>>, | ||||
|             override val title: String, | ||||
|             override val subtitle: String? = null, | ||||
|             override val icon: ImageVector? = null, | ||||
|             override val enabled: Boolean = true, | ||||
|             override val onValueChanged: suspend (newValue: Set<String>) -> Boolean = { true }, | ||||
|  | ||||
|             val entries: Map<String, String>, | ||||
|         ) : PreferenceItem<Set<String>>() | ||||
|  | ||||
|         /** | ||||
|          * A [PreferenceItem] that shows a EditText in the dialog. | ||||
|          */ | ||||
|         data class EditTextPreference( | ||||
|             val pref: PreferenceData<String>, | ||||
|             override val title: String, | ||||
|             override val subtitle: String? = "%s", | ||||
|             override val icon: ImageVector? = null, | ||||
|             override val enabled: Boolean = true, | ||||
|             override val onValueChanged: suspend (newValue: String) -> Boolean = { true }, | ||||
|         ) : PreferenceItem<String>() | ||||
|  | ||||
|         /** | ||||
|          * A [PreferenceItem] that shows previews of [AppTheme] selection. | ||||
|          */ | ||||
|         data class AppThemePreference( | ||||
|             val pref: PreferenceData<AppTheme>, | ||||
|             override val title: String, | ||||
|         ) : PreferenceItem<AppTheme>() { | ||||
|             override val enabled: Boolean = true | ||||
|             override val subtitle: String? = null | ||||
|             override val icon: ImageVector? = null | ||||
|             override val onValueChanged: suspend (newValue: AppTheme) -> Boolean = { true } | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * A [PreferenceItem] for individual tracking service. | ||||
|          */ | ||||
|         data class TrackingPreference( | ||||
|             val service: TrackService, | ||||
|             override val title: String, | ||||
|             val login: () -> Unit, | ||||
|             val logout: () -> Unit, | ||||
|         ) : PreferenceItem<String>() { | ||||
|             override val enabled: Boolean = true | ||||
|             override val subtitle: String? = null | ||||
|             override val icon: ImageVector? = null | ||||
|             override val onValueChanged: suspend (newValue: String) -> Boolean = { true } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     data class PreferenceGroup( | ||||
|         override val title: String, | ||||
|         override val enabled: Boolean = true, | ||||
|  | ||||
|         val preferenceItems: List<PreferenceItem<out Any>>, | ||||
|     ) : Preference() | ||||
|  | ||||
|     companion object { | ||||
|         fun infoPreference(info: String) = PreferenceItem.TextPreference( | ||||
|             title = "", | ||||
|             subtitle = info, | ||||
|             icon = Icons.Outlined.Info, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| package eu.kanade.presentation.more.settings | ||||
|  | ||||
| import androidx.compose.foundation.layout.RowScope | ||||
| import androidx.compose.runtime.Composable | ||||
| import eu.kanade.presentation.components.AppBar | ||||
| import eu.kanade.presentation.components.Scaffold | ||||
|  | ||||
| @Composable | ||||
| fun PreferenceScaffold( | ||||
|     title: String, | ||||
|     actions: @Composable RowScope.() -> Unit = {}, | ||||
|     onBackPressed: () -> Unit = {}, | ||||
|     itemsProvider: @Composable () -> List<Preference>, | ||||
| ) { | ||||
|     Scaffold( | ||||
|         topBar = { scrollBehavior -> | ||||
|             AppBar( | ||||
|                 title = title, | ||||
|                 navigateUp = onBackPressed, | ||||
|                 actions = actions, | ||||
|                 scrollBehavior = scrollBehavior, | ||||
|             ) | ||||
|         }, | ||||
|         content = { contentPadding -> | ||||
|             PreferenceScreen( | ||||
|                 items = itemsProvider(), | ||||
|                 contentPadding = contentPadding, | ||||
|             ) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,100 @@ | ||||
| package eu.kanade.presentation.more.settings | ||||
|  | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.items | ||||
| import androidx.compose.foundation.lazy.rememberLazyListState | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.util.fastForEachIndexed | ||||
| import eu.kanade.presentation.components.Divider | ||||
| import eu.kanade.presentation.components.ScrollbarLazyColumn | ||||
| import eu.kanade.presentation.more.settings.screen.SearchableSettings | ||||
| import eu.kanade.presentation.more.settings.widget.PreferenceGroupHeader | ||||
| import kotlinx.coroutines.delay | ||||
|  | ||||
| /** | ||||
|  * Preference Screen composable which contains a list of [Preference] items | ||||
|  * @param items [Preference] items which should be displayed on the preference screen. An item can be a single [PreferenceItem] or a group ([Preference.PreferenceGroup]) | ||||
|  * @param modifier [Modifier] to be applied to the preferenceScreen layout | ||||
|  */ | ||||
| @Composable | ||||
| fun PreferenceScreen( | ||||
|     items: List<Preference>, | ||||
|     modifier: Modifier = Modifier, | ||||
|     contentPadding: PaddingValues = PaddingValues(0.dp), | ||||
| ) { | ||||
|     val state = rememberLazyListState() | ||||
|     val highlightKey = SearchableSettings.highlightKey | ||||
|     if (highlightKey != null) { | ||||
|         LaunchedEffect(Unit) { | ||||
|             val i = items.findHighlightedIndex(highlightKey) | ||||
|             if (i >= 0) { | ||||
|                 delay(500) | ||||
|                 state.animateScrollToItem(i) | ||||
|             } | ||||
|             SearchableSettings.highlightKey = null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     ScrollbarLazyColumn( | ||||
|         modifier = modifier, | ||||
|         state = state, | ||||
|         contentPadding = contentPadding, | ||||
|     ) { | ||||
|         items.fastForEachIndexed { i, preference -> | ||||
|             when (preference) { | ||||
|                 // Create Preference Group | ||||
|                 is Preference.PreferenceGroup -> { | ||||
|                     if (!preference.enabled) return@fastForEachIndexed | ||||
|  | ||||
|                     item { | ||||
|                         Column { | ||||
|                             if (i != 0) { | ||||
|                                 Divider(modifier = Modifier.padding(bottom = 8.dp)) | ||||
|                             } | ||||
|                             PreferenceGroupHeader(title = preference.title) | ||||
|                         } | ||||
|                     } | ||||
|                     items(preference.preferenceItems) { item -> | ||||
|                         PreferenceItem( | ||||
|                             item = item, | ||||
|                             highlightKey = highlightKey, | ||||
|                         ) | ||||
|                     } | ||||
|                     item { | ||||
|                         Spacer(modifier = Modifier.height(12.dp)) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // Create Preference Item | ||||
|                 is Preference.PreferenceItem<*> -> item { | ||||
|                     PreferenceItem( | ||||
|                         item = preference, | ||||
|                         highlightKey = highlightKey, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private fun List<Preference>.findHighlightedIndex(highlightKey: String): Int { | ||||
|     return flatMap { | ||||
|         if (it is Preference.PreferenceGroup) { | ||||
|             mutableListOf<String?>() | ||||
|                 .apply { | ||||
|                     add(null) // Header | ||||
|                     addAll(it.preferenceItems.map { groupItem -> groupItem.title }) | ||||
|                     add(null) // Spacer | ||||
|                 } | ||||
|         } else { | ||||
|             listOf(it.title) | ||||
|         } | ||||
|     }.indexOfFirst { it == highlightKey } | ||||
| } | ||||
| @@ -0,0 +1,218 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.items | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.FlipToBack | ||||
| import androidx.compose.material.icons.outlined.SelectAll | ||||
| import androidx.compose.material3.Button | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import cafe.adriel.voyager.core.model.StateScreenModel | ||||
| import cafe.adriel.voyager.core.model.coroutineScope | ||||
| import cafe.adriel.voyager.core.model.rememberScreenModel | ||||
| import cafe.adriel.voyager.core.screen.Screen | ||||
| import cafe.adriel.voyager.navigator.LocalNavigator | ||||
| import cafe.adriel.voyager.navigator.currentOrThrow | ||||
| import eu.kanade.domain.source.interactor.GetSourcesWithNonLibraryManga | ||||
| import eu.kanade.domain.source.model.Source | ||||
| import eu.kanade.domain.source.model.SourceWithCount | ||||
| import eu.kanade.presentation.components.AppBar | ||||
| import eu.kanade.presentation.components.AppBarActions | ||||
| import eu.kanade.presentation.components.Divider | ||||
| import eu.kanade.presentation.components.EmptyScreen | ||||
| import eu.kanade.presentation.components.FastScrollLazyColumn | ||||
| import eu.kanade.presentation.components.LoadingScreen | ||||
| import eu.kanade.presentation.components.Scaffold | ||||
| import eu.kanade.presentation.more.settings.database.components.ClearDatabaseDeleteDialog | ||||
| import eu.kanade.presentation.more.settings.database.components.ClearDatabaseItem | ||||
| import eu.kanade.tachiyomi.Database | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.update | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class ClearDatabaseScreen : Screen { | ||||
|  | ||||
|     @Composable | ||||
|     override fun Content() { | ||||
|         val context = LocalContext.current | ||||
|         val navigator = LocalNavigator.currentOrThrow | ||||
|         val model = rememberScreenModel { ClearDatabaseScreenModel() } | ||||
|         val state by model.state.collectAsState() | ||||
|  | ||||
|         when (val s = state) { | ||||
|             is ClearDatabaseScreenModel.State.Loading -> LoadingScreen() | ||||
|             is ClearDatabaseScreenModel.State.Ready -> { | ||||
|                 if (s.showConfirmation) { | ||||
|                     ClearDatabaseDeleteDialog( | ||||
|                         onDismissRequest = model::hideConfirmation, | ||||
|                         onDelete = { | ||||
|                             model.removeMangaBySourceId() | ||||
|                             model.clearSelection() | ||||
|                             model.hideConfirmation() | ||||
|                             context.toast(R.string.clear_database_completed) | ||||
|                         }, | ||||
|                     ) | ||||
|                 } | ||||
|  | ||||
|                 Scaffold( | ||||
|                     topBar = { scrollBehavior -> | ||||
|                         AppBar( | ||||
|                             title = stringResource(R.string.pref_clear_database), | ||||
|                             navigateUp = navigator::pop, | ||||
|                             actions = { | ||||
|                                 if (s.items.isNotEmpty()) { | ||||
|                                     AppBarActions( | ||||
|                                         actions = listOf( | ||||
|                                             AppBar.Action( | ||||
|                                                 title = stringResource(R.string.action_select_all), | ||||
|                                                 icon = Icons.Outlined.SelectAll, | ||||
|                                                 onClick = model::selectAll, | ||||
|                                             ), | ||||
|                                             AppBar.Action( | ||||
|                                                 title = stringResource(R.string.action_select_all), | ||||
|                                                 icon = Icons.Outlined.FlipToBack, | ||||
|                                                 onClick = model::invertSelection, | ||||
|                                             ), | ||||
|                                         ), | ||||
|                                     ) | ||||
|                                 } | ||||
|                             }, | ||||
|                             scrollBehavior = scrollBehavior, | ||||
|                         ) | ||||
|                     }, | ||||
|                 ) { contentPadding -> | ||||
|                     if (s.items.isEmpty()) { | ||||
|                         EmptyScreen( | ||||
|                             message = stringResource(R.string.database_clean), | ||||
|                             modifier = Modifier.padding(contentPadding), | ||||
|                         ) | ||||
|                     } else { | ||||
|                         Column( | ||||
|                             modifier = Modifier | ||||
|                                 .padding(contentPadding) | ||||
|                                 .fillMaxSize(), | ||||
|                         ) { | ||||
|                             FastScrollLazyColumn( | ||||
|                                 modifier = Modifier.weight(1f), | ||||
|                             ) { | ||||
|                                 items(s.items) { sourceWithCount -> | ||||
|                                     ClearDatabaseItem( | ||||
|                                         source = sourceWithCount.source, | ||||
|                                         count = sourceWithCount.count, | ||||
|                                         isSelected = s.selection.contains(sourceWithCount.id), | ||||
|                                         onClickSelect = { model.toggleSelection(sourceWithCount.source) }, | ||||
|                                     ) | ||||
|                                 } | ||||
|                             } | ||||
|  | ||||
|                             Divider() | ||||
|  | ||||
|                             Button( | ||||
|                                 modifier = Modifier | ||||
|                                     .padding(horizontal = 16.dp, vertical = 8.dp) | ||||
|                                     .fillMaxWidth(), | ||||
|                                 onClick = model::showConfirmation, | ||||
|                                 enabled = s.selection.isNotEmpty(), | ||||
|                             ) { | ||||
|                                 Text( | ||||
|                                     text = stringResource(R.string.action_delete), | ||||
|                                     color = MaterialTheme.colorScheme.onPrimary, | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private class ClearDatabaseScreenModel : StateScreenModel<ClearDatabaseScreenModel.State>(State.Loading) { | ||||
|     private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga = Injekt.get() | ||||
|     private val database: Database = Injekt.get() | ||||
|  | ||||
|     init { | ||||
|         coroutineScope.launchIO { | ||||
|             getSourcesWithNonLibraryManga.subscribe() | ||||
|                 .collectLatest { list -> | ||||
|                     mutableState.update { old -> | ||||
|                         val items = list.sortedBy { it.name } | ||||
|                         when (old) { | ||||
|                             State.Loading -> State.Ready(items) | ||||
|                             is State.Ready -> old.copy(items = items) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun removeMangaBySourceId() { | ||||
|         val state = state.value as? State.Ready ?: return | ||||
|         database.mangasQueries.deleteMangasNotInLibraryBySourceIds(state.selection) | ||||
|         database.historyQueries.removeResettedHistory() | ||||
|     } | ||||
|  | ||||
|     fun toggleSelection(source: Source) = mutableState.update { state -> | ||||
|         if (state !is State.Ready) return@update state | ||||
|         val mutableList = state.selection.toMutableList() | ||||
|         if (mutableList.contains(source.id)) { | ||||
|             mutableList.remove(source.id) | ||||
|         } else { | ||||
|             mutableList.add(source.id) | ||||
|         } | ||||
|         state.copy(selection = mutableList) | ||||
|     } | ||||
|  | ||||
|     fun clearSelection() = mutableState.update { state -> | ||||
|         if (state !is State.Ready) return@update state | ||||
|         state.copy(selection = emptyList()) | ||||
|     } | ||||
|  | ||||
|     fun selectAll() = mutableState.update { state -> | ||||
|         if (state !is State.Ready) return@update state | ||||
|         state.copy(selection = state.items.map { it.id }) | ||||
|     } | ||||
|  | ||||
|     fun invertSelection() = mutableState.update { state -> | ||||
|         if (state !is State.Ready) return@update state | ||||
|         state.copy( | ||||
|             selection = state.items | ||||
|                 .map { it.id } | ||||
|                 .filterNot { it in state.selection }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun showConfirmation() = mutableState.update { state -> | ||||
|         if (state !is State.Ready) return@update state | ||||
|         state.copy(showConfirmation = true) | ||||
|     } | ||||
|  | ||||
|     fun hideConfirmation() = mutableState.update { state -> | ||||
|         if (state !is State.Ready) return@update state | ||||
|         state.copy(showConfirmation = false) | ||||
|     } | ||||
|  | ||||
|     sealed class State { | ||||
|         object Loading : State() | ||||
|         data class Ready( | ||||
|             val items: List<SourceWithCount>, | ||||
|             val selection: List<Long> = emptyList(), | ||||
|             val showConfirmation: Boolean = false, | ||||
|         ) : State() | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,47 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import eu.kanade.domain.category.model.Category | ||||
| import eu.kanade.presentation.category.visualName | ||||
| import eu.kanade.tachiyomi.R | ||||
|  | ||||
| /** | ||||
|  * Returns a string of categories name for settings subtitle | ||||
|  */ | ||||
|  | ||||
| @ReadOnlyComposable | ||||
| @Composable | ||||
| fun getCategoriesLabel( | ||||
|     allCategories: List<Category>, | ||||
|     included: Set<String>, | ||||
|     excluded: Set<String>, | ||||
| ): String { | ||||
|     val context = LocalContext.current | ||||
|  | ||||
|     val includedCategories = included | ||||
|         .mapNotNull { id -> allCategories.find { it.id == id.toLong() } } | ||||
|         .sortedBy { it.order } | ||||
|     val excludedCategories = excluded | ||||
|         .mapNotNull { id -> allCategories.find { it.id == id.toLong() } } | ||||
|         .sortedBy { it.order } | ||||
|     val allExcluded = excludedCategories.size == allCategories.size | ||||
|  | ||||
|     val includedItemsText = when { | ||||
|         // Some selected, but not all | ||||
|         includedCategories.isNotEmpty() && includedCategories.size != allCategories.size -> includedCategories.joinToString { it.visualName(context) } | ||||
|         // All explicitly selected | ||||
|         includedCategories.size == allCategories.size -> stringResource(R.string.all) | ||||
|         allExcluded -> stringResource(R.string.none) | ||||
|         else -> stringResource(R.string.all) | ||||
|     } | ||||
|     val excludedItemsText = when { | ||||
|         excludedCategories.isEmpty() -> stringResource(R.string.none) | ||||
|         allExcluded -> stringResource(R.string.all) | ||||
|         else -> excludedCategories.joinToString { it.visualName(context) } | ||||
|     } | ||||
|     return stringResource(id = R.string.include, includedItemsText) + "\n" + | ||||
|         stringResource(id = R.string.exclude, excludedItemsText) | ||||
| } | ||||
| @@ -0,0 +1,42 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import androidx.compose.foundation.layout.RowScope | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import cafe.adriel.voyager.core.screen.Screen | ||||
| import cafe.adriel.voyager.navigator.currentOrThrow | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.more.settings.PreferenceScaffold | ||||
| import eu.kanade.presentation.util.LocalBackPress | ||||
|  | ||||
| interface SearchableSettings : Screen { | ||||
|     @Composable | ||||
|     @ReadOnlyComposable | ||||
|     fun getTitle(): String | ||||
|  | ||||
|     @Composable | ||||
|     fun getPreferences(): List<Preference> | ||||
|  | ||||
|     @Composable | ||||
|     fun RowScope.AppBarAction() { | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     override fun Content() { | ||||
|         val handleBack = LocalBackPress.currentOrThrow | ||||
|         PreferenceScaffold( | ||||
|             title = getTitle(), | ||||
|             onBackPressed = handleBack::invoke, | ||||
|             actions = { AppBarAction() }, | ||||
|             itemsProvider = { getPreferences() }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         // HACK: for the background blipping thingy. | ||||
|         // The title of the target PreferenceItem | ||||
|         // Set before showing the destination screen and reset after | ||||
|         // See BasePreferenceWidget.highlightBackground | ||||
|         var highlightKey: String? = null | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,398 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.ActivityNotFoundException | ||||
| import android.content.Intent | ||||
| import android.provider.Settings | ||||
| import android.webkit.WebStorage | ||||
| import android.webkit.WebView | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| 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.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.core.net.toUri | ||||
| import cafe.adriel.voyager.navigator.LocalNavigator | ||||
| import cafe.adriel.voyager.navigator.currentOrThrow | ||||
| import eu.kanade.domain.base.BasePreferences | ||||
| import eu.kanade.domain.library.service.LibraryPreferences | ||||
| import eu.kanade.domain.manga.repository.MangaRepository | ||||
| import eu.kanade.domain.ui.UiPreferences | ||||
| import eu.kanade.domain.ui.model.TabletUiMode | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.cache.ChapterCache | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.data.preference.PreferenceValues | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.network.NetworkPreferences | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_360 | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_ALIDNS | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_CONTROLD | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_DNSPOD | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_MULLVAD | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_NJALLA | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101 | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9 | ||||
| import eu.kanade.tachiyomi.util.CrashLogUtil | ||||
| import eu.kanade.tachiyomi.util.lang.launchNonCancellable | ||||
| import eu.kanade.tachiyomi.util.lang.withUIContext | ||||
| import eu.kanade.tachiyomi.util.system.DeviceUtil | ||||
| import eu.kanade.tachiyomi.util.system.isDevFlavor | ||||
| import eu.kanade.tachiyomi.util.system.isPackageInstalled | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import eu.kanade.tachiyomi.util.system.openInBrowser | ||||
| import eu.kanade.tachiyomi.util.system.powerManager | ||||
| import eu.kanade.tachiyomi.util.system.setDefaultSettings | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import logcat.LogPriority | ||||
| import rikka.sui.Sui | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.io.File | ||||
|  | ||||
| class SettingsAdvancedScreen : SearchableSettings { | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_advanced) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val context = LocalContext.current | ||||
|         val basePreferences = remember { Injekt.get<BasePreferences>() } | ||||
|         val networkPreferences = remember { Injekt.get<NetworkPreferences>() } | ||||
|  | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = basePreferences.acraEnabled(), | ||||
|                 title = stringResource(id = R.string.pref_enable_acra), | ||||
|                 subtitle = stringResource(id = R.string.pref_acra_summary), | ||||
|                 enabled = !isDevFlavor, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(id = R.string.pref_dump_crash_logs), | ||||
|                 subtitle = stringResource(id = R.string.pref_dump_crash_logs_summary), | ||||
|                 onClick = { | ||||
|                     scope.launchNonCancellable { | ||||
|                         CrashLogUtil(context).dumpLogs() | ||||
|                     } | ||||
|                 }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = networkPreferences.verboseLogging(), | ||||
|                 title = stringResource(id = R.string.pref_verbose_logging), | ||||
|                 subtitle = stringResource(id = R.string.pref_verbose_logging_summary), | ||||
|                 onValueChanged = { | ||||
|                     context.toast(R.string.requires_app_restart) | ||||
|                     true | ||||
|                 }, | ||||
|             ), | ||||
|             getBackgroundActivityGroup(), | ||||
|             getDataGroup(), | ||||
|             getNetworkGroup(networkPreferences = networkPreferences), | ||||
|             getLibraryGroup(), | ||||
|             getExtensionsGroup(basePreferences = basePreferences), | ||||
|             getDisplayGroup(), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getBackgroundActivityGroup(): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.label_background_activity), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_disable_battery_optimization), | ||||
|                     subtitle = stringResource(id = R.string.pref_disable_battery_optimization_summary), | ||||
|                     onClick = { | ||||
|                         val packageName: String = context.packageName | ||||
|                         if (!context.powerManager.isIgnoringBatteryOptimizations(packageName)) { | ||||
|                             try { | ||||
|                                 @SuppressLint("BatteryLife") | ||||
|                                 val intent = Intent().apply { | ||||
|                                     action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | ||||
|                                     data = "package:$packageName".toUri() | ||||
|                                     addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||||
|                                 } | ||||
|                                 context.startActivity(intent) | ||||
|                             } catch (e: ActivityNotFoundException) { | ||||
|                                 context.toast(R.string.battery_optimization_setting_activity_not_found) | ||||
|                             } | ||||
|                         } else { | ||||
|                             context.toast(R.string.battery_optimization_disabled) | ||||
|                         } | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = "Don't kill my app!", | ||||
|                     subtitle = stringResource(id = R.string.about_dont_kill_my_app), | ||||
|                     onClick = { context.openInBrowser("https://dontkillmyapp.com/") }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDataGroup(): Preference.PreferenceGroup { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val context = LocalContext.current | ||||
|         val navigator = LocalNavigator.currentOrThrow | ||||
|         val libraryPreferences = remember { Injekt.get<LibraryPreferences>() } | ||||
|  | ||||
|         val chapterCache = remember { Injekt.get<ChapterCache>() } | ||||
|         var readableSizeSema by remember { mutableStateOf(0) } | ||||
|         val readableSize = remember(readableSizeSema) { chapterCache.readableSize } | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.label_data), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_clear_chapter_cache), | ||||
|                     subtitle = stringResource(id = R.string.used_cache, readableSize), | ||||
|                     onClick = { | ||||
|                         scope.launchNonCancellable { | ||||
|                             try { | ||||
|                                 val deletedFiles = chapterCache.clear() | ||||
|                                 withUIContext { | ||||
|                                     context.toast(context.getString(R.string.cache_deleted, deletedFiles)) | ||||
|                                     readableSizeSema++ | ||||
|                                 } | ||||
|                             } catch (e: Throwable) { | ||||
|                                 logcat(LogPriority.ERROR, e) | ||||
|                                 withUIContext { context.toast(R.string.cache_delete_error) } | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = libraryPreferences.autoClearChapterCache(), | ||||
|                     title = stringResource(id = R.string.pref_auto_clear_chapter_cache), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_clear_database), | ||||
|                     subtitle = stringResource(id = R.string.pref_clear_database_summary), | ||||
|                     onClick = { navigator.push(ClearDatabaseScreen()) }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getNetworkGroup( | ||||
|         networkPreferences: NetworkPreferences, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         val networkHelper = remember { Injekt.get<NetworkHelper>() } | ||||
|  | ||||
|         val userAgentPref = networkPreferences.defaultUserAgent() | ||||
|         val userAgent by userAgentPref.collectAsState() | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.label_network), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_clear_cookies), | ||||
|                     onClick = { | ||||
|                         networkHelper.cookieManager.removeAll() | ||||
|                         context.toast(R.string.cookies_cleared) | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_clear_webview_data), | ||||
|                     onClick = { | ||||
|                         try { | ||||
|                             WebView(context).run { | ||||
|                                 setDefaultSettings() | ||||
|                                 clearCache(true) | ||||
|                                 clearFormData() | ||||
|                                 clearHistory() | ||||
|                                 clearSslPreferences() | ||||
|                             } | ||||
|                             WebStorage.getInstance().deleteAllData() | ||||
|                             context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() } | ||||
|                             context.toast(R.string.webview_data_deleted) | ||||
|                         } catch (e: Throwable) { | ||||
|                             logcat(LogPriority.ERROR, e) | ||||
|                             context.toast(R.string.cache_delete_error) | ||||
|                         } | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = networkPreferences.dohProvider(), | ||||
|                     title = stringResource(id = R.string.pref_dns_over_https), | ||||
|                     entries = mapOf( | ||||
|                         -1 to stringResource(id = R.string.disabled), | ||||
|                         PREF_DOH_CLOUDFLARE to "Cloudflare", | ||||
|                         PREF_DOH_GOOGLE to "Google", | ||||
|                         PREF_DOH_ADGUARD to "AdGuard", | ||||
|                         PREF_DOH_QUAD9 to "Quad9", | ||||
|                         PREF_DOH_ALIDNS to "AliDNS", | ||||
|                         PREF_DOH_DNSPOD to "DNSPod", | ||||
|                         PREF_DOH_360 to "360", | ||||
|                         PREF_DOH_QUAD101 to "Quad 101", | ||||
|                         PREF_DOH_MULLVAD to "Mullvad", | ||||
|                         PREF_DOH_CONTROLD to "Control D", | ||||
|                         PREF_DOH_NJALLA to "Njalla", | ||||
|                     ), | ||||
|                     onValueChanged = { | ||||
|                         context.toast(R.string.requires_app_restart) | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.EditTextPreference( | ||||
|                     pref = userAgentPref, | ||||
|                     title = stringResource(id = R.string.pref_user_agent_string), | ||||
|                     onValueChanged = { | ||||
|                         if (it.isBlank()) { | ||||
|                             context.toast(R.string.error_user_agent_string_blank) | ||||
|                             return@EditTextPreference false | ||||
|                         } | ||||
|                         context.toast(R.string.requires_app_restart) | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_reset_user_agent_string), | ||||
|                     enabled = remember(userAgent) { userAgent != userAgentPref.defaultValue() }, | ||||
|                     onClick = { | ||||
|                         userAgentPref.delete() | ||||
|                         context.toast(R.string.requires_app_restart) | ||||
|                     }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getLibraryGroup(): Preference.PreferenceGroup { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val context = LocalContext.current | ||||
|         val trackManager = remember { Injekt.get<TrackManager>() } | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.label_library), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_refresh_library_covers), | ||||
|                     onClick = { LibraryUpdateService.start(context, target = LibraryUpdateService.Target.COVERS) }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_refresh_library_tracking), | ||||
|                     subtitle = stringResource(id = R.string.pref_refresh_library_tracking_summary), | ||||
|                     enabled = trackManager.hasLoggedServices(), | ||||
|                     onClick = { LibraryUpdateService.start(context, target = LibraryUpdateService.Target.TRACKING) }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_reset_viewer_flags), | ||||
|                     subtitle = stringResource(id = R.string.pref_reset_viewer_flags_summary), | ||||
|                     onClick = { | ||||
|                         scope.launchNonCancellable { | ||||
|                             val success = Injekt.get<MangaRepository>().resetViewerFlags() | ||||
|                             withUIContext { | ||||
|                                 val message = if (success) { | ||||
|                                     R.string.pref_reset_viewer_flags_success | ||||
|                                 } else { | ||||
|                                     R.string.pref_reset_viewer_flags_error | ||||
|                                 } | ||||
|                                 context.toast(message) | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getExtensionsGroup( | ||||
|         basePreferences: BasePreferences, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         var shizukuMissing by rememberSaveable { mutableStateOf(false) } | ||||
|         if (shizukuMissing) { | ||||
|             val dismiss = { shizukuMissing = false } | ||||
|             AlertDialog( | ||||
|                 onDismissRequest = dismiss, | ||||
|                 title = { Text(text = stringResource(id = R.string.ext_installer_shizuku)) }, | ||||
|                 text = { Text(text = stringResource(id = R.string.ext_installer_shizuku_unavailable_dialog)) }, | ||||
|                 dismissButton = { | ||||
|                     TextButton(onClick = dismiss) { | ||||
|                         Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                     } | ||||
|                 }, | ||||
|                 confirmButton = { | ||||
|                     TextButton( | ||||
|                         onClick = { | ||||
|                             dismiss() | ||||
|                             context.openInBrowser("https://shizuku.rikka.app/download") | ||||
|                         }, | ||||
|                     ) { | ||||
|                         Text(text = stringResource(id = android.R.string.ok)) | ||||
|                     } | ||||
|                 }, | ||||
|             ) | ||||
|         } | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.label_extensions), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = basePreferences.extensionInstaller(), | ||||
|                     title = stringResource(id = R.string.ext_installer_pref), | ||||
|                     entries = PreferenceValues.ExtensionInstaller.values() | ||||
|                         .run { | ||||
|                             if (DeviceUtil.isMiui) { | ||||
|                                 filter { it != PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER } | ||||
|                             } else { | ||||
|                                 toList() | ||||
|                             } | ||||
|                         }.associateWith { stringResource(id = it.titleResId) }, | ||||
|                     onValueChanged = { | ||||
|                         if (it == PreferenceValues.ExtensionInstaller.SHIZUKU && | ||||
|                             !(context.isPackageInstalled("moe.shizuku.privileged.api") || Sui.isSui()) | ||||
|                         ) { | ||||
|                             shizukuMissing = true | ||||
|                             false | ||||
|                         } else { | ||||
|                             true | ||||
|                         } | ||||
|                     }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDisplayGroup(): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         val uiPreferences = remember { Injekt.get<UiPreferences>() } | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_category_display), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = uiPreferences.tabletUiMode(), | ||||
|                     title = stringResource(id = R.string.pref_tablet_ui_mode), | ||||
|                     entries = TabletUiMode.values().associateWith { stringResource(id = it.titleResId) }, | ||||
|                     onValueChanged = { | ||||
|                         context.toast(R.string.requires_app_restart) | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,142 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.core.app.ActivityCompat | ||||
| import eu.kanade.domain.ui.UiPreferences | ||||
| import eu.kanade.domain.ui.model.ThemeMode | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.system.isTablet | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.drop | ||||
| import kotlinx.coroutines.flow.merge | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.Date | ||||
|  | ||||
| class SettingsAppearanceScreen : SearchableSettings { | ||||
|  | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_appearance) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val context = LocalContext.current | ||||
|         val uiPreferences = remember { Injekt.get<UiPreferences>() } | ||||
|         val themeModePref = uiPreferences.themeMode() | ||||
|         val appThemePref = uiPreferences.appTheme() | ||||
|         val amoledPref = uiPreferences.themeDarkAmoled() | ||||
|  | ||||
|         val themeMode by themeModePref.collectAsState() | ||||
|  | ||||
|         LaunchedEffect(Unit) { | ||||
|             merge(appThemePref.changes(), amoledPref.changes()) | ||||
|                 .drop(2) | ||||
|                 .collectLatest { (context as? Activity)?.let { ActivityCompat.recreate(it) } } | ||||
|         } | ||||
|  | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.ListPreference( | ||||
|                 pref = themeModePref, | ||||
|                 title = stringResource(id = R.string.pref_category_theme), | ||||
|                 subtitle = "%s", | ||||
|                 entries = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||||
|                     mapOf( | ||||
|                         ThemeMode.SYSTEM to stringResource(id = R.string.theme_system), | ||||
|                         ThemeMode.LIGHT to stringResource(id = R.string.theme_light), | ||||
|                         ThemeMode.DARK to stringResource(id = R.string.theme_dark), | ||||
|                     ) | ||||
|                 } else { | ||||
|                     mapOf( | ||||
|                         ThemeMode.LIGHT to stringResource(id = R.string.theme_light), | ||||
|                         ThemeMode.DARK to stringResource(id = R.string.theme_dark), | ||||
|                     ) | ||||
|                 }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.AppThemePreference( | ||||
|                 title = stringResource(id = R.string.pref_app_theme), | ||||
|                 pref = appThemePref, | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = amoledPref, | ||||
|                 title = stringResource(id = R.string.pref_dark_theme_pure_black), | ||||
|                 enabled = themeMode != ThemeMode.LIGHT, | ||||
|             ), | ||||
|             getNavigationGroup(context = context, uiPreferences = uiPreferences), | ||||
|             getTimestampGroup(uiPreferences = uiPreferences), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getNavigationGroup( | ||||
|         context: Context, | ||||
|         uiPreferences: UiPreferences, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_category_navigation), | ||||
|             enabled = remember(context) { context.isTablet() }, | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = uiPreferences.sideNavIconAlignment(), | ||||
|                     title = stringResource(id = R.string.pref_side_nav_icon_alignment), | ||||
|                     subtitle = "%s", | ||||
|                     entries = mapOf( | ||||
|                         0 to stringResource(id = R.string.alignment_top), | ||||
|                         1 to stringResource(id = R.string.alignment_center), | ||||
|                         2 to stringResource(id = R.string.alignment_bottom), | ||||
|                     ), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getTimestampGroup(uiPreferences: UiPreferences): Preference.PreferenceGroup { | ||||
|         val now = remember { Date().time } | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_category_timestamps), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = uiPreferences.relativeTime(), | ||||
|                     title = stringResource(id = R.string.pref_relative_format), | ||||
|                     subtitle = "%s", | ||||
|                     entries = mapOf( | ||||
|                         0 to stringResource(id = R.string.off), | ||||
|                         2 to stringResource(id = R.string.pref_relative_time_short), | ||||
|                         7 to stringResource(id = R.string.pref_relative_time_long), | ||||
|                     ), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = uiPreferences.dateFormat(), | ||||
|                     title = stringResource(id = R.string.pref_date_format), | ||||
|                     subtitle = "%s", | ||||
|                     entries = DateFormats | ||||
|                         .associateWith { | ||||
|                             val formattedDate = UiPreferences.dateFormat(it).format(now) | ||||
|                             "${it.ifEmpty { stringResource(id = R.string.label_default) }} ($formattedDate)" | ||||
|                         }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| private val DateFormats = listOf( | ||||
|     "", // Default | ||||
|     "MM/dd/yy", | ||||
|     "dd/MM/yy", | ||||
|     "yyyy-MM-dd", | ||||
|     "dd MMM yyyy", | ||||
|     "MMM dd, yyyy", | ||||
| ) | ||||
| @@ -0,0 +1,370 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.widget.Toast | ||||
| import androidx.activity.compose.rememberLauncherForActivityResult | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.compose.foundation.clickable | ||||
| 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.material3.AlertDialog | ||||
| import androidx.compose.material3.Checkbox | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| 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.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalClipboardManager | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.AnnotatedString | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.core.net.toUri | ||||
| import com.google.accompanist.permissions.rememberPermissionState | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.domain.backup.service.BackupPreferences | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst | ||||
| import eu.kanade.tachiyomi.data.backup.BackupCreatorJob | ||||
| import eu.kanade.tachiyomi.data.backup.BackupFileValidator | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreService | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.util.system.DeviceUtil | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import kotlinx.coroutines.launch | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SettingsBackupScreen : SearchableSettings { | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.label_backup) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val backupPreferences = Injekt.get<BackupPreferences>() | ||||
|  | ||||
|         RequestStoragePermission() | ||||
|  | ||||
|         return listOf( | ||||
|             getCreateBackupPref(), | ||||
|             getRestoreBackupPref(), | ||||
|             getAutomaticBackupGroup(backupPreferences = backupPreferences), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun RequestStoragePermission() { | ||||
|         val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) | ||||
|         LaunchedEffect(Unit) { | ||||
|             permissionState.launchPermissionRequest() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val context = LocalContext.current | ||||
|  | ||||
|         var flag by rememberSaveable { mutableStateOf(0) } | ||||
|         val chooseBackupDir = rememberLauncherForActivityResult( | ||||
|             contract = ActivityResultContracts.CreateDocument("application/*"), | ||||
|         ) { | ||||
|             if (it != null) { | ||||
|                 context.contentResolver.takePersistableUriPermission( | ||||
|                     it, | ||||
|                     Intent.FLAG_GRANT_READ_URI_PERMISSION or | ||||
|                         Intent.FLAG_GRANT_WRITE_URI_PERMISSION, | ||||
|                 ) | ||||
|                 BackupCreatorJob.startNow(context, it, flag) | ||||
|             } | ||||
|             flag = 0 | ||||
|         } | ||||
|         var showCreateDialog by rememberSaveable { mutableStateOf(false) } | ||||
|         if (showCreateDialog) { | ||||
|             CreateBackupDialog( | ||||
|                 onConfirm = { | ||||
|                     showCreateDialog = false | ||||
|                     flag = it | ||||
|                     chooseBackupDir.launch(Backup.getBackupFilename()) | ||||
|                 }, | ||||
|                 onDismissRequest = { showCreateDialog = false }, | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         return Preference.PreferenceItem.TextPreference( | ||||
|             title = stringResource(id = R.string.pref_create_backup), | ||||
|             subtitle = stringResource(id = R.string.pref_create_backup_summ), | ||||
|             onClick = { | ||||
|                 scope.launch { | ||||
|                     if (!BackupCreatorJob.isManualJobRunning(context)) { | ||||
|                         if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { | ||||
|                             context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG) | ||||
|                         } | ||||
|                         showCreateDialog = true | ||||
|                     } else { | ||||
|                         context.toast(R.string.backup_in_progress) | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun CreateBackupDialog( | ||||
|         onConfirm: (flag: Int) -> Unit, | ||||
|         onDismissRequest: () -> Unit, | ||||
|     ) { | ||||
|         val flags = remember { mutableStateListOf<Int>() } | ||||
|         AlertDialog( | ||||
|             onDismissRequest = onDismissRequest, | ||||
|             title = { Text(text = stringResource(id = R.string.backup_choice)) }, | ||||
|             text = { | ||||
|                 val choices = remember { | ||||
|                     mapOf( | ||||
|                         BackupConst.BACKUP_CATEGORY to R.string.categories, | ||||
|                         BackupConst.BACKUP_CHAPTER to R.string.chapters, | ||||
|                         BackupConst.BACKUP_TRACK to R.string.track, | ||||
|                         BackupConst.BACKUP_HISTORY to R.string.history, | ||||
|                     ) | ||||
|                 } | ||||
|                 Column { | ||||
|                     CreateBackupDialogItem( | ||||
|                         isSelected = true, | ||||
|                         title = stringResource(id = R.string.manga), | ||||
|                     ) | ||||
|                     choices.forEach { (k, v) -> | ||||
|                         val isSelected = flags.contains(k) | ||||
|                         CreateBackupDialogItem( | ||||
|                             isSelected = isSelected, | ||||
|                             title = stringResource(id = v), | ||||
|                             modifier = Modifier.clickable { | ||||
|                                 if (isSelected) { | ||||
|                                     flags.remove(k) | ||||
|                                 } else { | ||||
|                                     flags.add(k) | ||||
|                                 } | ||||
|                             }, | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             dismissButton = { | ||||
|                 TextButton(onClick = onDismissRequest) { | ||||
|                     Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                 } | ||||
|             }, | ||||
|             confirmButton = { | ||||
|                 TextButton( | ||||
|                     onClick = { | ||||
|                         val flag = flags.fold(initial = 0, operation = { a, b -> a or b }) | ||||
|                         onConfirm(flag) | ||||
|                     }, | ||||
|                 ) { | ||||
|                     Text(text = stringResource(id = android.R.string.ok)) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun CreateBackupDialogItem( | ||||
|         modifier: Modifier = Modifier, | ||||
|         isSelected: Boolean, | ||||
|         title: String, | ||||
|     ) { | ||||
|         Row( | ||||
|             verticalAlignment = Alignment.CenterVertically, | ||||
|             modifier = modifier.fillMaxWidth(), | ||||
|         ) { | ||||
|             Checkbox( | ||||
|                 modifier = Modifier.heightIn(min = 48.dp), | ||||
|                 checked = isSelected, | ||||
|                 onCheckedChange = null, | ||||
|             ) | ||||
|             Text( | ||||
|                 text = title, | ||||
|                 style = MaterialTheme.typography.bodyMedium.merge(), | ||||
|                 modifier = Modifier.padding(start = 24.dp), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getRestoreBackupPref(): Preference.PreferenceItem.TextPreference { | ||||
|         val context = LocalContext.current | ||||
|         var error by remember { mutableStateOf<Any?>(null) } | ||||
|         if (error != null) { | ||||
|             val onDismissRequest = { error = null } | ||||
|             when (val err = error) { | ||||
|                 is InvalidRestore -> { | ||||
|                     val clipboard = LocalClipboardManager.current | ||||
|                     AlertDialog( | ||||
|                         onDismissRequest = onDismissRequest, | ||||
|                         title = { Text(text = stringResource(id = R.string.invalid_backup_file)) }, | ||||
|                         text = { Text(text = err.message) }, | ||||
|                         dismissButton = { | ||||
|                             TextButton( | ||||
|                                 onClick = { | ||||
|                                     clipboard.setText(AnnotatedString(err.message)) | ||||
|                                     context.toast(R.string.copied_to_clipboard) | ||||
|                                     onDismissRequest() | ||||
|                                 }, | ||||
|                             ) { | ||||
|                                 Text(text = stringResource(id = R.string.copy)) | ||||
|                             } | ||||
|                         }, | ||||
|                         confirmButton = { | ||||
|                             TextButton(onClick = onDismissRequest) { | ||||
|                                 Text(text = stringResource(id = android.R.string.ok)) | ||||
|                             } | ||||
|                         }, | ||||
|                     ) | ||||
|                 } | ||||
|                 is MissingRestoreComponents -> { | ||||
|                     AlertDialog( | ||||
|                         onDismissRequest = onDismissRequest, | ||||
|                         title = { Text(text = stringResource(id = R.string.pref_restore_backup)) }, | ||||
|                         text = { | ||||
|                             var msg = stringResource(id = R.string.backup_restore_content_full) | ||||
|                             if (err.sources.isNotEmpty()) { | ||||
|                                 msg += "\n\n${stringResource(R.string.backup_restore_missing_sources)}\n${err.sources.joinToString("\n") { "- $it" }}" | ||||
|                             } | ||||
|                             if (err.sources.isNotEmpty()) { | ||||
|                                 msg += "\n\n${stringResource(R.string.backup_restore_missing_trackers)}\n${err.trackers.joinToString("\n") { "- $it" }}" | ||||
|                             } | ||||
|                             Text(text = msg) | ||||
|                         }, | ||||
|                         confirmButton = { | ||||
|                             TextButton( | ||||
|                                 onClick = { | ||||
|                                     BackupRestoreService.start(context, err.uri) | ||||
|                                     onDismissRequest() | ||||
|                                 }, | ||||
|                             ) { | ||||
|                                 Text(text = stringResource(id = R.string.action_restore)) | ||||
|                             } | ||||
|                         }, | ||||
|                     ) | ||||
|                 } | ||||
|                 else -> error = null // Unknown | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val chooseBackup = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { | ||||
|             if (it != null) { | ||||
|                 val results = try { | ||||
|                     BackupFileValidator().validate(context, it) | ||||
|                 } catch (e: Exception) { | ||||
|                     error = InvalidRestore(e.message.toString()) | ||||
|                     return@rememberLauncherForActivityResult | ||||
|                 } | ||||
|  | ||||
|                 if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) { | ||||
|                     BackupRestoreService.start(context, it) | ||||
|                     return@rememberLauncherForActivityResult | ||||
|                 } | ||||
|  | ||||
|                 error = MissingRestoreComponents(it, results.missingSources, results.missingTrackers) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return Preference.PreferenceItem.TextPreference( | ||||
|             title = stringResource(id = R.string.pref_restore_backup), | ||||
|             subtitle = stringResource(id = R.string.pref_restore_backup_summ), | ||||
|             onClick = { | ||||
|                 if (!BackupRestoreService.isRunning(context)) { | ||||
|                     if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { | ||||
|                         context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG) | ||||
|                     } | ||||
|                     chooseBackup.launch("*/*") | ||||
|                 } else { | ||||
|                     context.toast(R.string.restore_in_progress) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     fun getAutomaticBackupGroup( | ||||
|         backupPreferences: BackupPreferences, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         val backupDirPref = backupPreferences.backupsDirectory() | ||||
|         val backupDir by backupDirPref.collectAsState() | ||||
|         val pickBackupLocation = rememberLauncherForActivityResult( | ||||
|             contract = ActivityResultContracts.OpenDocumentTree(), | ||||
|         ) { uri -> | ||||
|             if (uri != null) { | ||||
|                 val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or | ||||
|                     Intent.FLAG_GRANT_WRITE_URI_PERMISSION | ||||
|  | ||||
|                 context.contentResolver.takePersistableUriPermission(uri, flags) | ||||
|  | ||||
|                 val file = UniFile.fromUri(context, uri) | ||||
|                 backupDirPref.set(file.uri.toString()) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_backup_service_category), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = backupPreferences.backupInterval(), | ||||
|                     title = stringResource(id = R.string.pref_backup_interval), | ||||
|                     entries = mapOf( | ||||
|                         6 to stringResource(id = R.string.update_6hour), | ||||
|                         12 to stringResource(id = R.string.update_12hour), | ||||
|                         24 to stringResource(id = R.string.update_24hour), | ||||
|                         48 to stringResource(id = R.string.update_48hour), | ||||
|                         168 to stringResource(id = R.string.update_weekly), | ||||
|                     ), | ||||
|                     onValueChanged = { | ||||
|                         BackupCreatorJob.setupTask(context, it) | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.pref_backup_directory), | ||||
|                     subtitle = remember(backupDir) { | ||||
|                         UniFile.fromUri(context, backupDir.toUri()).filePath!! + "/automatic" | ||||
|                     }, | ||||
|                     onClick = { pickBackupLocation.launch(null) }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = backupPreferences.numberOfBackups(), | ||||
|                     title = stringResource(id = R.string.pref_backup_slots), | ||||
|                     entries = listOf(2, 3, 4, 5).associateWith { it.toString() }, | ||||
|                 ), | ||||
|                 Preference.infoPreference(stringResource(id = R.string.backup_info)), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| private data class MissingRestoreComponents( | ||||
|     val uri: Uri, | ||||
|     val sources: List<String>, | ||||
|     val trackers: List<String>, | ||||
| ) | ||||
|  | ||||
| data class InvalidRestore( | ||||
|     val message: String, | ||||
| ) | ||||
| @@ -0,0 +1,79 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import eu.kanade.domain.base.BasePreferences | ||||
| import eu.kanade.domain.source.service.SourcePreferences | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.extension.ExtensionUpdateJob | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SettingsBrowseScreen : SearchableSettings { | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.browse) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val context = LocalContext.current | ||||
|         val sourcePreferences = remember { Injekt.get<SourcePreferences>() } | ||||
|         val preferences = remember { Injekt.get<BasePreferences>() } | ||||
|         return listOf( | ||||
|             Preference.PreferenceGroup( | ||||
|                 title = stringResource(id = R.string.label_sources), | ||||
|                 preferenceItems = listOf( | ||||
|                     Preference.PreferenceItem.SwitchPreference( | ||||
|                         pref = sourcePreferences.duplicatePinnedSources(), | ||||
|                         title = stringResource(id = R.string.pref_duplicate_pinned_sources), | ||||
|                         subtitle = stringResource(id = R.string.pref_duplicate_pinned_sources_summary), | ||||
|                     ), | ||||
|                 ), | ||||
|             ), | ||||
|             Preference.PreferenceGroup( | ||||
|                 title = stringResource(id = R.string.label_extensions), | ||||
|                 preferenceItems = listOf( | ||||
|                     Preference.PreferenceItem.SwitchPreference( | ||||
|                         pref = preferences.automaticExtUpdates(), | ||||
|                         title = stringResource(id = R.string.pref_enable_automatic_extension_updates), | ||||
|                         onValueChanged = { | ||||
|                             ExtensionUpdateJob.setupTask(context, it) | ||||
|                             true | ||||
|                         }, | ||||
|                     ), | ||||
|                 ), | ||||
|             ), | ||||
|             Preference.PreferenceGroup( | ||||
|                 title = stringResource(id = R.string.action_global_search), | ||||
|                 preferenceItems = listOf( | ||||
|                     Preference.PreferenceItem.SwitchPreference( | ||||
|                         pref = sourcePreferences.searchPinnedSourcesOnly(), | ||||
|                         title = stringResource(id = R.string.pref_search_pinned_sources_only), | ||||
|                     ), | ||||
|                 ), | ||||
|             ), | ||||
|             Preference.PreferenceGroup( | ||||
|                 title = stringResource(id = R.string.pref_category_nsfw_content), | ||||
|                 preferenceItems = listOf( | ||||
|                     Preference.PreferenceItem.SwitchPreference( | ||||
|                         pref = sourcePreferences.showNsfwSource(), | ||||
|                         title = stringResource(id = R.string.pref_show_nsfw_source), | ||||
|                         subtitle = stringResource(id = R.string.requires_app_restart), | ||||
|                         onValueChanged = { | ||||
|                             (context as FragmentActivity).authenticate( | ||||
|                                 title = context.getString(R.string.pref_category_nsfw_content), | ||||
|                             ) | ||||
|                         }, | ||||
|                     ), | ||||
|                     Preference.infoPreference(stringResource(id = R.string.parental_controls_info)), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,269 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.content.Intent | ||||
| import android.os.Environment | ||||
| import androidx.activity.compose.rememberLauncherForActivityResult | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.produceState | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.saveable.rememberSaveable | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.pluralStringResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.core.net.toUri | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.domain.category.interactor.GetCategories | ||||
| import eu.kanade.domain.category.model.Category | ||||
| import eu.kanade.domain.download.service.DownloadPreferences | ||||
| import eu.kanade.presentation.category.visualName | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.more.settings.widget.TriStateListDialog | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import kotlinx.coroutines.flow.stateIn | ||||
| import kotlinx.coroutines.runBlocking | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.io.File | ||||
|  | ||||
| class SettingsDownloadScreen : SearchableSettings { | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_downloads) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val getCategories = remember { Injekt.get<GetCategories>() } | ||||
|         val allCategories by getCategories.subscribe().collectAsState(initial = runBlocking { getCategories.await() }) | ||||
|  | ||||
|         val downloadPreferences = remember { Injekt.get<DownloadPreferences>() } | ||||
|         return listOf( | ||||
|             getDownloadLocationPreference(downloadPreferences = downloadPreferences), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = downloadPreferences.downloadOnlyOverWifi(), | ||||
|                 title = stringResource(id = R.string.connected_to_wifi), | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = downloadPreferences.saveChaptersAsCBZ(), | ||||
|                 title = stringResource(id = R.string.save_chapter_as_cbz), | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = downloadPreferences.splitTallImages(), | ||||
|                 title = stringResource(id = R.string.split_tall_images), | ||||
|                 subtitle = stringResource(id = R.string.split_tall_images_summary), | ||||
|             ), | ||||
|             getDeleteChaptersGroup( | ||||
|                 downloadPreferences = downloadPreferences, | ||||
|                 categories = allCategories, | ||||
|             ), | ||||
|             getDownloadNewChaptersGroup( | ||||
|                 downloadPreferences = downloadPreferences, | ||||
|                 allCategories = allCategories, | ||||
|             ), | ||||
|             getDownloadAheadGroup(downloadPreferences = downloadPreferences), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDownloadLocationPreference( | ||||
|         downloadPreferences: DownloadPreferences, | ||||
|     ): Preference.PreferenceItem.ListPreference<String> { | ||||
|         val context = LocalContext.current | ||||
|         val currentDirPref = downloadPreferences.downloadsDirectory() | ||||
|         val currentDir by currentDirPref.collectAsState() | ||||
|  | ||||
|         val pickLocation = rememberLauncherForActivityResult( | ||||
|             contract = ActivityResultContracts.OpenDocumentTree(), | ||||
|         ) { uri -> | ||||
|             if (uri != null) { | ||||
|                 val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or | ||||
|                     Intent.FLAG_GRANT_WRITE_URI_PERMISSION | ||||
|  | ||||
|                 context.contentResolver.takePersistableUriPermission(uri, flags) | ||||
|  | ||||
|                 val file = UniFile.fromUri(context, uri) | ||||
|                 currentDirPref.set(file.uri.toString()) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val defaultDirPair = rememberDefaultDownloadDir() | ||||
|         val customDirEntryKey = currentDir.takeIf { it != defaultDirPair.first } ?: "custom" | ||||
|  | ||||
|         return Preference.PreferenceItem.ListPreference( | ||||
|             pref = currentDirPref, | ||||
|             title = stringResource(id = R.string.pref_download_directory), | ||||
|             subtitle = remember(currentDir) { | ||||
|                 UniFile.fromUri(context, currentDir.toUri()).filePath!! | ||||
|             }, | ||||
|             entries = mapOf( | ||||
|                 defaultDirPair, | ||||
|                 customDirEntryKey to stringResource(id = R.string.custom_dir), | ||||
|             ), | ||||
|             onValueChanged = { | ||||
|                 val default = it == defaultDirPair.first | ||||
|                 if (!default) { | ||||
|                     pickLocation.launch(null) | ||||
|                 } | ||||
|                 default // Don't update when non-default chosen | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun rememberDefaultDownloadDir(): Pair<String, String> { | ||||
|         val appName = stringResource(id = R.string.app_name) | ||||
|         return remember { | ||||
|             val file = UniFile.fromFile( | ||||
|                 File( | ||||
|                     "${Environment.getExternalStorageDirectory().absolutePath}${File.separator}$appName", | ||||
|                     "downloads", | ||||
|                 ), | ||||
|             )!! | ||||
|             file.uri.toString() to file.filePath!! | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDeleteChaptersGroup( | ||||
|         downloadPreferences: DownloadPreferences, | ||||
|         categories: List<Category>, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_category_delete_chapters), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = downloadPreferences.removeAfterMarkedAsRead(), | ||||
|                     title = stringResource(id = R.string.pref_remove_after_marked_as_read), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = downloadPreferences.removeAfterReadSlots(), | ||||
|                     title = stringResource(id = R.string.pref_remove_after_read), | ||||
|                     entries = mapOf( | ||||
|                         -1 to stringResource(id = R.string.disabled), | ||||
|                         0 to stringResource(id = R.string.last_read_chapter), | ||||
|                         1 to stringResource(id = R.string.second_to_last), | ||||
|                         2 to stringResource(id = R.string.third_to_last), | ||||
|                         3 to stringResource(id = R.string.fourth_to_last), | ||||
|                         4 to stringResource(id = R.string.fifth_to_last), | ||||
|                     ), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = downloadPreferences.removeBookmarkedChapters(), | ||||
|                     title = stringResource(id = R.string.pref_remove_bookmarked_chapters), | ||||
|                 ), | ||||
|                 getExcludedCategoriesPreference( | ||||
|                     downloadPreferences = downloadPreferences, | ||||
|                     categories = { categories }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getExcludedCategoriesPreference( | ||||
|         downloadPreferences: DownloadPreferences, | ||||
|         categories: () -> List<Category>, | ||||
|     ): Preference.PreferenceItem.MultiSelectListPreference { | ||||
|         val none = stringResource(id = R.string.none) | ||||
|         val pref = downloadPreferences.removeExcludeCategories() | ||||
|         val entries = categories().associate { it.id.toString() to it.visualName } | ||||
|         val subtitle by produceState(initialValue = "") { | ||||
|             pref.changes() | ||||
|                 .stateIn(this) | ||||
|                 .collect { mutable -> | ||||
|                     value = mutable | ||||
|                         .mapNotNull { id -> entries[id] } | ||||
|                         .sortedBy { entries.values.indexOf(it) } | ||||
|                         .joinToString() | ||||
|                         .ifEmpty { none } | ||||
|                 } | ||||
|         } | ||||
|         return Preference.PreferenceItem.MultiSelectListPreference( | ||||
|             pref = pref, | ||||
|             title = stringResource(id = R.string.pref_remove_exclude_categories), | ||||
|             subtitle = subtitle, | ||||
|             entries = entries, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDownloadNewChaptersGroup( | ||||
|         downloadPreferences: DownloadPreferences, | ||||
|         allCategories: List<Category>, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         val downloadNewChaptersPref = downloadPreferences.downloadNewChapters() | ||||
|         val downloadNewChapterCategoriesPref = downloadPreferences.downloadNewChapterCategories() | ||||
|         val downloadNewChapterCategoriesExcludePref = downloadPreferences.downloadNewChapterCategoriesExclude() | ||||
|  | ||||
|         val downloadNewChapters by downloadNewChaptersPref.collectAsState() | ||||
|  | ||||
|         val included by downloadNewChapterCategoriesPref.collectAsState() | ||||
|         val excluded by downloadNewChapterCategoriesExcludePref.collectAsState() | ||||
|         var showDialog by rememberSaveable { mutableStateOf(false) } | ||||
|         if (showDialog) { | ||||
|             TriStateListDialog( | ||||
|                 title = stringResource(id = R.string.categories), | ||||
|                 message = stringResource(id = R.string.pref_download_new_categories_details), | ||||
|                 items = allCategories, | ||||
|                 initialChecked = included.mapNotNull { id -> allCategories.find { it.id.toString() == id } }, | ||||
|                 initialInversed = excluded.mapNotNull { id -> allCategories.find { it.id.toString() == id } }, | ||||
|                 itemLabel = { it.visualName }, | ||||
|                 onDismissRequest = { showDialog = false }, | ||||
|                 onValueChanged = { newIncluded, newExcluded -> | ||||
|                     downloadNewChapterCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) | ||||
|                     downloadNewChapterCategoriesExcludePref.set(newExcluded.map { it.id.toString() }.toSet()) | ||||
|                     showDialog = false | ||||
|                 }, | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_download_new), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = downloadNewChaptersPref, | ||||
|                     title = stringResource(id = R.string.pref_download_new), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.categories), | ||||
|                     subtitle = getCategoriesLabel( | ||||
|                         allCategories = allCategories, | ||||
|                         included = included, | ||||
|                         excluded = excluded, | ||||
|                     ), | ||||
|                     onClick = { showDialog = true }, | ||||
|                     enabled = downloadNewChapters, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDownloadAheadGroup( | ||||
|         downloadPreferences: DownloadPreferences, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.download_ahead), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = downloadPreferences.autoDownloadWhileReading(), | ||||
|                     title = stringResource(id = R.string.auto_download_while_reading), | ||||
|                     entries = listOf(0, 2, 3, 5, 10).associateWith { | ||||
|                         if (it == 0) { | ||||
|                             stringResource(id = R.string.disabled) | ||||
|                         } else { | ||||
|                             pluralStringResource(id = R.plurals.next_unread_chapters, count = it, it) | ||||
|                         } | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.infoPreference(stringResource(id = R.string.download_ahead_info)), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,108 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.os.Build | ||||
| import android.provider.Settings | ||||
| import androidx.appcompat.app.AppCompatDelegate | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.core.os.LocaleListCompat | ||||
| import eu.kanade.domain.base.BasePreferences | ||||
| import eu.kanade.domain.library.service.LibraryPreferences | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.system.LocaleHelper | ||||
| import org.xmlpull.v1.XmlPullParser | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SettingsGeneralScreen : SearchableSettings { | ||||
|     @Composable | ||||
|     @ReadOnlyComposable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_general) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val prefs = remember { Injekt.get<BasePreferences>() } | ||||
|         val libraryPrefs = remember { Injekt.get<LibraryPreferences>() } | ||||
|         return mutableListOf<Preference>().apply { | ||||
|             add( | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = libraryPrefs.showUpdatesNavBadge(), | ||||
|                     title = stringResource(id = R.string.pref_library_update_show_tab_badge), | ||||
|                 ), | ||||
|             ) | ||||
|  | ||||
|             add( | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = prefs.confirmExit(), | ||||
|                     title = stringResource(id = R.string.pref_confirm_exit), | ||||
|                 ), | ||||
|             ) | ||||
|  | ||||
|             val context = LocalContext.current | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||||
|                 add( | ||||
|                     Preference.PreferenceItem.TextPreference( | ||||
|                         title = stringResource(id = R.string.pref_manage_notifications), | ||||
|                         onClick = { | ||||
|                             val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { | ||||
|                                 putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) | ||||
|                             } | ||||
|                             context.startActivity(intent) | ||||
|                         }, | ||||
|                     ), | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             val langs = remember { getLangs(context) } | ||||
|             val currentLanguage = remember { AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "" } | ||||
|             add( | ||||
|                 Preference.PreferenceItem.BasicListPreference( | ||||
|                     value = currentLanguage, | ||||
|                     title = stringResource(id = R.string.pref_app_language), | ||||
|                     subtitle = "%s", | ||||
|                     entries = langs, | ||||
|                     onValueChanged = { newValue -> | ||||
|                         val locale = if (newValue.isEmpty()) { | ||||
|                             LocaleListCompat.getEmptyLocaleList() | ||||
|                         } else { | ||||
|                             LocaleListCompat.forLanguageTags(newValue) | ||||
|                         } | ||||
|                         AppCompatDelegate.setApplicationLocales(locale) | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun getLangs(context: Context): Map<String, String> { | ||||
|         val langs = mutableListOf<Pair<String, String>>() | ||||
|         val parser = context.resources.getXml(R.xml.locales_config) | ||||
|         var eventType = parser.eventType | ||||
|         while (eventType != XmlPullParser.END_DOCUMENT) { | ||||
|             if (eventType == XmlPullParser.START_TAG && parser.name == "locale") { | ||||
|                 for (i in 0 until parser.attributeCount) { | ||||
|                     if (parser.getAttributeName(i) == "name") { | ||||
|                         val langTag = parser.getAttributeValue(i) | ||||
|                         val displayName = LocaleHelper.getDisplayName(langTag) | ||||
|                         if (displayName.isNotEmpty()) { | ||||
|                             langs.add(Pair(langTag, displayName)) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             eventType = parser.next() | ||||
|         } | ||||
|  | ||||
|         langs.sortBy { it.second } | ||||
|         langs.add(0, Pair("", context.getString(R.string.label_default))) | ||||
|  | ||||
|         return langs.toMap() | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,360 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.content.Context | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.LocalTextStyle | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| 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.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clipToBounds | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.pluralStringResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.core.content.ContextCompat | ||||
| import cafe.adriel.voyager.navigator.currentOrThrow | ||||
| import com.bluelinelabs.conductor.Router | ||||
| import com.chargemap.compose.numberpicker.NumberPicker | ||||
| import eu.kanade.domain.category.interactor.GetCategories | ||||
| import eu.kanade.domain.category.interactor.ResetCategoryFlags | ||||
| import eu.kanade.domain.category.model.Category | ||||
| import eu.kanade.domain.library.service.LibraryPreferences | ||||
| import eu.kanade.presentation.category.visualName | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.more.settings.widget.TriStateListDialog | ||||
| import eu.kanade.presentation.util.LocalRouter | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateJob | ||||
| import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW | ||||
| import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING | ||||
| import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED | ||||
| import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI | ||||
| import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD | ||||
| import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED | ||||
| import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.pushController | ||||
| import eu.kanade.tachiyomi.ui.category.CategoryController | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.runBlocking | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SettingsLibraryScreen : SearchableSettings { | ||||
|  | ||||
|     @Composable | ||||
|     @ReadOnlyComposable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_library) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val getCategories = remember { Injekt.get<GetCategories>() } | ||||
|         val libraryPreferences = remember { Injekt.get<LibraryPreferences>() } | ||||
|         val allCategories by getCategories.subscribe().collectAsState(initial = runBlocking { getCategories.await() }) | ||||
|  | ||||
|         return mutableListOf( | ||||
|             getDisplayGroup(libraryPreferences), | ||||
|             getCategoriesGroup(LocalRouter.currentOrThrow, allCategories, libraryPreferences), | ||||
|             getGlobalUpdateGroup(allCategories, libraryPreferences), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDisplayGroup(libraryPreferences: LibraryPreferences): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val portraitColumns by libraryPreferences.portraitColumns().stateIn(scope).collectAsState() | ||||
|         val landscapeColumns by libraryPreferences.landscapeColumns().stateIn(scope).collectAsState() | ||||
|  | ||||
|         var showDialog by rememberSaveable { mutableStateOf(false) } | ||||
|         if (showDialog) { | ||||
|             LibraryColumnsDialog( | ||||
|                 initialPortrait = portraitColumns, | ||||
|                 initialLandscape = landscapeColumns, | ||||
|                 onDismissRequest = { showDialog = false }, | ||||
|                 onValueChanged = { portrait, landscape -> | ||||
|                     libraryPreferences.portraitColumns().set(portrait) | ||||
|                     libraryPreferences.landscapeColumns().set(landscape) | ||||
|                     showDialog = false | ||||
|                 }, | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(R.string.pref_category_display), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(R.string.pref_library_columns), | ||||
|                     subtitle = "${stringResource(R.string.portrait)}: ${getColumnValue(context, portraitColumns)}, " + | ||||
|                         "${stringResource(R.string.landscape)}: ${getColumnValue(context, landscapeColumns)}", | ||||
|                     onClick = { showDialog = true }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getCategoriesGroup( | ||||
|         router: Router?, | ||||
|         allCategories: List<Category>, | ||||
|         libraryPreferences: LibraryPreferences, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val userCategoriesCount = allCategories.filterNot(Category::isSystemCategory).size | ||||
|  | ||||
|         val defaultCategory by libraryPreferences.defaultCategory().collectAsState() | ||||
|         val selectedCategory = allCategories.find { it.id == defaultCategory.toLong() } | ||||
|  | ||||
|         // For default category | ||||
|         val ids = listOf(libraryPreferences.defaultCategory().defaultValue()) + | ||||
|             allCategories.map { it.id.toInt() } | ||||
|         val labels = listOf(stringResource(id = R.string.default_category_summary)) + | ||||
|             allCategories.map { it.visualName(context) } | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.categories), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.action_edit_categories), | ||||
|                     subtitle = pluralStringResource( | ||||
|                         id = R.plurals.num_categories, | ||||
|                         count = userCategoriesCount, | ||||
|                         userCategoriesCount, | ||||
|                     ), | ||||
|                     onClick = { router?.pushController(CategoryController()) }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = libraryPreferences.defaultCategory(), | ||||
|                     title = stringResource(id = R.string.default_category), | ||||
|                     subtitle = selectedCategory?.visualName ?: stringResource(id = R.string.default_category_summary), | ||||
|                     entries = ids.zip(labels).toMap(), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = libraryPreferences.categorizedDisplaySettings(), | ||||
|                     title = stringResource(id = R.string.categorized_display_settings), | ||||
|                     onValueChanged = { | ||||
|                         if (!it) { | ||||
|                             scope.launch { | ||||
|                                 Injekt.get<ResetCategoryFlags>().await() | ||||
|                             } | ||||
|                         } | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getGlobalUpdateGroup( | ||||
|         allCategories: List<Category>, | ||||
|         libraryPreferences: LibraryPreferences, | ||||
|     ): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|  | ||||
|         val libraryUpdateIntervalPref = libraryPreferences.libraryUpdateInterval() | ||||
|         val libraryUpdateDeviceRestrictionPref = libraryPreferences.libraryUpdateDeviceRestriction() | ||||
|         val libraryUpdateMangaRestrictionPref = libraryPreferences.libraryUpdateMangaRestriction() | ||||
|         val libraryUpdateCategoriesPref = libraryPreferences.libraryUpdateCategories() | ||||
|         val libraryUpdateCategoriesExcludePref = libraryPreferences.libraryUpdateCategoriesExclude() | ||||
|  | ||||
|         val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState() | ||||
|  | ||||
|         val deviceRestrictionEntries = mapOf( | ||||
|             DEVICE_ONLY_ON_WIFI to stringResource(id = R.string.connected_to_wifi), | ||||
|             DEVICE_NETWORK_NOT_METERED to stringResource(id = R.string.network_not_metered), | ||||
|             DEVICE_CHARGING to stringResource(id = R.string.charging), | ||||
|             DEVICE_BATTERY_NOT_LOW to stringResource(id = R.string.battery_not_low), | ||||
|         ) | ||||
|         val deviceRestrictions = libraryUpdateDeviceRestrictionPref.collectAsState() | ||||
|             .value | ||||
|             .sorted() | ||||
|             .map { deviceRestrictionEntries.getOrElse(it) { it } } | ||||
|             .let { if (it.isEmpty()) stringResource(id = R.string.none) else it.joinToString() } | ||||
|  | ||||
|         val mangaRestrictionEntries = mapOf( | ||||
|             MANGA_HAS_UNREAD to stringResource(id = R.string.pref_update_only_completely_read), | ||||
|             MANGA_NON_READ to stringResource(id = R.string.pref_update_only_started), | ||||
|             MANGA_NON_COMPLETED to stringResource(id = R.string.pref_update_only_non_completed), | ||||
|         ) | ||||
|         val mangaRestrictions = libraryUpdateMangaRestrictionPref.collectAsState() | ||||
|             .value | ||||
|             .map { mangaRestrictionEntries.getOrElse(it) { it } } | ||||
|             .let { if (it.isEmpty()) stringResource(id = R.string.none) else it.joinToString() } | ||||
|  | ||||
|         val included by libraryUpdateCategoriesPref.collectAsState() | ||||
|         val excluded by libraryUpdateCategoriesExcludePref.collectAsState() | ||||
|         var showDialog by rememberSaveable { mutableStateOf(false) } | ||||
|         if (showDialog) { | ||||
|             TriStateListDialog( | ||||
|                 title = stringResource(id = R.string.categories), | ||||
|                 message = stringResource(id = R.string.pref_library_update_categories_details), | ||||
|                 items = allCategories, | ||||
|                 initialChecked = included.mapNotNull { id -> allCategories.find { it.id.toString() == id } }, | ||||
|                 initialInversed = excluded.mapNotNull { id -> allCategories.find { it.id.toString() == id } }, | ||||
|                 itemLabel = { it.visualName }, | ||||
|                 onDismissRequest = { showDialog = false }, | ||||
|                 onValueChanged = { newIncluded, newExcluded -> | ||||
|                     libraryUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) | ||||
|                     libraryUpdateCategoriesExcludePref.set(newExcluded.map { it.id.toString() }.toSet()) | ||||
|                     showDialog = false | ||||
|                 }, | ||||
|             ) | ||||
|         } | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_category_library_update), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = libraryUpdateIntervalPref, | ||||
|                     title = stringResource(id = R.string.pref_library_update_interval), | ||||
|                     subtitle = "%s", | ||||
|                     entries = mapOf( | ||||
|                         0 to stringResource(id = R.string.update_never), | ||||
|                         12 to stringResource(id = R.string.update_12hour), | ||||
|                         24 to stringResource(id = R.string.update_24hour), | ||||
|                         48 to stringResource(id = R.string.update_48hour), | ||||
|                         72 to stringResource(id = R.string.update_72hour), | ||||
|                         168 to stringResource(id = R.string.update_weekly), | ||||
|                     ), | ||||
|                     onValueChanged = { | ||||
|                         LibraryUpdateJob.setupTask(context, it) | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.MultiSelectListPreference( | ||||
|                     pref = libraryUpdateDeviceRestrictionPref, | ||||
|                     enabled = libraryUpdateInterval > 0, | ||||
|                     title = stringResource(id = R.string.pref_library_update_restriction), | ||||
|                     subtitle = stringResource(id = R.string.restrictions, deviceRestrictions), | ||||
|                     entries = deviceRestrictionEntries, | ||||
|                     onValueChanged = { | ||||
|                         // Post to event looper to allow the preference to be updated. | ||||
|                         ContextCompat.getMainExecutor(context).execute { LibraryUpdateJob.setupTask(context) } | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.MultiSelectListPreference( | ||||
|                     pref = libraryUpdateMangaRestrictionPref, | ||||
|                     title = stringResource(id = R.string.pref_library_update_manga_restriction), | ||||
|                     subtitle = mangaRestrictions, | ||||
|                     entries = mangaRestrictionEntries, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(id = R.string.categories), | ||||
|                     subtitle = getCategoriesLabel( | ||||
|                         allCategories = allCategories, | ||||
|                         included = included, | ||||
|                         excluded = excluded, | ||||
|                     ), | ||||
|                     onClick = { showDialog = true }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = libraryPreferences.autoUpdateMetadata(), | ||||
|                     title = stringResource(id = R.string.pref_library_update_refresh_metadata), | ||||
|                     subtitle = stringResource(id = R.string.pref_library_update_refresh_metadata_summary), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = libraryPreferences.autoUpdateTrackers(), | ||||
|                     enabled = Injekt.get<TrackManager>().hasLoggedServices(), | ||||
|                     title = stringResource(id = R.string.pref_library_update_refresh_trackers), | ||||
|                     subtitle = stringResource(id = R.string.pref_library_update_refresh_trackers_summary), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun LibraryColumnsDialog( | ||||
|         initialPortrait: Int, | ||||
|         initialLandscape: Int, | ||||
|         onDismissRequest: () -> Unit, | ||||
|         onValueChanged: (portrait: Int, landscape: Int) -> Unit, | ||||
|     ) { | ||||
|         val context = LocalContext.current | ||||
|         var portraitValue by rememberSaveable { mutableStateOf(initialPortrait) } | ||||
|         var landscapeValue by rememberSaveable { mutableStateOf(initialLandscape) } | ||||
|  | ||||
|         AlertDialog( | ||||
|             onDismissRequest = onDismissRequest, | ||||
|             title = { Text(text = stringResource(id = R.string.pref_library_columns)) }, | ||||
|             text = { | ||||
|                 Row { | ||||
|                     Column( | ||||
|                         modifier = Modifier.weight(1f), | ||||
|                         horizontalAlignment = Alignment.CenterHorizontally, | ||||
|                     ) { | ||||
|                         Text( | ||||
|                             text = stringResource(id = R.string.portrait), | ||||
|                             style = MaterialTheme.typography.labelMedium, | ||||
|                         ) | ||||
|                         NumberPicker( | ||||
|                             modifier = Modifier | ||||
|                                 .fillMaxWidth() | ||||
|                                 .clipToBounds(), | ||||
|                             value = portraitValue, | ||||
|                             onValueChange = { portraitValue = it }, | ||||
|                             range = 0..10, | ||||
|                             label = { getColumnValue(context, it) }, | ||||
|                             dividersColor = MaterialTheme.colorScheme.primary, | ||||
|                             textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), | ||||
|                         ) | ||||
|                     } | ||||
|  | ||||
|                     Column( | ||||
|                         modifier = Modifier.weight(1f), | ||||
|                         horizontalAlignment = Alignment.CenterHorizontally, | ||||
|                     ) { | ||||
|                         Text( | ||||
|                             text = stringResource(id = R.string.landscape), | ||||
|                             style = MaterialTheme.typography.labelMedium, | ||||
|                         ) | ||||
|                         NumberPicker( | ||||
|                             modifier = Modifier | ||||
|                                 .fillMaxWidth() | ||||
|                                 .clipToBounds(), | ||||
|                             value = landscapeValue, | ||||
|                             onValueChange = { landscapeValue = it }, | ||||
|                             range = 0..10, | ||||
|                             label = { getColumnValue(context, it) }, | ||||
|                             dividersColor = MaterialTheme.colorScheme.primary, | ||||
|                             textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             dismissButton = { | ||||
|                 TextButton(onClick = onDismissRequest) { | ||||
|                     Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                 } | ||||
|             }, | ||||
|             confirmButton = { | ||||
|                 TextButton(onClick = { onValueChanged(portraitValue, landscapeValue) }) { | ||||
|                     Text(text = stringResource(id = android.R.string.ok)) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private fun getColumnValue(context: Context, value: Int): String { | ||||
|         return if (value == 0) { | ||||
|             context.getString(R.string.label_default) | ||||
|         } else { | ||||
|             value.toString() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,112 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.ChromeReaderMode | ||||
| import androidx.compose.material.icons.outlined.Code | ||||
| import androidx.compose.material.icons.outlined.CollectionsBookmark | ||||
| import androidx.compose.material.icons.outlined.Explore | ||||
| import androidx.compose.material.icons.outlined.GetApp | ||||
| import androidx.compose.material.icons.outlined.Palette | ||||
| import androidx.compose.material.icons.outlined.Search | ||||
| import androidx.compose.material.icons.outlined.Security | ||||
| import androidx.compose.material.icons.outlined.SettingsBackupRestore | ||||
| import androidx.compose.material.icons.outlined.Sync | ||||
| import androidx.compose.material.icons.outlined.Tune | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.NonRestartableComposable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import cafe.adriel.voyager.navigator.LocalNavigator | ||||
| import cafe.adriel.voyager.navigator.currentOrThrow | ||||
| import eu.kanade.presentation.components.AppBar | ||||
| import eu.kanade.presentation.components.AppBarActions | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.more.settings.PreferenceScaffold | ||||
| import eu.kanade.presentation.util.LocalBackPress | ||||
| import eu.kanade.tachiyomi.R | ||||
|  | ||||
| object SettingsMainScreen : SearchableSettings { | ||||
|     @Composable | ||||
|     @ReadOnlyComposable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.label_settings) | ||||
|  | ||||
|     @Composable | ||||
|     @NonRestartableComposable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val navigator = LocalNavigator.currentOrThrow | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_general), | ||||
|                 icon = Icons.Outlined.Tune, | ||||
|                 onClick = { navigator.push(SettingsGeneralScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_appearance), | ||||
|                 icon = Icons.Outlined.Palette, | ||||
|                 onClick = { navigator.push(SettingsAppearanceScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_library), | ||||
|                 icon = Icons.Outlined.CollectionsBookmark, | ||||
|                 onClick = { navigator.push(SettingsLibraryScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_reader), | ||||
|                 icon = Icons.Outlined.ChromeReaderMode, | ||||
|                 onClick = { navigator.push(SettingsReaderScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_downloads), | ||||
|                 icon = Icons.Outlined.GetApp, | ||||
|                 onClick = { navigator.push(SettingsDownloadScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_tracking), | ||||
|                 icon = Icons.Outlined.Sync, | ||||
|                 onClick = { navigator.push(SettingsTrackingScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.browse), | ||||
|                 icon = Icons.Outlined.Explore, | ||||
|                 onClick = { navigator.push(SettingsBrowseScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.label_backup), | ||||
|                 icon = Icons.Outlined.SettingsBackupRestore, | ||||
|                 onClick = { navigator.push(SettingsBackupScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_security), | ||||
|                 icon = Icons.Outlined.Security, | ||||
|                 onClick = { navigator.push(SettingsSecurityScreen()) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.TextPreference( | ||||
|                 title = stringResource(R.string.pref_category_advanced), | ||||
|                 icon = Icons.Outlined.Code, | ||||
|                 onClick = { navigator.push(SettingsAdvancedScreen()) }, | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     override fun Content() { | ||||
|         val navigator = LocalNavigator.currentOrThrow | ||||
|         val backPress = LocalBackPress.currentOrThrow | ||||
|         PreferenceScaffold( | ||||
|             title = getTitle(), | ||||
|             actions = { | ||||
|                 AppBarActions( | ||||
|                     listOf( | ||||
|                         AppBar.Action( | ||||
|                             title = stringResource(R.string.action_search), | ||||
|                             icon = Icons.Outlined.Search, | ||||
|                             onClick = { navigator.push(SettingsSearchScreen()) }, | ||||
|                         ), | ||||
|                     ), | ||||
|                 ) | ||||
|             }, | ||||
|             onBackPressed = backPress::invoke, | ||||
|             itemsProvider = { getPreferences() }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,312 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.os.Build | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalView | ||||
| import androidx.compose.ui.res.stringArrayResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferenceValues.ReaderHideThreshold | ||||
| import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.OrientationType | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SettingsReaderScreen : SearchableSettings { | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_reader) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val readerPref = remember { Injekt.get<ReaderPreferences>() } | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.ListPreference( | ||||
|                 pref = readerPref.defaultReadingMode(), | ||||
|                 title = stringResource(id = R.string.pref_viewer_type), | ||||
|                 entries = ReadingModeType.values().drop(1) | ||||
|                     .associate { it.flagValue to stringResource(id = it.stringRes) }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.ListPreference( | ||||
|                 pref = readerPref.doubleTapAnimSpeed(), | ||||
|                 title = stringResource(id = R.string.pref_double_tap_anim_speed), | ||||
|                 entries = mapOf( | ||||
|                     1 to stringResource(id = R.string.double_tap_anim_speed_0), | ||||
|                     500 to stringResource(id = R.string.double_tap_anim_speed_normal), | ||||
|                     250 to stringResource(id = R.string.double_tap_anim_speed_fast), | ||||
|                 ), | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = readerPref.showReadingMode(), | ||||
|                 title = stringResource(id = R.string.pref_show_reading_mode), | ||||
|                 subtitle = stringResource(id = R.string.pref_show_reading_mode_summary), | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = readerPref.showNavigationOverlayOnStart(), | ||||
|                 title = stringResource(id = R.string.pref_show_navigation_mode), | ||||
|                 subtitle = stringResource(id = R.string.pref_show_navigation_mode_summary), | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = readerPref.trueColor(), | ||||
|                 title = stringResource(id = R.string.pref_true_color), | ||||
|                 subtitle = stringResource(id = R.string.pref_true_color_summary), | ||||
|                 enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O, | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = readerPref.pageTransitions(), | ||||
|                 title = stringResource(id = R.string.pref_page_transitions), | ||||
|             ), | ||||
|             getDisplayGroup(readerPreferences = readerPref), | ||||
|             getPagedGroup(readerPreferences = readerPref), | ||||
|             getWebtoonGroup(readerPreferences = readerPref), | ||||
|             getNavigationGroup(readerPreferences = readerPref), | ||||
|             getActionsGroup(readerPreferences = readerPref), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { | ||||
|         val fullscreenPref = readerPreferences.fullscreen() | ||||
|         val fullscreen by fullscreenPref.collectAsState() | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_category_display), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = readerPreferences.defaultOrientationType(), | ||||
|                     title = stringResource(id = R.string.pref_rotation_type), | ||||
|                     entries = OrientationType.values().drop(1) | ||||
|                         .associate { it.flagValue to stringResource(id = it.stringRes) }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = readerPreferences.readerTheme(), | ||||
|                     title = stringResource(id = R.string.pref_reader_theme), | ||||
|                     entries = mapOf( | ||||
|                         1 to stringResource(id = R.string.black_background), | ||||
|                         2 to stringResource(id = R.string.gray_background), | ||||
|                         0 to stringResource(id = R.string.white_background), | ||||
|                         3 to stringResource(id = R.string.automatic_background), | ||||
|                     ), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = fullscreenPref, | ||||
|                     title = stringResource(id = R.string.pref_fullscreen), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.cutoutShort(), | ||||
|                     title = stringResource(id = R.string.pref_cutout_short), | ||||
|                     enabled = fullscreen && | ||||
|                         Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && | ||||
|                         LocalView.current.rootWindowInsets?.displayCutout != null, // has cutout | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.keepScreenOn(), | ||||
|                     title = stringResource(id = R.string.pref_keep_screen_on), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.showPageNumber(), | ||||
|                     title = stringResource(id = R.string.pref_show_page_number), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getPagedGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { | ||||
|         val navModePref = readerPreferences.navigationModePager() | ||||
|         val imageScaleTypePref = readerPreferences.imageScaleType() | ||||
|         val dualPageSplitPref = readerPreferences.dualPageSplitPaged() | ||||
|  | ||||
|         val navMode by navModePref.collectAsState() | ||||
|         val imageScaleType by imageScaleTypePref.collectAsState() | ||||
|         val dualPageSplit by dualPageSplitPref.collectAsState() | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pager_viewer), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = navModePref, | ||||
|                     title = stringResource(id = R.string.pref_viewer_nav), | ||||
|                     entries = stringArrayResource(id = R.array.pager_nav).let { | ||||
|                         it.indices.zip(it).toMap() | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = readerPreferences.pagerNavInverted(), | ||||
|                     title = stringResource(id = R.string.pref_read_with_tapping_inverted), | ||||
|                     entries = mapOf( | ||||
|                         TappingInvertMode.NONE to stringResource(id = R.string.none), | ||||
|                         TappingInvertMode.HORIZONTAL to stringResource(id = R.string.tapping_inverted_horizontal), | ||||
|                         TappingInvertMode.VERTICAL to stringResource(id = R.string.tapping_inverted_vertical), | ||||
|                         TappingInvertMode.BOTH to stringResource(id = R.string.tapping_inverted_both), | ||||
|                     ), | ||||
|                     enabled = navMode != 5, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.navigateToPan(), | ||||
|                     title = stringResource(id = R.string.pref_navigate_pan), | ||||
|                     enabled = navMode != 5, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = imageScaleTypePref, | ||||
|                     title = stringResource(id = R.string.pref_image_scale_type), | ||||
|                     entries = mapOf( | ||||
|                         1 to stringResource(id = R.string.scale_type_fit_screen), | ||||
|                         2 to stringResource(id = R.string.scale_type_stretch), | ||||
|                         3 to stringResource(id = R.string.scale_type_fit_width), | ||||
|                         4 to stringResource(id = R.string.scale_type_fit_height), | ||||
|                         5 to stringResource(id = R.string.scale_type_original_size), | ||||
|                         6 to stringResource(id = R.string.scale_type_smart_fit), | ||||
|                     ), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.landscapeZoom(), | ||||
|                     title = stringResource(id = R.string.pref_landscape_zoom), | ||||
|                     enabled = imageScaleType == 1, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = readerPreferences.zoomStart(), | ||||
|                     title = stringResource(id = R.string.pref_zoom_start), | ||||
|                     entries = mapOf( | ||||
|                         1 to stringResource(id = R.string.zoom_start_automatic), | ||||
|                         2 to stringResource(id = R.string.zoom_start_left), | ||||
|                         3 to stringResource(id = R.string.zoom_start_right), | ||||
|                         4 to stringResource(id = R.string.zoom_start_center), | ||||
|                     ), | ||||
|  | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.cropBorders(), | ||||
|                     title = stringResource(id = R.string.pref_crop_borders), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = dualPageSplitPref, | ||||
|                     title = stringResource(id = R.string.pref_dual_page_split), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.dualPageInvertPaged(), | ||||
|                     title = stringResource(id = R.string.pref_dual_page_invert), | ||||
|                     subtitle = stringResource(id = R.string.pref_dual_page_invert_summary), | ||||
|                     enabled = dualPageSplit, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getWebtoonGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { | ||||
|         val navModePref = readerPreferences.navigationModeWebtoon() | ||||
|         val dualPageSplitPref = readerPreferences.dualPageSplitWebtoon() | ||||
|  | ||||
|         val navMode by navModePref.collectAsState() | ||||
|         val dualPageSplit by dualPageSplitPref.collectAsState() | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.webtoon_viewer), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = navModePref, | ||||
|                     title = stringResource(id = R.string.pref_viewer_nav), | ||||
|                     entries = stringArrayResource(id = R.array.webtoon_nav).let { | ||||
|                         it.indices.zip(it).toMap() | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = readerPreferences.webtoonNavInverted(), | ||||
|                     title = stringResource(id = R.string.pref_read_with_tapping_inverted), | ||||
|                     entries = mapOf( | ||||
|                         TappingInvertMode.NONE to stringResource(id = R.string.none), | ||||
|                         TappingInvertMode.HORIZONTAL to stringResource(id = R.string.tapping_inverted_horizontal), | ||||
|                         TappingInvertMode.VERTICAL to stringResource(id = R.string.tapping_inverted_vertical), | ||||
|                         TappingInvertMode.BOTH to stringResource(id = R.string.tapping_inverted_both), | ||||
|                     ), | ||||
|                     enabled = navMode != 5, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = readerPreferences.webtoonSidePadding(), | ||||
|                     title = stringResource(id = R.string.pref_webtoon_side_padding), | ||||
|                     entries = mapOf( | ||||
|                         0 to stringResource(id = R.string.webtoon_side_padding_0), | ||||
|                         5 to stringResource(id = R.string.webtoon_side_padding_5), | ||||
|                         10 to stringResource(id = R.string.webtoon_side_padding_10), | ||||
|                         15 to stringResource(id = R.string.webtoon_side_padding_15), | ||||
|                         20 to stringResource(id = R.string.webtoon_side_padding_20), | ||||
|                         25 to stringResource(id = R.string.webtoon_side_padding_25), | ||||
|                     ), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = readerPreferences.readerHideThreshold(), | ||||
|                     title = stringResource(id = R.string.pref_hide_threshold), | ||||
|                     entries = mapOf( | ||||
|                         ReaderHideThreshold.HIGHEST to stringResource(id = R.string.pref_highest), | ||||
|                         ReaderHideThreshold.HIGH to stringResource(id = R.string.pref_high), | ||||
|                         ReaderHideThreshold.LOW to stringResource(id = R.string.pref_low), | ||||
|                         ReaderHideThreshold.LOWEST to stringResource(id = R.string.pref_lowest), | ||||
|                     ), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.cropBordersWebtoon(), | ||||
|                     title = stringResource(id = R.string.pref_crop_borders), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = dualPageSplitPref, | ||||
|                     title = stringResource(id = R.string.pref_dual_page_split), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.dualPageInvertWebtoon(), | ||||
|                     title = stringResource(id = R.string.pref_dual_page_invert), | ||||
|                     subtitle = stringResource(id = R.string.pref_dual_page_invert_summary), | ||||
|                     enabled = dualPageSplit, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.longStripSplitWebtoon(), | ||||
|                     title = stringResource(id = R.string.pref_long_strip_split), | ||||
|                     subtitle = stringResource(id = R.string.split_tall_images_summary), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getNavigationGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { | ||||
|         val readWithVolumeKeysPref = readerPreferences.readWithVolumeKeys() | ||||
|         val readWithVolumeKeys by readWithVolumeKeysPref.collectAsState() | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_reader_navigation), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readWithVolumeKeysPref, | ||||
|                     title = stringResource(id = R.string.pref_read_with_volume_keys), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.readWithVolumeKeysInverted(), | ||||
|                     title = stringResource(id = R.string.pref_read_with_volume_keys_inverted), | ||||
|                     enabled = readWithVolumeKeys, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getActionsGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(id = R.string.pref_reader_actions), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.readWithLongTap(), | ||||
|                     title = stringResource(id = R.string.pref_read_with_long_tap), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.folderPerManga(), | ||||
|                     title = stringResource(id = R.string.pref_create_folder_per_manga), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,303 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.content.res.Resources | ||||
| import androidx.compose.animation.Crossfade | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.paddingFromBaseline | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.lazy.LazyListState | ||||
| import androidx.compose.foundation.lazy.items | ||||
| import androidx.compose.foundation.lazy.rememberLazyListState | ||||
| import androidx.compose.foundation.text.BasicTextField | ||||
| import androidx.compose.foundation.text.KeyboardActions | ||||
| import androidx.compose.foundation.text.KeyboardOptions | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.ArrowBack | ||||
| import androidx.compose.material.icons.filled.Close | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.IconButton | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TopAppBar | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.DisposableEffect | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.NonRestartableComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.produceState | ||||
| 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.focus.FocusRequester | ||||
| import androidx.compose.ui.focus.focusRequester | ||||
| import androidx.compose.ui.graphics.SolidColor | ||||
| import androidx.compose.ui.platform.LocalFocusManager | ||||
| import androidx.compose.ui.platform.LocalSoftwareKeyboardController | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.text.input.ImeAction | ||||
| import androidx.compose.ui.text.input.TextFieldValue | ||||
| import androidx.compose.ui.text.style.TextOverflow | ||||
| import androidx.compose.ui.unit.dp | ||||
| import cafe.adriel.voyager.core.screen.Screen | ||||
| import cafe.adriel.voyager.navigator.LocalNavigator | ||||
| import cafe.adriel.voyager.navigator.currentOrThrow | ||||
| import eu.kanade.presentation.components.Divider | ||||
| import eu.kanade.presentation.components.EmptyScreen | ||||
| import eu.kanade.presentation.components.Scaffold | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.system.isLTR | ||||
|  | ||||
| class SettingsSearchScreen : Screen { | ||||
|     @Composable | ||||
|     override fun Content() { | ||||
|         val navigator = LocalNavigator.currentOrThrow | ||||
|         val softKeyboardController = LocalSoftwareKeyboardController.current | ||||
|         val focusManager = LocalFocusManager.current | ||||
|         val focusRequester = remember { FocusRequester() } | ||||
|         val listState = rememberLazyListState() | ||||
|  | ||||
|         // Hide keyboard on change screen | ||||
|         DisposableEffect(Unit) { | ||||
|             onDispose { | ||||
|                 softKeyboardController?.hide() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Hide keyboard on outside text field is touched | ||||
|         LaunchedEffect(listState.isScrollInProgress) { | ||||
|             if (listState.isScrollInProgress) { | ||||
|                 focusManager.clearFocus() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Request text field focus on launch | ||||
|         LaunchedEffect(focusRequester) { | ||||
|             focusRequester.requestFocus() | ||||
|         } | ||||
|  | ||||
|         var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } | ||||
|         Scaffold( | ||||
|             topBar = { | ||||
|                 Column { | ||||
|                     TopAppBar( | ||||
|                         navigationIcon = { | ||||
|                             IconButton(onClick = navigator::pop) { | ||||
|                                 Icon( | ||||
|                                     imageVector = Icons.Default.ArrowBack, | ||||
|                                     contentDescription = null, | ||||
|                                     tint = MaterialTheme.colorScheme.onSurfaceVariant, | ||||
|                                 ) | ||||
|                             } | ||||
|                         }, | ||||
|                         title = { | ||||
|                             BasicTextField( | ||||
|                                 value = textFieldValue, | ||||
|                                 onValueChange = { textFieldValue = it }, | ||||
|                                 modifier = Modifier | ||||
|                                     .fillMaxWidth() | ||||
|                                     .focusRequester(focusRequester), | ||||
|                                 textStyle = MaterialTheme.typography.bodyLarge | ||||
|                                     .copy(color = MaterialTheme.colorScheme.onSurface), | ||||
|                                 singleLine = true, | ||||
|                                 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), | ||||
|                                 keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), | ||||
|                                 cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), | ||||
|                                 decorationBox = { | ||||
|                                     if (textFieldValue.text.isEmpty()) { | ||||
|                                         Text( | ||||
|                                             text = stringResource(id = R.string.action_search_settings), | ||||
|                                             color = MaterialTheme.colorScheme.onSurfaceVariant, | ||||
|                                             style = MaterialTheme.typography.bodyLarge, | ||||
|                                         ) | ||||
|                                     } | ||||
|                                     it() | ||||
|                                 }, | ||||
|                             ) | ||||
|                         }, | ||||
|                         actions = { | ||||
|                             if (textFieldValue.text.isNotEmpty()) { | ||||
|                                 IconButton(onClick = { textFieldValue = TextFieldValue() }) { | ||||
|                                     Icon( | ||||
|                                         imageVector = Icons.Default.Close, | ||||
|                                         contentDescription = null, | ||||
|                                         tint = MaterialTheme.colorScheme.onSurfaceVariant, | ||||
|                                     ) | ||||
|                                 } | ||||
|                             } | ||||
|                         }, | ||||
|                     ) | ||||
|                     Divider() | ||||
|                 } | ||||
|             }, | ||||
|         ) { contentPadding -> | ||||
|             SearchResult( | ||||
|                 searchKey = textFieldValue.text, | ||||
|                 listState = listState, | ||||
|                 contentPadding = contentPadding, | ||||
|             ) { result -> | ||||
|                 SearchableSettings.highlightKey = result.highlightKey | ||||
|                 navigator.popUntil { it is SettingsMainScreen } | ||||
|                 navigator.push(result.route) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun SearchResult( | ||||
|     searchKey: String, | ||||
|     modifier: Modifier = Modifier, | ||||
|     listState: LazyListState = rememberLazyListState(), | ||||
|     contentPadding: PaddingValues = PaddingValues(), | ||||
|     onItemClick: (SearchResultItem) -> Unit, | ||||
| ) { | ||||
|     if (searchKey.isEmpty()) return | ||||
|  | ||||
|     val index = getIndex() | ||||
|     val result by produceState<List<SearchResultItem>?>(initialValue = null, searchKey) { | ||||
|         value = index.asSequence() | ||||
|             .flatMap { settingsData -> | ||||
|                 settingsData.contents.asSequence() | ||||
|                     // Only search from enabled prefs and one with valid title | ||||
|                     .filter { it.enabled && it.title.isNotBlank() } | ||||
|                     // Flatten items contained inside *enabled* PreferenceGroup | ||||
|                     .flatMap { p -> | ||||
|                         when (p) { | ||||
|                             is Preference.PreferenceGroup -> { | ||||
|                                 if (p.enabled) { | ||||
|                                     p.preferenceItems.asSequence() | ||||
|                                         .filter { it.enabled && it.title.isNotBlank() } | ||||
|                                         .map { p.title to it } | ||||
|                                 } else { | ||||
|                                     emptySequence() | ||||
|                                 } | ||||
|                             } | ||||
|                             is Preference.PreferenceItem<*> -> sequenceOf(null to p) | ||||
|                             else -> emptySequence() // Ignore other prefs | ||||
|                         } | ||||
|                     } | ||||
|                     // Filter by search query | ||||
|                     .filter { (_, p) -> | ||||
|                         val inTitle = p.title.contains(searchKey, true) | ||||
|                         val inSummary = p.subtitle?.contains(searchKey, true) ?: false | ||||
|                         inTitle || inSummary | ||||
|                     } | ||||
|                     // Map result data | ||||
|                     .map { (categoryTitle, p) -> | ||||
|                         SearchResultItem( | ||||
|                             route = settingsData.route, | ||||
|                             title = p.title, | ||||
|                             breadcrumbs = getLocalizedBreadcrumb(path = settingsData.title, node = categoryTitle), | ||||
|                             highlightKey = p.title, | ||||
|                         ) | ||||
|                     } | ||||
|             } | ||||
|             .take(10) // Just take top 10 result for quicker result | ||||
|             .toList() | ||||
|     } | ||||
|  | ||||
|     Crossfade(targetState = result) { | ||||
|         LazyColumn( | ||||
|             modifier = modifier.fillMaxSize(), | ||||
|             state = listState, | ||||
|             contentPadding = contentPadding, | ||||
|             horizontalAlignment = Alignment.CenterHorizontally, | ||||
|         ) { | ||||
|             when { | ||||
|                 it == null -> { | ||||
|                     /* Don't show anything just yet */ | ||||
|                 } | ||||
|                 // No result | ||||
|                 it.isEmpty() -> item { EmptyScreen(stringResource(id = R.string.no_results_found)) } | ||||
|                 // Show result list | ||||
|                 else -> items( | ||||
|                     items = it, | ||||
|                     key = { i -> i.hashCode() }, | ||||
|                 ) { item -> | ||||
|                     Column( | ||||
|                         modifier = Modifier | ||||
|                             .fillMaxWidth() | ||||
|                             .clickable { onItemClick(item) } | ||||
|                             .padding(horizontal = 24.dp, vertical = 14.dp), | ||||
|                     ) { | ||||
|                         Text( | ||||
|                             text = item.title, | ||||
|                             overflow = TextOverflow.Ellipsis, | ||||
|                             maxLines = 1, | ||||
|                             fontWeight = FontWeight.Normal, | ||||
|                             style = MaterialTheme.typography.titleMedium, | ||||
|                         ) | ||||
|                         Text( | ||||
|                             text = item.breadcrumbs, | ||||
|                             modifier = Modifier.paddingFromBaseline(top = 16.dp), | ||||
|                             maxLines = 1, | ||||
|                             color = MaterialTheme.colorScheme.onSurfaceVariant, | ||||
|                             style = MaterialTheme.typography.bodySmall, | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| @NonRestartableComposable | ||||
| private fun getIndex() = settingScreens | ||||
|     .map { screen -> | ||||
|         SettingsData( | ||||
|             title = screen.getTitle(), | ||||
|             route = screen, | ||||
|             contents = screen.getPreferences(), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
| private fun getLocalizedBreadcrumb(path: String, node: String?): String { | ||||
|     return if (node == null) { | ||||
|         path | ||||
|     } else { | ||||
|         if (Resources.getSystem().isLTR) { | ||||
|             // This locale reads left to right. | ||||
|             "$path > $node" | ||||
|         } else { | ||||
|             // This locale reads right to left. | ||||
|             "$node < $path" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private val settingScreens = listOf( | ||||
|     SettingsGeneralScreen(), | ||||
|     SettingsAppearanceScreen(), | ||||
|     SettingsLibraryScreen(), | ||||
|     SettingsReaderScreen(), | ||||
|     SettingsDownloadScreen(), | ||||
|     SettingsTrackingScreen(), | ||||
|     SettingsBrowseScreen(), | ||||
|     SettingsBackupScreen(), | ||||
|     SettingsSecurityScreen(), | ||||
|     SettingsAdvancedScreen(), | ||||
| ) | ||||
|  | ||||
| private data class SettingsData( | ||||
|     val title: String, | ||||
|     val route: Screen, | ||||
|     val contents: List<Preference>, | ||||
| ) | ||||
|  | ||||
| private data class SearchResultItem( | ||||
|     val route: Screen, | ||||
|     val title: String, | ||||
|     val breadcrumbs: String, | ||||
|     val highlightKey: String, | ||||
| ) | ||||
| @@ -0,0 +1,89 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.pluralStringResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.core.security.SecurityPreferences | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SettingsSecurityScreen : SearchableSettings { | ||||
|  | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_security) | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val context = LocalContext.current | ||||
|         val securityPreferences = remember { Injekt.get<SecurityPreferences>() } | ||||
|         val authSupported = remember { context.isAuthenticationSupported() } | ||||
|  | ||||
|         val useAuthPref = securityPreferences.useAuthenticator() | ||||
|  | ||||
|         val useAuth by useAuthPref.collectAsState() | ||||
|  | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = useAuthPref, | ||||
|                 title = stringResource(id = R.string.lock_with_biometrics), | ||||
|                 enabled = authSupported, | ||||
|                 onValueChanged = { | ||||
|                     (context as FragmentActivity).authenticate( | ||||
|                         title = context.getString(R.string.lock_with_biometrics), | ||||
|                     ) | ||||
|                 }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.ListPreference( | ||||
|                 pref = securityPreferences.lockAppAfter(), | ||||
|                 title = stringResource(id = R.string.lock_when_idle), | ||||
|                 subtitle = "%s", | ||||
|                 enabled = authSupported && useAuth, | ||||
|                 entries = LockAfterValues | ||||
|                     .associateWith { | ||||
|                         when (it) { | ||||
|                             -1 -> stringResource(id = R.string.lock_never) | ||||
|                             0 -> stringResource(id = R.string.lock_always) | ||||
|                             else -> pluralStringResource(id = R.plurals.lock_after_mins, count = it, it) | ||||
|                         } | ||||
|                     }, | ||||
|                 onValueChanged = { | ||||
|                     (context as FragmentActivity).authenticate( | ||||
|                         title = context.getString(R.string.lock_when_idle), | ||||
|                     ) | ||||
|                 }, | ||||
|             ), | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = securityPreferences.hideNotificationContent(), | ||||
|                 title = stringResource(id = R.string.hide_notification_content), | ||||
|             ), | ||||
|             Preference.PreferenceItem.ListPreference( | ||||
|                 pref = securityPreferences.secureScreen(), | ||||
|                 title = stringResource(id = R.string.secure_screen), | ||||
|                 subtitle = "%s", | ||||
|                 entries = SecurityPreferences.SecureScreenMode.values() | ||||
|                     .associateWith { stringResource(id = it.titleResId) }, | ||||
|             ), | ||||
|             Preference.infoPreference(stringResource(id = R.string.secure_screen_summary)), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| private val LockAfterValues = listOf( | ||||
|     0, // Always | ||||
|     1, | ||||
|     2, | ||||
|     5, | ||||
|     10, | ||||
|     -1, // Never | ||||
| ) | ||||
| @@ -0,0 +1,336 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.content.Context | ||||
| import android.widget.Toast | ||||
| import androidx.annotation.StringRes | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.RowScope | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.text.KeyboardOptions | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.HelpOutline | ||||
| import androidx.compose.material.icons.filled.Visibility | ||||
| import androidx.compose.material.icons.filled.VisibilityOff | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.Button | ||||
| import androidx.compose.material3.ButtonDefaults | ||||
| 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.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.rememberCoroutineScope | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.input.ImeAction | ||||
| import androidx.compose.ui.text.input.PasswordVisualTransformation | ||||
| import androidx.compose.ui.text.input.TextFieldValue | ||||
| import androidx.compose.ui.text.input.VisualTransformation | ||||
| import androidx.compose.ui.text.style.TextAlign | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.domain.track.service.TrackPreferences | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.anilist.AnilistApi | ||||
| import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi | ||||
| import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi | ||||
| import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.lang.withUIContext | ||||
| import eu.kanade.tachiyomi.util.system.openInBrowser | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SettingsTrackingScreen : SearchableSettings { | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     override fun getTitle(): String = stringResource(id = R.string.pref_category_tracking) | ||||
|  | ||||
|     @Composable | ||||
|     override fun RowScope.AppBarAction() { | ||||
|         val context = LocalContext.current | ||||
|         IconButton(onClick = { context.openInBrowser("https://tachiyomi.org/help/guides/tracking/") }) { | ||||
|             Icon( | ||||
|                 imageVector = Icons.Default.HelpOutline, | ||||
|                 contentDescription = stringResource(id = R.string.tracking_guide), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val context = LocalContext.current | ||||
|         val trackPreferences = remember { Injekt.get<TrackPreferences>() } | ||||
|         val trackManager = remember { Injekt.get<TrackManager>() } | ||||
|  | ||||
|         var dialog by remember { mutableStateOf<Any?>(null) } | ||||
|         dialog?.run { | ||||
|             when (this) { | ||||
|                 is LoginDialog -> { | ||||
|                     TrackingLoginDialog( | ||||
|                         service = service, | ||||
|                         uNameStringRes = uNameStringRes, | ||||
|                         onDismissRequest = { dialog = null }, | ||||
|                     ) | ||||
|                 } | ||||
|                 is LogoutDialog -> { | ||||
|                     TrackingLogoutDialog( | ||||
|                         service = service, | ||||
|                         onDismissRequest = { dialog = null }, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.SwitchPreference( | ||||
|                 pref = trackPreferences.autoUpdateTrack(), | ||||
|                 title = stringResource(id = R.string.pref_auto_update_manga_sync), | ||||
|             ), | ||||
|             Preference.PreferenceGroup( | ||||
|                 title = stringResource(id = R.string.services), | ||||
|                 preferenceItems = listOf( | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(id = trackManager.myAnimeList.nameRes()), | ||||
|                         service = trackManager.myAnimeList, | ||||
|                         login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.myAnimeList) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(id = trackManager.aniList.nameRes()), | ||||
|                         service = trackManager.aniList, | ||||
|                         login = { context.openInBrowser(AnilistApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.aniList) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(id = trackManager.kitsu.nameRes()), | ||||
|                         service = trackManager.kitsu, | ||||
|                         login = { dialog = LoginDialog(trackManager.kitsu, R.string.email) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.kitsu) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(id = trackManager.mangaUpdates.nameRes()), | ||||
|                         service = trackManager.mangaUpdates, | ||||
|                         login = { dialog = LoginDialog(trackManager.mangaUpdates, R.string.username) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.mangaUpdates) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(id = trackManager.shikimori.nameRes()), | ||||
|                         service = trackManager.shikimori, | ||||
|                         login = { context.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.shikimori) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(id = trackManager.bangumi.nameRes()), | ||||
|                         service = trackManager.bangumi, | ||||
|                         login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.bangumi) }, | ||||
|                     ), | ||||
|                     Preference.infoPreference(stringResource(id = R.string.tracking_info)), | ||||
|                 ), | ||||
|             ), | ||||
|             Preference.PreferenceGroup( | ||||
|                 title = stringResource(id = R.string.enhanced_services), | ||||
|                 preferenceItems = listOf( | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(id = trackManager.komga.nameRes()), | ||||
|                         service = trackManager.komga, | ||||
|                         login = { | ||||
|                             val sourceManager = Injekt.get<SourceManager>() | ||||
|                             val acceptedSources = trackManager.komga.getAcceptedSources() | ||||
|                             val hasValidSourceInstalled = sourceManager.getCatalogueSources() | ||||
|                                 .any { it::class.qualifiedName in acceptedSources } | ||||
|  | ||||
|                             if (hasValidSourceInstalled) { | ||||
|                                 trackManager.komga.loginNoop() | ||||
|                             } else { | ||||
|                                 context.toast(R.string.tracker_komga_warning, Toast.LENGTH_LONG) | ||||
|                             } | ||||
|                         }, | ||||
|                         logout = trackManager.komga::logout, | ||||
|                     ), | ||||
|                     Preference.infoPreference(stringResource(id = R.string.enhanced_tracking_info)), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun TrackingLoginDialog( | ||||
|         service: TrackService, | ||||
|         @StringRes uNameStringRes: Int, | ||||
|         onDismissRequest: () -> Unit, | ||||
|     ) { | ||||
|         val context = LocalContext.current | ||||
|         val scope = rememberCoroutineScope() | ||||
|  | ||||
|         var username by remember { mutableStateOf(TextFieldValue(service.getUsername())) } | ||||
|         var password by remember { mutableStateOf(TextFieldValue(service.getPassword())) } | ||||
|         var processing by remember { mutableStateOf(false) } | ||||
|         var inputError by remember { mutableStateOf(false) } | ||||
|  | ||||
|         AlertDialog( | ||||
|             onDismissRequest = onDismissRequest, | ||||
|             title = { Text(text = stringResource(id = R.string.login_title, stringResource(id = service.nameRes()))) }, | ||||
|             text = { | ||||
|                 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { | ||||
|                     OutlinedTextField( | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         value = username, | ||||
|                         onValueChange = { username = it }, | ||||
|                         label = { Text(text = stringResource(id = uNameStringRes)) }, | ||||
|                         keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), | ||||
|                         singleLine = true, | ||||
|                         isError = inputError && username.text.isEmpty(), | ||||
|                     ) | ||||
|  | ||||
|                     var hidePassword by remember { mutableStateOf(true) } | ||||
|                     OutlinedTextField( | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         value = password, | ||||
|                         onValueChange = { password = it }, | ||||
|                         label = { Text(text = stringResource(id = R.string.password)) }, | ||||
|                         trailingIcon = { | ||||
|                             IconButton(onClick = { hidePassword = !hidePassword }) { | ||||
|                                 Icon( | ||||
|                                     imageVector = if (hidePassword) { | ||||
|                                         Icons.Default.Visibility | ||||
|                                     } else { | ||||
|                                         Icons.Default.VisibilityOff | ||||
|                                     }, | ||||
|                                     contentDescription = null, | ||||
|                                 ) | ||||
|                             } | ||||
|                         }, | ||||
|                         visualTransformation = if (hidePassword) { | ||||
|                             PasswordVisualTransformation() | ||||
|                         } else { | ||||
|                             VisualTransformation.None | ||||
|                         }, | ||||
|                         keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), | ||||
|                         singleLine = true, | ||||
|                         isError = inputError && password.text.isEmpty(), | ||||
|                     ) | ||||
|                 } | ||||
|             }, | ||||
|             confirmButton = { | ||||
|                 Column { | ||||
|                     Button( | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         enabled = !processing, | ||||
|                         onClick = { | ||||
|                             if (username.text.isEmpty() || password.text.isEmpty()) { | ||||
|                                 inputError = true | ||||
|                                 return@Button | ||||
|                             } | ||||
|                             scope.launchIO { | ||||
|                                 inputError = false | ||||
|                                 processing = true | ||||
|                                 val result = checkLogin( | ||||
|                                     context = context, | ||||
|                                     service = service, | ||||
|                                     username = username.text, | ||||
|                                     password = password.text, | ||||
|                                 ) | ||||
|                                 if (result) onDismissRequest() | ||||
|                                 processing = false | ||||
|                             } | ||||
|                         }, | ||||
|                     ) { | ||||
|                         val id = if (processing) R.string.loading else R.string.login | ||||
|                         Text(text = stringResource(id = id)) | ||||
|                     } | ||||
|                     TextButton( | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         onClick = onDismissRequest, | ||||
|                     ) { | ||||
|                         Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private suspend fun checkLogin( | ||||
|         context: Context, | ||||
|         service: TrackService, | ||||
|         username: String, | ||||
|         password: String, | ||||
|     ): Boolean { | ||||
|         return try { | ||||
|             service.login(username, password) | ||||
|             withUIContext { context.toast(R.string.login_success) } | ||||
|             true | ||||
|         } catch (e: Throwable) { | ||||
|             service.logout() | ||||
|             withUIContext { context.toast(e.message.toString()) } | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun TrackingLogoutDialog( | ||||
|         service: TrackService, | ||||
|         onDismissRequest: () -> Unit, | ||||
|     ) { | ||||
|         val context = LocalContext.current | ||||
|         AlertDialog( | ||||
|             onDismissRequest = onDismissRequest, | ||||
|             title = { | ||||
|                 Text( | ||||
|                     text = stringResource(id = R.string.logout_title, stringResource(id = service.nameRes())), | ||||
|                     textAlign = TextAlign.Center, | ||||
|                     modifier = Modifier.fillMaxWidth(), | ||||
|                 ) | ||||
|             }, | ||||
|             confirmButton = { | ||||
|                 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { | ||||
|                     OutlinedButton( | ||||
|                         modifier = Modifier.weight(1f), | ||||
|                         onClick = onDismissRequest, | ||||
|                     ) { | ||||
|                         Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                     } | ||||
|                     Button( | ||||
|                         modifier = Modifier.weight(1f), | ||||
|                         onClick = { | ||||
|                             service.logout() | ||||
|                             onDismissRequest() | ||||
|                             context.toast(R.string.logout_success) | ||||
|                         }, | ||||
|                         colors = ButtonDefaults.buttonColors( | ||||
|                             containerColor = MaterialTheme.colorScheme.error, | ||||
|                             contentColor = MaterialTheme.colorScheme.onError, | ||||
|                         ), | ||||
|                     ) { | ||||
|                         Text(text = stringResource(id = R.string.logout)) | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| private data class LoginDialog( | ||||
|     val service: TrackService, | ||||
|     @StringRes val uNameStringRes: Int, | ||||
| ) | ||||
|  | ||||
| private data class LogoutDialog( | ||||
|     val service: TrackService, | ||||
| ) | ||||
| @@ -0,0 +1,270 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import android.content.res.Configuration.UI_MODE_NIGHT_YES | ||||
| import androidx.compose.animation.animateContentSize | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.border | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.aspectRatio | ||||
| import androidx.compose.foundation.layout.fillMaxHeight | ||||
| 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.shape.CircleShape | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.CheckCircle | ||||
| import androidx.compose.material3.Icon | ||||
| 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.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.draw.alpha | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.style.TextAlign | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.domain.ui.model.AppTheme | ||||
| import eu.kanade.presentation.components.DIVIDER_ALPHA | ||||
| import eu.kanade.presentation.components.MangaCover | ||||
| import eu.kanade.presentation.theme.TachiyomiTheme | ||||
| import eu.kanade.presentation.util.secondaryItemAlpha | ||||
|  | ||||
| @Composable | ||||
| internal fun AppThemePreferenceWidget( | ||||
|     title: String, | ||||
|     value: AppTheme, | ||||
|     amoled: Boolean, | ||||
|     onItemClick: (AppTheme) -> Unit, | ||||
| ) { | ||||
|     BasePreferenceWidget( | ||||
|         title = title, | ||||
|         subcomponent = { | ||||
|             AppThemesList( | ||||
|                 currentTheme = value, | ||||
|                 amoled = amoled, | ||||
|                 onItemClick = onItemClick, | ||||
|             ) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun AppThemesList( | ||||
|     currentTheme: AppTheme, | ||||
|     amoled: Boolean, | ||||
|     onItemClick: (AppTheme) -> Unit, | ||||
| ) { | ||||
|     val appThemes = remember { | ||||
|         AppTheme.values().filter { it.titleResId != null } | ||||
|     } | ||||
|     LazyRow( | ||||
|         modifier = Modifier | ||||
|             .animateContentSize() | ||||
|             .padding(vertical = 8.dp), | ||||
|         contentPadding = PaddingValues(horizontal = HorizontalPadding), | ||||
|         horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||
|     ) { | ||||
|         items( | ||||
|             items = appThemes, | ||||
|             key = { it.name }, | ||||
|         ) { appTheme -> | ||||
|             Column( | ||||
|                 modifier = Modifier | ||||
|                     .width(114.dp) | ||||
|                     .padding(top = 8.dp), | ||||
|             ) { | ||||
|                 TachiyomiTheme( | ||||
|                     appTheme = appTheme, | ||||
|                     amoled = amoled, | ||||
|                 ) { | ||||
|                     AppThemePreviewItem( | ||||
|                         selected = currentTheme == appTheme, | ||||
|                         onClick = { onItemClick(appTheme) }, | ||||
|                     ) | ||||
|                 } | ||||
|  | ||||
|                 Text( | ||||
|                     text = stringResource(id = appTheme.titleResId!!), | ||||
|                     modifier = Modifier | ||||
|                         .fillMaxWidth() | ||||
|                         .padding(top = 8.dp) | ||||
|                         .secondaryItemAlpha(), | ||||
|                     color = MaterialTheme.colorScheme.onSurface, | ||||
|                     textAlign = TextAlign.Center, | ||||
|                     maxLines = 2, | ||||
|                     style = MaterialTheme.typography.bodySmall, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun AppThemePreviewItem( | ||||
|     selected: Boolean, | ||||
|     onClick: () -> Unit, | ||||
| ) { | ||||
|     val dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA) | ||||
|     Column( | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .aspectRatio(9f / 16f) | ||||
|             .border( | ||||
|                 width = 4.dp, | ||||
|                 color = if (selected) { | ||||
|                     MaterialTheme.colorScheme.primary | ||||
|                 } else { | ||||
|                     dividerColor | ||||
|                 }, | ||||
|                 shape = RoundedCornerShape(17.dp), | ||||
|             ) | ||||
|             .padding(4.dp) | ||||
|             .clip(RoundedCornerShape(13.dp)) | ||||
|             .background(MaterialTheme.colorScheme.background) | ||||
|             .clickable(onClick = onClick), | ||||
|     ) { | ||||
|         // App Bar | ||||
|         Row( | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .height(40.dp) | ||||
|                 .padding(8.dp), | ||||
|             verticalAlignment = Alignment.CenterVertically, | ||||
|         ) { | ||||
|             Box( | ||||
|                 modifier = Modifier | ||||
|                     .fillMaxHeight(0.8f) | ||||
|                     .weight(0.7f) | ||||
|                     .padding(end = 4.dp) | ||||
|                     .background( | ||||
|                         color = MaterialTheme.colorScheme.onSurface, | ||||
|                         shape = RoundedCornerShape(9.dp), | ||||
|                     ), | ||||
|             ) | ||||
|  | ||||
|             Box( | ||||
|                 modifier = Modifier.weight(0.3f), | ||||
|                 contentAlignment = Alignment.CenterEnd, | ||||
|             ) { | ||||
|                 if (selected) { | ||||
|                     Icon( | ||||
|                         imageVector = Icons.Default.CheckCircle, | ||||
|                         contentDescription = null, | ||||
|                         tint = MaterialTheme.colorScheme.primary, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Cover | ||||
|         Box( | ||||
|             modifier = Modifier | ||||
|                 .padding(start = 8.dp, top = 2.dp) | ||||
|                 .background( | ||||
|                     color = dividerColor, | ||||
|                     shape = RoundedCornerShape(9.dp), | ||||
|                 ) | ||||
|                 .fillMaxWidth(0.5f) | ||||
|                 .aspectRatio(MangaCover.Book.ratio), | ||||
|         ) { | ||||
|             Row( | ||||
|                 modifier = Modifier | ||||
|                     .padding(4.dp) | ||||
|                     .size(width = 24.dp, height = 16.dp) | ||||
|                     .clip(RoundedCornerShape(5.dp)), | ||||
|             ) { | ||||
|                 Box( | ||||
|                     modifier = Modifier | ||||
|                         .fillMaxHeight() | ||||
|                         .width(12.dp) | ||||
|                         .background(MaterialTheme.colorScheme.tertiary), | ||||
|                 ) | ||||
|                 Box( | ||||
|                     modifier = Modifier | ||||
|                         .fillMaxHeight() | ||||
|                         .width(12.dp) | ||||
|                         .background(MaterialTheme.colorScheme.secondary), | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Bottom bar | ||||
|         Box( | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .weight(1f), | ||||
|             contentAlignment = Alignment.BottomCenter, | ||||
|         ) { | ||||
|             Surface( | ||||
|                 tonalElevation = 3.dp, | ||||
|             ) { | ||||
|                 Row( | ||||
|                     modifier = Modifier | ||||
|                         .height(32.dp) | ||||
|                         .fillMaxWidth() | ||||
|                         .background(MaterialTheme.colorScheme.surfaceVariant) | ||||
|                         .padding(horizontal = 8.dp), | ||||
|                     verticalAlignment = Alignment.CenterVertically, | ||||
|                 ) { | ||||
|                     Box( | ||||
|                         modifier = Modifier | ||||
|                             .size(17.dp) | ||||
|                             .background( | ||||
|                                 color = MaterialTheme.colorScheme.primary, | ||||
|                                 shape = CircleShape, | ||||
|                             ), | ||||
|                     ) | ||||
|                     Box( | ||||
|                         modifier = Modifier | ||||
|                             .padding(start = 8.dp) | ||||
|                             .alpha(0.6f) | ||||
|                             .height(17.dp) | ||||
|                             .weight(1f) | ||||
|                             .background( | ||||
|                                 color = MaterialTheme.colorScheme.onSurface, | ||||
|                                 shape = RoundedCornerShape(9.dp), | ||||
|                             ), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Preview( | ||||
|     name = "light", | ||||
|     showBackground = true, | ||||
| ) | ||||
| @Preview( | ||||
|     name = "dark", | ||||
|     showBackground = true, | ||||
|     uiMode = UI_MODE_NIGHT_YES, | ||||
| ) | ||||
| @Composable | ||||
| private fun AppThemesListPreview() { | ||||
|     var appTheme by remember { mutableStateOf(AppTheme.DEFAULT) } | ||||
|     TachiyomiTheme { | ||||
|         AppThemesList( | ||||
|             currentTheme = appTheme, | ||||
|             amoled = false, | ||||
|             onItemClick = { appTheme = it }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,176 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.compose.animation.animateColorAsState | ||||
| import androidx.compose.animation.core.RepeatMode | ||||
| import androidx.compose.animation.core.StartOffset | ||||
| import androidx.compose.animation.core.StartOffsetType | ||||
| import androidx.compose.animation.core.repeatable | ||||
| import androidx.compose.animation.core.tween | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.layout.Box | ||||
| 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.padding | ||||
| import androidx.compose.foundation.layout.sizeIn | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| 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.composed | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.text.style.TextOverflow | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted | ||||
| import eu.kanade.presentation.util.secondaryItemAlpha | ||||
| import kotlinx.coroutines.delay | ||||
|  | ||||
| @Composable | ||||
| internal fun BasePreferenceWidget( | ||||
|     modifier: Modifier = Modifier, | ||||
|     title: String, | ||||
|     subtitle: String? = null, | ||||
|     icon: ImageVector? = null, | ||||
|     onClick: (() -> Unit)? = null, | ||||
|     widget: @Composable (() -> Unit)? = null, | ||||
| ) { | ||||
|     BasePreferenceWidget( | ||||
|         modifier = modifier, | ||||
|         title = title, | ||||
|         subcomponent = if (!subtitle.isNullOrBlank()) { | ||||
|             { | ||||
|                 Text( | ||||
|                     text = subtitle, | ||||
|                     modifier = Modifier | ||||
|                         .padding( | ||||
|                             start = HorizontalPadding, | ||||
|                             top = 4.dp, | ||||
|                             end = HorizontalPadding, | ||||
|                         ) | ||||
|                         .secondaryItemAlpha(), | ||||
|                     color = MaterialTheme.colorScheme.onSurface, | ||||
|                     style = MaterialTheme.typography.bodySmall, | ||||
|                 ) | ||||
|             } | ||||
|         } else { | ||||
|             null | ||||
|         }, | ||||
|         icon = icon, | ||||
|         onClick = onClick, | ||||
|         widget = widget, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| internal fun BasePreferenceWidget( | ||||
|     modifier: Modifier = Modifier, | ||||
|     title: String, | ||||
|     subcomponent: @Composable (ColumnScope.() -> Unit)? = null, | ||||
|     icon: ImageVector? = null, | ||||
|     onClick: (() -> Unit)? = null, | ||||
|     widget: @Composable (() -> Unit)? = null, | ||||
| ) { | ||||
|     BasePreferenceWidgetImpl(modifier, title, subcomponent, icon, onClick, widget) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun BasePreferenceWidgetImpl( | ||||
|     modifier: Modifier = Modifier, | ||||
|     title: String, | ||||
|     subcomponent: @Composable (ColumnScope.() -> Unit)? = null, | ||||
|     icon: ImageVector? = null, | ||||
|     onClick: (() -> Unit)? = null, | ||||
|     widget: @Composable (() -> Unit)? = null, | ||||
| ) { | ||||
|     val highlighted = LocalPreferenceHighlighted.current | ||||
|     Box(modifier = Modifier.highlightBackground(highlighted)) { | ||||
|         Row( | ||||
|             modifier = modifier | ||||
|                 .sizeIn(minHeight = 56.dp) | ||||
|                 .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) | ||||
|                 .fillMaxWidth(), | ||||
|             verticalAlignment = Alignment.CenterVertically, | ||||
|         ) { | ||||
|             if (icon != null) { | ||||
|                 Icon( | ||||
|                     imageVector = icon, | ||||
|                     contentDescription = null, | ||||
|                     modifier = Modifier | ||||
|                         .padding(start = HorizontalPadding, end = 12.dp) | ||||
|                         .secondaryItemAlpha(), | ||||
|                     tint = MaterialTheme.colorScheme.onSurface, | ||||
|                 ) | ||||
|             } | ||||
|             Column( | ||||
|                 modifier = Modifier | ||||
|                     .weight(1f) | ||||
|                     .padding(vertical = 14.dp), | ||||
|             ) { | ||||
|                 if (title.isNotBlank()) { | ||||
|                     Row( | ||||
|                         modifier = Modifier.padding(horizontal = HorizontalPadding), | ||||
|                         verticalAlignment = Alignment.CenterVertically, | ||||
|                     ) { | ||||
|                         Text( | ||||
|                             text = title, | ||||
|                             overflow = TextOverflow.Ellipsis, | ||||
|                             maxLines = 2, | ||||
|                             style = MaterialTheme.typography.bodyLarge, | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|                 subcomponent?.invoke(this) | ||||
|             } | ||||
|             if (widget != null) { | ||||
|                 Box(modifier = Modifier.padding(end = HorizontalPadding)) { | ||||
|                     widget() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = composed { | ||||
|     var highlightFlag by remember { mutableStateOf(false) } | ||||
|     LaunchedEffect(Unit) { | ||||
|         if (highlighted) { | ||||
|             highlightFlag = true | ||||
|             delay(3000) | ||||
|             highlightFlag = false | ||||
|         } | ||||
|     } | ||||
|     val highlight by animateColorAsState( | ||||
|         targetValue = if (highlightFlag) { | ||||
|             MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f) | ||||
|         } else { | ||||
|             Color.Transparent | ||||
|         }, | ||||
|         animationSpec = if (highlightFlag) { | ||||
|             repeatable( | ||||
|                 iterations = 5, | ||||
|                 animation = tween(durationMillis = 200), | ||||
|                 repeatMode = RepeatMode.Reverse, | ||||
|                 initialStartOffset = StartOffset( | ||||
|                     offsetMillis = 600, | ||||
|                     offsetType = StartOffsetType.Delay, | ||||
|                 ), | ||||
|             ) | ||||
|         } else { | ||||
|             tween(200) | ||||
|         }, | ||||
|     ) | ||||
|     then(Modifier.background(color = highlight)) | ||||
| } | ||||
|  | ||||
| internal val TrailingWidgetBuffer = 16.dp | ||||
| internal val HorizontalPadding = 16.dp | ||||
| @@ -0,0 +1,79 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.OutlinedTextField | ||||
| 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.rememberCoroutineScope | ||||
| import androidx.compose.runtime.saveable.rememberSaveable | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.input.TextFieldValue | ||||
| import androidx.compose.ui.window.DialogProperties | ||||
| import kotlinx.coroutines.launch | ||||
|  | ||||
| @Composable | ||||
| fun EditTextPreferenceWidget( | ||||
|     title: String, | ||||
|     subtitle: String?, | ||||
|     icon: ImageVector?, | ||||
|     value: String, | ||||
|     onConfirm: suspend (String) -> Boolean, | ||||
| ) { | ||||
|     val (isDialogShown, showDialog) = remember { mutableStateOf(false) } | ||||
|  | ||||
|     TextPreferenceWidget( | ||||
|         title = title, | ||||
|         subtitle = subtitle?.format(value), | ||||
|         icon = icon, | ||||
|         onPreferenceClick = { showDialog(true) }, | ||||
|     ) | ||||
|  | ||||
|     if (isDialogShown) { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val onDismissRequest = { showDialog(false) } | ||||
|         var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { | ||||
|             mutableStateOf(TextFieldValue(value)) | ||||
|         } | ||||
|         AlertDialog( | ||||
|             onDismissRequest = onDismissRequest, | ||||
|             title = { Text(text = title) }, | ||||
|             text = { | ||||
|                 OutlinedTextField( | ||||
|                     value = textFieldValue, | ||||
|                     onValueChange = { textFieldValue = it }, | ||||
|                     singleLine = true, | ||||
|                     modifier = Modifier.fillMaxWidth(), | ||||
|                 ) | ||||
|             }, | ||||
|             properties = DialogProperties( | ||||
|                 usePlatformDefaultWidth = true, | ||||
|             ), | ||||
|             confirmButton = { | ||||
|                 TextButton( | ||||
|                     onClick = { | ||||
|                         scope.launch { | ||||
|                             if (onConfirm(textFieldValue.text)) { | ||||
|                                 onDismissRequest() | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 ) { | ||||
|                     Text(text = stringResource(id = android.R.string.ok)) | ||||
|                 } | ||||
|             }, | ||||
|             dismissButton = { | ||||
|                 TextButton(onClick = onDismissRequest) { | ||||
|                     Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,105 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| 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.lazy.rememberLazyListState | ||||
| import androidx.compose.foundation.selection.selectable | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.RadioButton | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.presentation.components.Divider | ||||
| import eu.kanade.presentation.components.ScrollbarLazyColumn | ||||
| import eu.kanade.presentation.util.isScrolledToEnd | ||||
| import eu.kanade.presentation.util.isScrolledToStart | ||||
|  | ||||
| @Composable | ||||
| fun <T> ListPreferenceWidget( | ||||
|     value: T, | ||||
|     title: String, | ||||
|     subtitle: String?, | ||||
|     icon: ImageVector?, | ||||
|     entries: Map<out T, String>, | ||||
|     onValueChange: (T) -> Unit, | ||||
| ) { | ||||
|     val (isDialogShown, showDialog) = remember { mutableStateOf(false) } | ||||
|  | ||||
|     TextPreferenceWidget( | ||||
|         title = title, | ||||
|         subtitle = subtitle?.format(entries[value]), | ||||
|         icon = icon, | ||||
|         onPreferenceClick = { showDialog(true) }, | ||||
|     ) | ||||
|  | ||||
|     if (isDialogShown) { | ||||
|         AlertDialog( | ||||
|             onDismissRequest = { showDialog(false) }, | ||||
|             title = { Text(text = title) }, | ||||
|             text = { | ||||
|                 Box { | ||||
|                     val state = rememberLazyListState() | ||||
|                     ScrollbarLazyColumn(state = state) { | ||||
|                         entries.forEach { current -> | ||||
|                             val isSelected = value == current.key | ||||
|                             item { | ||||
|                                 DialogRow( | ||||
|                                     label = current.value, | ||||
|                                     isSelected = isSelected, | ||||
|                                     onSelected = { | ||||
|                                         onValueChange(current.key!!) | ||||
|                                         showDialog(false) | ||||
|                                     }, | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                     if (!state.isScrolledToStart()) Divider(modifier = Modifier.align(Alignment.TopCenter)) | ||||
|                     if (!state.isScrolledToEnd()) Divider(modifier = Modifier.align(Alignment.BottomCenter)) | ||||
|                 } | ||||
|             }, | ||||
|             confirmButton = { | ||||
|                 TextButton(onClick = { showDialog(false) }) { | ||||
|                     Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun DialogRow( | ||||
|     label: String, | ||||
|     isSelected: Boolean, | ||||
|     onSelected: () -> Unit, | ||||
| ) { | ||||
|     Row( | ||||
|         verticalAlignment = Alignment.CenterVertically, | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .selectable( | ||||
|                 selected = isSelected, | ||||
|                 onClick = { if (!isSelected) onSelected() }, | ||||
|             ), | ||||
|     ) { | ||||
|         RadioButton( | ||||
|             selected = isSelected, | ||||
|             onClick = { if (!isSelected) onSelected() }, | ||||
|         ) | ||||
|         Text( | ||||
|             text = label, | ||||
|             style = MaterialTheme.typography.bodyLarge.merge(), | ||||
|             modifier = Modifier.padding(start = 12.dp), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,99 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.Checkbox | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.toMutableStateList | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.window.DialogProperties | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
|  | ||||
| @Composable | ||||
| fun MultiSelectListPreferenceWidget( | ||||
|     preference: Preference.PreferenceItem.MultiSelectListPreference, | ||||
|     values: Set<String>, | ||||
|     onValuesChange: (Set<String>) -> Unit, | ||||
| ) { | ||||
|     val (isDialogShown, showDialog) = remember { mutableStateOf(false) } | ||||
|  | ||||
|     TextPreferenceWidget( | ||||
|         title = preference.title, | ||||
|         subtitle = preference.subtitle, | ||||
|         icon = preference.icon, | ||||
|         onPreferenceClick = { showDialog(true) }, | ||||
|     ) | ||||
|  | ||||
|     if (isDialogShown) { | ||||
|         val selected = remember { | ||||
|             preference.entries.keys | ||||
|                 .filter { values.contains(it) } | ||||
|                 .toMutableStateList() | ||||
|         } | ||||
|         AlertDialog( | ||||
|             onDismissRequest = { showDialog(false) }, | ||||
|             title = { Text(text = preference.title) }, | ||||
|             text = { | ||||
|                 LazyColumn { | ||||
|                     preference.entries.forEach { current -> | ||||
|                         item { | ||||
|                             val isSelected = selected.contains(current.key) | ||||
|                             val onSelectionChanged = { | ||||
|                                 when (!isSelected) { | ||||
|                                     true -> selected.add(current.key) | ||||
|                                     false -> selected.remove(current.key) | ||||
|                                 } | ||||
|                             } | ||||
|                             Row( | ||||
|                                 verticalAlignment = Alignment.CenterVertically, | ||||
|                                 modifier = Modifier | ||||
|                                     .fillMaxWidth() | ||||
|                                     .clickable { onSelectionChanged() }, | ||||
|                             ) { | ||||
|                                 Checkbox( | ||||
|                                     checked = isSelected, | ||||
|                                     onCheckedChange = { onSelectionChanged() }, | ||||
|                                 ) | ||||
|                                 Text( | ||||
|                                     text = current.value, | ||||
|                                     style = MaterialTheme.typography.bodyMedium, | ||||
|                                     modifier = Modifier.padding(start = 12.dp), | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             properties = DialogProperties( | ||||
|                 usePlatformDefaultWidth = true, | ||||
|             ), | ||||
|             confirmButton = { | ||||
|                 TextButton( | ||||
|                     onClick = { | ||||
|                         onValuesChange(selected.toMutableSet()) | ||||
|                         showDialog(false) | ||||
|                     }, | ||||
|                 ) { | ||||
|                     Text(text = stringResource(id = android.R.string.ok)) | ||||
|                 } | ||||
|             }, | ||||
|             dismissButton = { | ||||
|                 TextButton(onClick = { showDialog(false) }) { | ||||
|                     Text(text = stringResource(id = android.R.string.cancel)) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.unit.dp | ||||
|  | ||||
| @Composable | ||||
| fun PreferenceGroupHeader(title: String) { | ||||
|     Box( | ||||
|         contentAlignment = Alignment.CenterStart, | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .padding(bottom = 8.dp, top = 14.dp), | ||||
|     ) { | ||||
|         Text( | ||||
|             text = title, | ||||
|             color = MaterialTheme.colorScheme.secondary, | ||||
|             modifier = Modifier.padding(horizontal = 16.dp), | ||||
|             style = MaterialTheme.typography.bodyMedium, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.Preview | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Surface | ||||
| import androidx.compose.material3.Switch | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
|  | ||||
| @Composable | ||||
| fun SwitchPreferenceWidget( | ||||
|     title: String, | ||||
|     subtitle: String? = null, | ||||
|     icon: ImageVector? = null, | ||||
|     checked: Boolean = false, | ||||
|     onCheckedChanged: (Boolean) -> Unit, | ||||
| ) { | ||||
|     BasePreferenceWidget( | ||||
|         title = title, | ||||
|         subtitle = subtitle, | ||||
|         icon = icon, | ||||
|         onClick = { onCheckedChanged(!checked) }, | ||||
|     ) { | ||||
|         Switch( | ||||
|             checked = checked, | ||||
|             onCheckedChange = null, | ||||
|             modifier = Modifier.padding(start = TrailingWidgetBuffer), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Preview | ||||
| @Composable | ||||
| fun SwitchPreferenceWidgetPreview() { | ||||
|     MaterialTheme { | ||||
|         Surface { | ||||
|             Column { | ||||
|                 SwitchPreferenceWidget( | ||||
|                     title = "Text preference with icon", | ||||
|                     subtitle = "Text preference summary", | ||||
|                     icon = Icons.Default.Preview, | ||||
|                     checked = true, | ||||
|                     onCheckedChanged = {}, | ||||
|                 ) | ||||
|                 SwitchPreferenceWidget( | ||||
|                     title = "Text preference", | ||||
|                     subtitle = "Text preference summary", | ||||
|                     checked = false, | ||||
|                     onCheckedChanged = {}, | ||||
|                 ) | ||||
|                 SwitchPreferenceWidget( | ||||
|                     title = "Text preference no summary", | ||||
|                     checked = false, | ||||
|                     onCheckedChanged = {}, | ||||
|                 ) | ||||
|                 SwitchPreferenceWidget( | ||||
|                     title = "Another text preference no summary", | ||||
|                     checked = false, | ||||
|                     onCheckedChanged = {}, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,50 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.Preview | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Surface | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
|  | ||||
| @Composable | ||||
| fun TextPreferenceWidget( | ||||
|     title: String, | ||||
|     subtitle: String? = null, | ||||
|     icon: ImageVector? = null, | ||||
|     onPreferenceClick: (() -> Unit)? = null, | ||||
| ) { | ||||
|     // TODO: Handle auth requirement here? | ||||
|     BasePreferenceWidget( | ||||
|         title = title, | ||||
|         subtitle = subtitle, | ||||
|         icon = icon, | ||||
|         onClick = onPreferenceClick, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Preview | ||||
| @Composable | ||||
| fun TextPreferenceWidgetPreview() { | ||||
|     MaterialTheme { | ||||
|         Surface { | ||||
|             Column { | ||||
|                 TextPreferenceWidget( | ||||
|                     title = "Text preference with icon", | ||||
|                     subtitle = "Text preference summary", | ||||
|                     icon = Icons.Default.Preview, | ||||
|                     onPreferenceClick = {}, | ||||
|                 ) | ||||
|                 TextPreferenceWidget( | ||||
|                     title = "Text preference", | ||||
|                     subtitle = "Text preference summary", | ||||
|                     onPreferenceClick = {}, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,77 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.annotation.ColorInt | ||||
| import androidx.annotation.DrawableRes | ||||
| import androidx.compose.foundation.Image | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.clickable | ||||
| 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.RoundedCornerShape | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.Check | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted | ||||
|  | ||||
| @Composable | ||||
| fun TrackingPreferenceWidget( | ||||
|     modifier: Modifier = Modifier, | ||||
|     title: String, | ||||
|     @DrawableRes logoRes: Int, | ||||
|     @ColorInt logoColor: Int, | ||||
|     checked: Boolean, | ||||
|     onClick: (() -> Unit)? = null, | ||||
| ) { | ||||
|     val highlighted = LocalPreferenceHighlighted.current | ||||
|     Box(modifier = Modifier.highlightBackground(highlighted)) { | ||||
|         Row( | ||||
|             modifier = modifier | ||||
|                 .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) | ||||
|                 .fillMaxWidth() | ||||
|                 .padding(horizontal = 16.dp, vertical = 8.dp), | ||||
|             verticalAlignment = Alignment.CenterVertically, | ||||
|         ) { | ||||
|             Box( | ||||
|                 modifier = Modifier | ||||
|                     .size(48.dp) | ||||
|                     .background(color = Color(logoColor), shape = RoundedCornerShape(8.dp)) | ||||
|                     .padding(4.dp), | ||||
|                 contentAlignment = Alignment.Center, | ||||
|             ) { | ||||
|                 Image( | ||||
|                     painter = painterResource(id = logoRes), | ||||
|                     contentDescription = null, | ||||
|                 ) | ||||
|             } | ||||
|             Text( | ||||
|                 text = title, | ||||
|                 modifier = Modifier | ||||
|                     .weight(1f) | ||||
|                     .padding(horizontal = 16.dp), | ||||
|                 maxLines = 1, | ||||
|                 style = MaterialTheme.typography.titleMedium, | ||||
|             ) | ||||
|             if (checked) { | ||||
|                 Icon( | ||||
|                     imageVector = Icons.Default.Check, | ||||
|                     modifier = Modifier | ||||
|                         .padding(4.dp) | ||||
|                         .size(32.dp), | ||||
|                     tint = Color(0xFF4CAF50), | ||||
|                     contentDescription = null, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,139 @@ | ||||
| package eu.kanade.presentation.more.settings.widget | ||||
|  | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.defaultMinSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.itemsIndexed | ||||
| import androidx.compose.foundation.lazy.rememberLazyListState | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.rounded.CheckBox | ||||
| import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank | ||||
| import androidx.compose.material.icons.rounded.DisabledByDefault | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.LocalContentColor | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.toMutableStateList | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.presentation.components.Divider | ||||
| import eu.kanade.presentation.components.LazyColumn | ||||
| import eu.kanade.presentation.util.isScrolledToEnd | ||||
| import eu.kanade.presentation.util.isScrolledToStart | ||||
| import eu.kanade.tachiyomi.R | ||||
|  | ||||
| private enum class State { | ||||
|     CHECKED, INVERSED, UNCHECKED | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun <T> TriStateListDialog( | ||||
|     title: String, | ||||
|     message: String? = null, | ||||
|     items: List<T>, | ||||
|     initialChecked: List<T>, | ||||
|     initialInversed: List<T>, | ||||
|     itemLabel: @Composable (T) -> String, | ||||
|     onDismissRequest: () -> Unit, | ||||
|     onValueChanged: (newIncluded: List<T>, newExcluded: List<T>) -> Unit, | ||||
| ) { | ||||
|     val selected = remember { | ||||
|         items | ||||
|             .map { | ||||
|                 when (it) { | ||||
|                     in initialChecked -> State.CHECKED | ||||
|                     in initialInversed -> State.INVERSED | ||||
|                     else -> State.UNCHECKED | ||||
|                 } | ||||
|             } | ||||
|             .toMutableStateList() | ||||
|     } | ||||
|     AlertDialog( | ||||
|         onDismissRequest = onDismissRequest, | ||||
|         title = { Text(text = title) }, | ||||
|         text = { | ||||
|             Column { | ||||
|                 if (message != null) { | ||||
|                     Text( | ||||
|                         text = message, | ||||
|                         modifier = Modifier.padding(bottom = 8.dp), | ||||
|                     ) | ||||
|                 } | ||||
|  | ||||
|                 Box { | ||||
|                     val listState = rememberLazyListState() | ||||
|                     LazyColumn(state = listState) { | ||||
|                         itemsIndexed(items = items) { index, item -> | ||||
|                             val state = selected[index] | ||||
|                             Row( | ||||
|                                 modifier = Modifier | ||||
|                                     .clip(RoundedCornerShape(25)) | ||||
|                                     .clickable { | ||||
|                                         selected[index] = when (state) { | ||||
|                                             State.UNCHECKED -> State.CHECKED | ||||
|                                             State.CHECKED -> State.INVERSED | ||||
|                                             State.INVERSED -> State.UNCHECKED | ||||
|                                         } | ||||
|                                     } | ||||
|                                     .defaultMinSize(minHeight = 48.dp) | ||||
|                                     .fillMaxWidth(), | ||||
|                                 verticalAlignment = Alignment.CenterVertically, | ||||
|                             ) { | ||||
|                                 Icon( | ||||
|                                     modifier = Modifier.padding(end = 20.dp), | ||||
|                                     imageVector = when (state) { | ||||
|                                         State.UNCHECKED -> Icons.Rounded.CheckBoxOutlineBlank | ||||
|                                         State.CHECKED -> Icons.Rounded.CheckBox | ||||
|                                         State.INVERSED -> Icons.Rounded.DisabledByDefault | ||||
|                                     }, | ||||
|                                     tint = if (state == State.UNCHECKED) { | ||||
|                                         LocalContentColor.current | ||||
|                                     } else { | ||||
|                                         MaterialTheme.colorScheme.primary | ||||
|                                     }, | ||||
|                                     contentDescription = null, | ||||
|                                 ) | ||||
|                                 Text(text = itemLabel(item)) | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     if (!listState.isScrolledToStart()) Divider(modifier = Modifier.align(Alignment.TopCenter)) | ||||
|                     if (!listState.isScrolledToEnd()) Divider(modifier = Modifier.align(Alignment.BottomCenter)) | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         dismissButton = { | ||||
|             TextButton(onClick = onDismissRequest) { | ||||
|                 Text(text = stringResource(id = android.R.string.cancel)) | ||||
|             } | ||||
|         }, | ||||
|         confirmButton = { | ||||
|             TextButton( | ||||
|                 onClick = { | ||||
|                     val included = items.mapIndexedNotNull { index, category -> | ||||
|                         if (selected[index] == State.CHECKED) category else null | ||||
|                     } | ||||
|                     val excluded = items.mapIndexedNotNull { index, category -> | ||||
|                         if (selected[index] == State.INVERSED) category else null | ||||
|                     } | ||||
|                     onValueChanged(included, excluded) | ||||
|                 }, | ||||
|             ) { | ||||
|                 Text(text = stringResource(id = android.R.string.ok)) | ||||
|             } | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
| @@ -1,10 +1,15 @@ | ||||
| package eu.kanade.presentation.theme | ||||
|  | ||||
| import androidx.appcompat.view.ContextThemeWrapper | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.platform.LocalLayoutDirection | ||||
| import com.google.android.material.composethemeadapter3.createMdc3Theme | ||||
| import eu.kanade.domain.ui.model.AppTheme | ||||
| import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegate | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| @Composable | ||||
| fun TachiyomiTheme(content: @Composable () -> Unit) { | ||||
| @@ -22,3 +27,29 @@ fun TachiyomiTheme(content: @Composable () -> Unit) { | ||||
|         content = content, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun TachiyomiTheme( | ||||
|     appTheme: AppTheme, | ||||
|     amoled: Boolean, | ||||
|     content: @Composable () -> Unit, | ||||
| ) { | ||||
|     val originalContext = LocalContext.current | ||||
|     val layoutDirection = LocalLayoutDirection.current | ||||
|     val themedContext = remember(appTheme, originalContext) { | ||||
|         val themeResIds = ThemingDelegate.getThemeResIds(appTheme, amoled) | ||||
|         themeResIds.fold(originalContext) { context, themeResId -> | ||||
|             ContextThemeWrapper(context, themeResId) | ||||
|         } | ||||
|     } | ||||
|     val (colorScheme, typography) = createMdc3Theme( | ||||
|         context = themedContext, | ||||
|         layoutDirection = layoutDirection, | ||||
|     ) | ||||
|  | ||||
|     MaterialTheme( | ||||
|         colorScheme = colorScheme!!, | ||||
|         typography = typography!!, | ||||
|         content = content, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,16 @@ import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.setValue | ||||
|  | ||||
| @Composable | ||||
| fun LazyListState.isScrolledToStart(): Boolean { | ||||
|     return remember { | ||||
|         derivedStateOf { | ||||
|             val firstItem = layoutInfo.visibleItemsInfo.firstOrNull() | ||||
|             firstItem == null || firstItem.offset == layoutInfo.viewportStartOffset | ||||
|         } | ||||
|     }.value | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun LazyListState.isScrolledToEnd(): Boolean { | ||||
|     return remember { | ||||
|   | ||||
							
								
								
									
										15
									
								
								app/src/main/java/eu/kanade/presentation/util/Navigator.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/src/main/java/eu/kanade/presentation/util/Navigator.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| package eu.kanade.presentation.util | ||||
|  | ||||
| import androidx.compose.runtime.ProvidableCompositionLocal | ||||
| import androidx.compose.runtime.staticCompositionLocalOf | ||||
| import com.bluelinelabs.conductor.Router | ||||
|  | ||||
| /** | ||||
|  * For interop with Conductor | ||||
|  */ | ||||
| val LocalRouter: ProvidableCompositionLocal<Router?> = staticCompositionLocalOf { null } | ||||
|  | ||||
| /** | ||||
|  * For invoking back press to the parent activity | ||||
|  */ | ||||
| val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null } | ||||
							
								
								
									
										13
									
								
								app/src/main/java/eu/kanade/presentation/util/Preference.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/src/main/java/eu/kanade/presentation/util/Preference.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| package eu.kanade.presentation.util | ||||
|  | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.remember | ||||
| import eu.kanade.tachiyomi.core.preference.Preference | ||||
|  | ||||
| @Composable | ||||
| fun <T> Preference<T>.collectAsState(): State<T> { | ||||
|     val flow = remember(this) { changes() } | ||||
|     return flow.collectAsState(initial = get()) | ||||
| } | ||||
| @@ -56,6 +56,17 @@ abstract class BasicFullComposeController(bundle: Bundle? = null) : | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Let Compose view handle this | ||||
|     override fun handleBack(): Boolean { | ||||
|         val dispatcher = (activity as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher ?: return false | ||||
|         return if (dispatcher.hasEnabledCallbacks()) { | ||||
|             dispatcher.onBackPressed() | ||||
|             true | ||||
|         } else { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| interface ComposeContentController { | ||||
|   | ||||
| @@ -7,7 +7,6 @@ import eu.kanade.tachiyomi.ui.base.controller.RootController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.pushController | ||||
| import eu.kanade.tachiyomi.ui.category.CategoryController | ||||
| import eu.kanade.tachiyomi.ui.download.DownloadController | ||||
| import eu.kanade.tachiyomi.ui.setting.SettingsBackupController | ||||
| import eu.kanade.tachiyomi.ui.setting.SettingsMainController | ||||
|  | ||||
| class MoreController : | ||||
| @@ -22,7 +21,7 @@ class MoreController : | ||||
|             presenter = presenter, | ||||
|             onClickDownloadQueue = { router.pushController(DownloadController()) }, | ||||
|             onClickCategories = { router.pushController(CategoryController()) }, | ||||
|             onClickBackupAndRestore = { router.pushController(SettingsBackupController()) }, | ||||
|             onClickBackupAndRestore = { router.pushController(SettingsMainController(toBackupScreen = true)) }, | ||||
|             onClickSettings = { router.pushController(SettingsMainController()) }, | ||||
|             onClickAbout = { router.pushController(AboutController()) }, | ||||
|         ) | ||||
|   | ||||
| @@ -1,85 +1,49 @@ | ||||
| package eu.kanade.tachiyomi.ui.setting | ||||
|  | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.ChromeReaderMode | ||||
| import androidx.compose.material.icons.outlined.Code | ||||
| import androidx.compose.material.icons.outlined.GetApp | ||||
| import androidx.compose.material.icons.outlined.Palette | ||||
| import androidx.compose.material.icons.outlined.Security | ||||
| import androidx.compose.material.icons.outlined.SettingsBackupRestore | ||||
| import androidx.compose.material.icons.outlined.Sync | ||||
| import androidx.compose.material.icons.outlined.Tune | ||||
| import android.os.Bundle | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.graphics.vector.rememberVectorPainter | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import eu.kanade.presentation.more.settings.SettingsMainScreen | ||||
| import eu.kanade.presentation.more.settings.SettingsSection | ||||
| import eu.kanade.tachiyomi.R | ||||
| import androidx.compose.runtime.CompositionLocalProvider | ||||
| import androidx.core.os.bundleOf | ||||
| import cafe.adriel.voyager.core.stack.StackEvent | ||||
| import cafe.adriel.voyager.navigator.Navigator | ||||
| import cafe.adriel.voyager.transitions.ScreenTransition | ||||
| import eu.kanade.presentation.more.settings.screen.SettingsBackupScreen | ||||
| import eu.kanade.presentation.more.settings.screen.SettingsMainScreen | ||||
| import eu.kanade.presentation.util.LocalBackPress | ||||
| import eu.kanade.presentation.util.LocalRouter | ||||
| import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.pushController | ||||
| import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchController | ||||
| import soup.compose.material.motion.animation.materialSharedAxisZ | ||||
|  | ||||
| class SettingsMainController : BasicFullComposeController() { | ||||
| class SettingsMainController : BasicFullComposeController { | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : this(bundle.getBoolean(TO_BACKUP_SCREEN)) | ||||
|  | ||||
|     constructor(toBackupScreen: Boolean = false) : super(bundleOf(TO_BACKUP_SCREEN to toBackupScreen)) | ||||
|  | ||||
|     private val toBackupScreen = args.getBoolean(TO_BACKUP_SCREEN) | ||||
|  | ||||
|     @Composable | ||||
|     override fun ComposeContent() { | ||||
|         val settingsSections = listOf( | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_general, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.Tune), | ||||
|                 onClick = { router.pushController(SettingsGeneralController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_appearance, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.Palette), | ||||
|                 onClick = { router.pushController(SettingsAppearanceController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_library, | ||||
|                 painter = painterResource(R.drawable.ic_library_outline_24dp), | ||||
|                 onClick = { router.pushController(SettingsLibraryController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_reader, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.ChromeReaderMode), | ||||
|                 onClick = { router.pushController(SettingsReaderController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_downloads, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.GetApp), | ||||
|                 onClick = { router.pushController(SettingsDownloadController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_tracking, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.Sync), | ||||
|                 onClick = { router.pushController(SettingsTrackingController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.browse, | ||||
|                 painter = painterResource(R.drawable.ic_browse_outline_24dp), | ||||
|                 onClick = { router.pushController(SettingsBrowseController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.label_backup, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.SettingsBackupRestore), | ||||
|                 onClick = { router.pushController(SettingsBackupController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_security, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.Security), | ||||
|                 onClick = { router.pushController(SettingsSecurityController()) }, | ||||
|             ), | ||||
|             SettingsSection( | ||||
|                 titleRes = R.string.pref_category_advanced, | ||||
|                 painter = rememberVectorPainter(Icons.Outlined.Code), | ||||
|                 onClick = { router.pushController(SettingsAdvancedController()) }, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         SettingsMainScreen( | ||||
|             navigateUp = router::popCurrentController, | ||||
|             sections = settingsSections, | ||||
|             onClickSearch = { router.pushController(SettingsSearchController()) }, | ||||
|         Navigator( | ||||
|             screen = if (toBackupScreen) SettingsBackupScreen() else SettingsMainScreen, | ||||
|             content = { | ||||
|                 CompositionLocalProvider( | ||||
|                     LocalRouter provides router, | ||||
|                     LocalBackPress provides this::back, | ||||
|                 ) { | ||||
|                     ScreenTransition( | ||||
|                         navigator = it, | ||||
|                         transition = { materialSharedAxisZ(forward = it.lastEvent != StackEvent.Pop) }, | ||||
|                     ) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private fun back() { | ||||
|         activity?.onBackPressed() | ||||
|     } | ||||
| } | ||||
|  | ||||
| private const val TO_BACKUP_SCREEN = "to_backup_screen" | ||||
|   | ||||
| @@ -10,6 +10,9 @@ import androidx.biometric.auth.AuthPromptCallback | ||||
| import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import eu.kanade.tachiyomi.R | ||||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||||
| import kotlin.coroutines.resume | ||||
|  | ||||
| object AuthenticatorUtil { | ||||
|  | ||||
| @@ -43,6 +46,45 @@ object AuthenticatorUtil { | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     suspend fun FragmentActivity.authenticate( | ||||
|         title: String, | ||||
|         subtitle: String? = getString(R.string.confirm_lock_change), | ||||
|     ): Boolean = suspendCancellableCoroutine { cont -> | ||||
|         if (!isAuthenticationSupported()) { | ||||
|             cont.resume(true) | ||||
|             return@suspendCancellableCoroutine | ||||
|         } | ||||
|  | ||||
|         startAuthentication( | ||||
|             title, | ||||
|             subtitle, | ||||
|             callback = object : AuthenticationCallback() { | ||||
|                 override fun onAuthenticationSucceeded( | ||||
|                     activity: FragmentActivity?, | ||||
|                     result: BiometricPrompt.AuthenticationResult, | ||||
|                 ) { | ||||
|                     super.onAuthenticationSucceeded(activity, result) | ||||
|                     cont.resume(true) | ||||
|                 } | ||||
|  | ||||
|                 override fun onAuthenticationError( | ||||
|                     activity: FragmentActivity?, | ||||
|                     errorCode: Int, | ||||
|                     errString: CharSequence, | ||||
|                 ) { | ||||
|                     super.onAuthenticationError(activity, errorCode, errString) | ||||
|                     activity?.toast(errString.toString()) | ||||
|                     cont.resume(false) | ||||
|                 } | ||||
|  | ||||
|                 override fun onAuthenticationFailed(activity: FragmentActivity?) { | ||||
|                     super.onAuthenticationFailed(activity) | ||||
|                     cont.resume(false) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns true if Class 2 biometric or credential lock is set and available to use | ||||
|      */ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user