From 890f1a3c7b0f2ceefa86a77cb40c11567e2b5a46 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Sat, 15 Oct 2022 22:38:01 +0700 Subject: [PATCH] Full Compose settings (#8201) * Uses Voyager for navigation. * Replaces every screen inside settings except category editor screen since it's called from several places. --- app/build.gradle.kts | 12 +- .../eu/kanade/domain/base/BasePreferences.kt | 2 + .../more/settings/PreferenceItem.kt | 168 ++++++++ .../more/settings/PreferenceModel.kt | 146 +++++++ .../more/settings/PreferenceScaffold.kt | 31 ++ .../more/settings/PreferenceScreen.kt | 100 +++++ .../settings/screen/ClearDatabaseScreen.kt | 218 ++++++++++ .../more/settings/screen/Commons.kt | 47 +++ .../settings/screen/SearchableSettings.kt | 42 ++ .../settings/screen/SettingsAdvancedScreen.kt | 398 ++++++++++++++++++ .../screen/SettingsAppearanceScreen.kt | 142 +++++++ .../settings/screen/SettingsBackupScreen.kt | 370 ++++++++++++++++ .../settings/screen/SettingsBrowseScreen.kt | 79 ++++ .../settings/screen/SettingsDownloadScreen.kt | 269 ++++++++++++ .../settings/screen/SettingsGeneralScreen.kt | 108 +++++ .../settings/screen/SettingsLibraryScreen.kt | 360 ++++++++++++++++ .../settings/screen/SettingsMainScreen.kt | 112 +++++ .../settings/screen/SettingsReaderScreen.kt | 312 ++++++++++++++ .../settings/screen/SettingsSearchScreen.kt | 303 +++++++++++++ .../settings/screen/SettingsSecurityScreen.kt | 89 ++++ .../settings/screen/SettingsTrackingScreen.kt | 336 +++++++++++++++ .../widget/AppThemePreferenceWidget.kt | 270 ++++++++++++ .../settings/widget/BasePreferenceWidget.kt | 176 ++++++++ .../widget/EditTextPreferenceWidget.kt | 79 ++++ .../settings/widget/ListPreferenceWidget.kt | 105 +++++ .../widget/MultiSelectListPreferenceWidget.kt | 99 +++++ .../settings/widget/PreferenceGroupHeader.kt | 28 ++ .../settings/widget/SwitchPreferenceWidget.kt | 69 +++ .../settings/widget/TextPreferenceWidget.kt | 50 +++ .../widget/TrackingPreferenceWidget.kt | 77 ++++ .../settings/widget/TriStateListDialog.kt | 139 ++++++ .../presentation/theme/TachiyomiTheme.kt | 31 ++ .../kanade/presentation/util/LazyListState.kt | 10 + .../eu/kanade/presentation/util/Navigator.kt | 15 + .../eu/kanade/presentation/util/Preference.kt | 13 + .../ui/base/controller/ComposeController.kt | 11 + .../tachiyomi/ui/more/MoreController.kt | 3 +- .../ui/setting/SettingsMainController.kt | 112 ++--- .../util/system/AuthenticatorUtil.kt | 42 ++ .../kanade/tachiyomi/network/NetworkHelper.kt | 2 +- gradle/compose.versions.toml | 1 + gradle/libs.versions.toml | 8 + 42 files changed, 4904 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/PreferenceModel.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScaffold.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearDatabaseScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/Commons.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SearchableSettings.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/AppThemePreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/BasePreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/EditTextPreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/ListPreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/MultiSelectListPreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/PreferenceGroupHeader.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/SwitchPreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/TextPreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/TrackingPreferenceWidget.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/widget/TriStateListDialog.kt create mode 100644 app/src/main/java/eu/kanade/presentation/util/Navigator.kt create mode 100644 app/src/main/java/eu/kanade/presentation/util/Preference.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0cfb94316..3f45e2c22e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -141,12 +141,12 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_11.toString() } sqldelight { @@ -178,6 +178,7 @@ dependencies { implementation(compose.accompanist.flowlayout) implementation(compose.accompanist.pager.core) implementation(compose.accompanist.pager.indicators) + implementation(compose.accompanist.permissions) implementation(androidx.paging.runtime) implementation(androidx.paging.compose) @@ -264,6 +265,9 @@ dependencies { implementation(libs.markwon) implementation(libs.aboutLibraries.compose) implementation(libs.cascade) + implementation(libs.numberpicker) + implementation(libs.bundles.voyager) + implementation(libs.materialmotion.core) // Conductor implementation(libs.bundles.conductor) @@ -315,10 +319,12 @@ tasks { kotlinOptions.freeCompilerArgs += listOf( "-opt-in=coil.annotation.ExperimentalCoilApi", "-opt-in=com.google.accompanist.pager.ExperimentalPagerApi", + "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi", "-opt-in=androidx.compose.material.ExperimentalMaterialApi", "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", + "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index 5bcc2cbbad..3219619859 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -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) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt new file mode 100644 index 0000000000..f4af9996e8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt @@ -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().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() + .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() }, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceModel.kt b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceModel.kt new file mode 100644 index 0000000000..9c8337ab12 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceModel.kt @@ -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 : 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() + + /** + * A [PreferenceItem] that provides a two-state toggleable option. + */ + data class SwitchPreference( + val pref: PreferenceData, + 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() + + /** + * A [PreferenceItem] that displays a list of entries as a dialog. + */ + @Suppress("UNCHECKED_CAST") + data class ListPreference( + val pref: PreferenceData, + 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, + ) : PreferenceItem() { + 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, + ) : PreferenceItem() + + /** + * 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>, + 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) -> Boolean = { true }, + + val entries: Map, + ) : PreferenceItem>() + + /** + * A [PreferenceItem] that shows a EditText in the dialog. + */ + data class EditTextPreference( + val pref: PreferenceData, + 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() + + /** + * A [PreferenceItem] that shows previews of [AppTheme] selection. + */ + data class AppThemePreference( + val pref: PreferenceData, + override val title: String, + ) : PreferenceItem() { + 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() { + 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>, + ) : Preference() + + companion object { + fun infoPreference(info: String) = PreferenceItem.TextPreference( + title = "", + subtitle = info, + icon = Icons.Outlined.Info, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScaffold.kt b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScaffold.kt new file mode 100644 index 0000000000..66ced3c445 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScaffold.kt @@ -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, +) { + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = title, + navigateUp = onBackPressed, + actions = actions, + scrollBehavior = scrollBehavior, + ) + }, + content = { contentPadding -> + PreferenceScreen( + items = itemsProvider(), + contentPadding = contentPadding, + ) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScreen.kt new file mode 100644 index 0000000000..629c062bf5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScreen.kt @@ -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, + 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.findHighlightedIndex(highlightKey: String): Int { + return flatMap { + if (it is Preference.PreferenceGroup) { + mutableListOf() + .apply { + add(null) // Header + addAll(it.preferenceItems.map { groupItem -> groupItem.title }) + add(null) // Spacer + } + } else { + listOf(it.title) + } + }.indexOfFirst { it == highlightKey } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearDatabaseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearDatabaseScreen.kt new file mode 100644 index 0000000000..5db9f824f3 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearDatabaseScreen.kt @@ -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(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, + val selection: List = emptyList(), + val showConfirmation: Boolean = false, + ) : State() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/Commons.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/Commons.kt new file mode 100644 index 0000000000..8fdb2d2176 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/Commons.kt @@ -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, + included: Set, + excluded: Set, +): 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) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SearchableSettings.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SearchableSettings.kt new file mode 100644 index 0000000000..28a3d05a71 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SearchableSettings.kt @@ -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 + + @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 + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt new file mode 100644 index 0000000000..bf84f9d707 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -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 { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val basePreferences = remember { Injekt.get() } + val networkPreferences = remember { Injekt.get() } + + 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() } + + val chapterCache = remember { Injekt.get() } + 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() } + + 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() } + + 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().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() } + 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 + }, + ), + ), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt new file mode 100644 index 0000000000..e66fb1b850 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt @@ -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 { + val context = LocalContext.current + val uiPreferences = remember { Injekt.get() } + 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", +) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt new file mode 100644 index 0000000000..ca4b055430 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt @@ -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 { + val backupPreferences = Injekt.get() + + 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() } + 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(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, + val trackers: List, +) + +data class InvalidRestore( + val message: String, +) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt new file mode 100644 index 0000000000..a89bb2ac0e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -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 { + val context = LocalContext.current + val sourcePreferences = remember { Injekt.get() } + val preferences = remember { Injekt.get() } + 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)), + ), + ), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt new file mode 100644 index 0000000000..dd611fcf5b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt @@ -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 { + val getCategories = remember { Injekt.get() } + val allCategories by getCategories.subscribe().collectAsState(initial = runBlocking { getCategories.await() }) + + val downloadPreferences = remember { Injekt.get() } + 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 { + 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 { + 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, + ): 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, + ): 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, + ): 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)), + ), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt new file mode 100644 index 0000000000..6247696629 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt @@ -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 { + val prefs = remember { Injekt.get() } + val libraryPrefs = remember { Injekt.get() } + return mutableListOf().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 { + val langs = mutableListOf>() + 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() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt new file mode 100644 index 0000000000..faae7fefb8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -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 { + val getCategories = remember { Injekt.get() } + val libraryPreferences = remember { Injekt.get() } + 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, + 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().await() + } + } + true + }, + ), + ), + ) + } + + @Composable + private fun getGlobalUpdateGroup( + allCategories: List, + 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().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() + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt new file mode 100644 index 0000000000..c01be7d4d0 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt @@ -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 { + 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() }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt new file mode 100644 index 0000000000..f1fcad3de8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -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 { + val readerPref = remember { Injekt.get() } + 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), + ), + ), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt new file mode 100644 index 0000000000..89c3ad6ebd --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt @@ -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?>(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, +) + +private data class SearchResultItem( + val route: Screen, + val title: String, + val breadcrumbs: String, + val highlightKey: String, +) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt new file mode 100644 index 0000000000..c734691a9a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt @@ -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 { + val context = LocalContext.current + val securityPreferences = remember { Injekt.get() } + 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 +) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt new file mode 100644 index 0000000000..dcedb6e4ec --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt @@ -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 { + val context = LocalContext.current + val trackPreferences = remember { Injekt.get() } + val trackManager = remember { Injekt.get() } + + var dialog by remember { mutableStateOf(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() + 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, +) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/AppThemePreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/AppThemePreferenceWidget.kt new file mode 100644 index 0000000000..6b45234972 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/AppThemePreferenceWidget.kt @@ -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 }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/BasePreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/BasePreferenceWidget.kt new file mode 100644 index 0000000000..b27246d6d7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/BasePreferenceWidget.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/EditTextPreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/EditTextPreferenceWidget.kt new file mode 100644 index 0000000000..bc0c7e64f9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/EditTextPreferenceWidget.kt @@ -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)) + } + }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/ListPreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/ListPreferenceWidget.kt new file mode 100644 index 0000000000..2a61e4fc96 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/ListPreferenceWidget.kt @@ -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 ListPreferenceWidget( + value: T, + title: String, + subtitle: String?, + icon: ImageVector?, + entries: Map, + 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), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/MultiSelectListPreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/MultiSelectListPreferenceWidget.kt new file mode 100644 index 0000000000..d4a3e8edc8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/MultiSelectListPreferenceWidget.kt @@ -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, + onValuesChange: (Set) -> 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)) + } + }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/PreferenceGroupHeader.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/PreferenceGroupHeader.kt new file mode 100644 index 0000000000..f825df342a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/PreferenceGroupHeader.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/SwitchPreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/SwitchPreferenceWidget.kt new file mode 100644 index 0000000000..b1e6f8f668 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/SwitchPreferenceWidget.kt @@ -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 = {}, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/TextPreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TextPreferenceWidget.kt new file mode 100644 index 0000000000..b670df2260 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TextPreferenceWidget.kt @@ -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 = {}, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/TrackingPreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TrackingPreferenceWidget.kt new file mode 100644 index 0000000000..2af0497bf9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TrackingPreferenceWidget.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/TriStateListDialog.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TriStateListDialog.kt new file mode 100644 index 0000000000..0e875d6922 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TriStateListDialog.kt @@ -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 TriStateListDialog( + title: String, + message: String? = null, + items: List, + initialChecked: List, + initialInversed: List, + itemLabel: @Composable (T) -> String, + onDismissRequest: () -> Unit, + onValueChanged: (newIncluded: List, newExcluded: List) -> 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)) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt index 1bc02a7f1a..1e092c6570 100644 --- a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt +++ b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt b/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt index 84409babd7..30304c78b5 100644 --- a/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt +++ b/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt new file mode 100644 index 0000000000..92fdc37f88 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -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 = staticCompositionLocalOf { null } + +/** + * For invoking back press to the parent activity + */ +val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null } diff --git a/app/src/main/java/eu/kanade/presentation/util/Preference.kt b/app/src/main/java/eu/kanade/presentation/util/Preference.kt new file mode 100644 index 0000000000..73888ed07f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Preference.kt @@ -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 Preference.collectAsState(): State { + val flow = remember(this) { changes() } + return flow.collectAsState(initial = get()) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt index 3fb57878f6..16b12725c8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt index 7e43a24ddb..5aebd53a58 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt @@ -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()) }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index 1368ee54b9..14d2f97eb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -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" diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt index f33c115662..379c776edf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt @@ -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 */ diff --git a/core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index c11d4bd1b2..7c63887732 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -69,6 +69,6 @@ class NetworkHelper(context: Context) { } val defaultUserAgent by lazy { - preferences.defaultUserAgent().get() + preferences.defaultUserAgent().get().trim() } } diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index caea579e94..b75a13897a 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -22,3 +22,4 @@ accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiper accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" } accompanist-pager-core = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist" } accompanist-pager-indicators = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a133aaa0f9..9077ac49cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ flowbinding_version = "1.2.0" shizuku_version = "12.2.0" sqldelight = "1.5.4" leakcanary = "2.9.1" +voyager = "1.0.0-beta16" [libraries] android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2" @@ -90,6 +91,12 @@ sqldelight-gradle = { module = "com.squareup.sqldelight:gradle-plugin", version. junit = "org.junit.jupiter:junit-jupiter:5.9.1" +voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } +voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } + +materialmotion-core = "io.github.fornewid:material-motion-compose-core:0.10.2-beta" +numberpicker= "com.chargemap.compose:numberpicker:1.0.3" + [bundles] reactivex = ["rxandroid", "rxjava", "rxrelay"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"] @@ -100,6 +107,7 @@ coil = ["coil-core", "coil-gif", "coil-compose"] flowbinding = ["flowbinding-android", "flowbinding-appcompat"] conductor = ["conductor-core", "conductor-support-preference"] shizuku = ["shizuku-api", "shizuku-provider"] +voyager = ["voyager-navigator", "voyager-transitions"] [plugins] kotlinter = { id = "org.jmailen.kotlinter", version = "3.12.0" }