diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 31176a609..762a69ef2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,12 +127,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 { @@ -164,6 +164,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) @@ -250,6 +251,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) @@ -325,10 +329,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 5bcc2cbba..321961985 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/browse/components/BrowseSourceToolbar.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt index 87478d5e2..64a47d740 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Help import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable 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 000000000..708fdaf23 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt @@ -0,0 +1,205 @@ +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.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PeopleAlt +import androidx.compose.material3.MaterialTheme +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 androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +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.BasePreferenceWidget +import eu.kanade.presentation.more.settings.widget.EditTextPreferenceWidget +import eu.kanade.presentation.more.settings.widget.HorizontalPadding +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.presentation.util.secondaryItemAlpha +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() }, + ) + } + } + // SY --> + is Preference.PreferenceItem.MangaDexPreference -> { + BasePreferenceWidget( + title = item.title, + widget = { + Icon( + imageVector = Icons.Outlined.PeopleAlt, + contentDescription = null, + modifier = Modifier + .padding(start = 12.dp, end = HorizontalPadding) + .secondaryItemAlpha(), + tint = if (item.loggedIn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + subcomponent = null, + onClick = if (item.loggedIn) { + item.logout + } else { + item.login + }, + ) + } + // SY <-- + } + } +} 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 000000000..713ddcad5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceModel.kt @@ -0,0 +1,163 @@ +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 } + } + + // SY --> + /** + * A [PreferenceItem] for mangadex login. + */ + data class MangaDexPreference( + override val title: String, + val loggedIn: Boolean, + 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 } + } + // SY <-- + } + + 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 000000000..66ced3c44 --- /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 000000000..629c062bf --- /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 000000000..460254412 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearDatabaseScreen.kt @@ -0,0 +1,226 @@ +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 = { + // SY --> + model.removeMangaBySourceId(it) + // SY <-- + 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(/* SY --> */keepReadManga: Boolean /* SY <-- */) { + val state = state.value as? State.Ready ?: return + // SY --> + if (keepReadManga) { + database.mangasQueries.deleteMangasNotInLibraryAndNotReadBySourceIds(state.selection) + } else { + database.mangasQueries.deleteMangasNotInLibraryBySourceIds(state.selection) + } + // SY <-- + 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 000000000..8fdb2d217 --- /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 000000000..d03da8f51 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SearchableSettings.kt @@ -0,0 +1,46 @@ +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() }, + ) + } + + // SY --> + fun isEnabled(): Boolean = true + // SY <-- + + 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 000000000..c99e3f01b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -0,0 +1,738 @@ +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 android.widget.Toast +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.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.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.UnsortedPreferences +import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.chapter.interactor.GetChapterByMangaId +import eu.kanade.domain.chapter.model.toDbChapter +import eu.kanade.domain.library.service.LibraryPreferences +import eu.kanade.domain.manga.interactor.GetAllManga +import eu.kanade.domain.manga.repository.MangaRepository +import eu.kanade.domain.source.service.SourcePreferences +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.LocalRouter +import eu.kanade.presentation.util.collectAsState +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.cache.PagePreviewCache +import eu.kanade.tachiyomi.data.download.DownloadManager +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.source.SourceManager +import eu.kanade.tachiyomi.ui.base.controller.pushController +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.storage.DiskUtil +import eu.kanade.tachiyomi.util.system.DeviceUtil +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 exh.debug.SettingsDebugController +import exh.log.EHLogLevel +import exh.pref.DelegateSourcePreferences +import exh.source.BlacklistedSources +import exh.source.EH_SOURCE_ID +import exh.source.EXH_SOURCE_ID +import kotlinx.coroutines.Job +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( + /* SY --> Preference.PreferenceItem.SwitchPreference( + pref = basePreferences.acraEnabled(), + title = stringResource(id = R.string.pref_enable_acra), + subtitle = stringResource(id = R.string.pref_acra_summary), + enabled = !isDevFlavor, + ), SY <-- */ + 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() + } + }, + ), + /* SY --> 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 + }, + ), SY <-- */ + getBackgroundActivityGroup(), + getDataGroup(), + getNetworkGroup(networkPreferences = networkPreferences), + getLibraryGroup(), + getExtensionsGroup(basePreferences = basePreferences), + getDisplayGroup(), + // SY --> + getDownloaderGroup(), + getDataSaverGroup(), + getDeveloperToolsGroup(), + // SY <-- + ) + } + + @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 } + + // SY --> + val pagePreviewCache = remember { Injekt.get() } + var pagePreviewReadableSizeSema by remember { mutableStateOf(0) } + val pagePreviewReadableSize = remember(pagePreviewReadableSizeSema) { pagePreviewCache.readableSize } + // SY <-- + + 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) } + } + } + }, + ), + // SY --> + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.pref_clear_page_preview_cache), + subtitle = stringResource(id = R.string.used_cache, pagePreviewReadableSize), + onClick = { + scope.launchNonCancellable { + try { + val deletedFiles = pagePreviewCache.clear() + withUIContext { + context.toast(context.getString(R.string.cache_deleted, deletedFiles)) + pagePreviewReadableSizeSema++ + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) + withUIContext { context.toast(R.string.cache_delete_error) } + } + } + }, + ), + // SY <-- + 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 + }, + ), + ), + ) + } + + // SY --> + @Composable + fun CleanupDownloadsDialog( + onDismissRequest: () -> Unit, + onCleanupDownloads: (removeRead: Boolean, removeNonFavorite: Boolean) -> Unit, + ) { + val context = LocalContext.current + val options = remember { context.resources.getStringArray(R.array.clean_up_downloads).toList() } + val selection = remember { + options.toMutableStateList() + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.clean_up_downloaded_chapters)) }, + text = { + LazyColumn { + options.forEachIndexed { index, option -> + item { + val isSelected = index == 0 || selection.contains(option) + val onSelectionChanged = { + when (!isSelected) { + true -> selection.add(option) + false -> selection.remove(option) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelectionChanged() }, + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { onSelectionChanged() }, + ) + Text( + text = option, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmButton = { + TextButton( + onClick = { + val removeRead = options[1] in selection + val removeNonFavorite = options[2] in selection + onCleanupDownloads(removeRead, removeNonFavorite) + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } + + @Composable + private fun getDownloaderGroup(): Preference.PreferenceGroup { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + CleanupDownloadsDialog( + onDismissRequest = { dialogOpen = false }, + onCleanupDownloads = { removeRead, removeNonFavorite -> + dialogOpen = false + if (job?.isActive == true) return@CleanupDownloadsDialog + context.toast(R.string.starting_cleanup) + job = scope.launchNonCancellable { + val mangaList = Injekt.get().await() + val downloadManager: DownloadManager = Injekt.get() + var foldersCleared = 0 + Injekt.get().getOnlineSources().forEach { source -> + val mangaFolders = downloadManager.getMangaFolders(source) + val sourceManga = mangaList + .asSequence() + .filter { it.source == source.id } + .map { it to DiskUtil.buildValidFilename(it.ogTitle) } + .toList() + + mangaFolders.forEach mangaFolder@{ mangaFolder -> + val manga = + sourceManga.find { (_, folderName) -> folderName == mangaFolder.name }?.first + if (manga == null) { + // download is orphaned delete it + foldersCleared += 1 + ( + mangaFolder.listFiles() + .orEmpty().size + ) + mangaFolder.delete() + } else { + val chapterList = Injekt.get().await(manga.id) + foldersCleared += downloadManager.cleanupChapters( + chapterList.map { it.toDbChapter() }, + manga, + source, + removeRead, + removeNonFavorite, + ) + } + } + } + withUIContext { + val cleanupString = + if (foldersCleared == 0) { + context.getString(R.string.no_folders_to_cleanup) + } else { + context.resources!!.getQuantityString( + R.plurals.cleanup_done, + foldersCleared, + foldersCleared, + ) + } + context.toast(cleanupString, Toast.LENGTH_LONG) + } + } + }, + ) + } + return Preference.PreferenceGroup( + title = stringResource(R.string.download_notifier_downloader_title), + preferenceItems = listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.clean_up_downloaded_chapters), + subtitle = stringResource(R.string.delete_unused_chapters), + onClick = { dialogOpen = true }, + ), + ), + ) + } + + @Composable + private fun getDataSaverGroup(): Preference.PreferenceGroup { + val sourcePreferences = remember { Injekt.get() } + val dataSaver by sourcePreferences.dataSaver().collectAsState() + return Preference.PreferenceGroup( + title = stringResource(R.string.data_saver), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.dataSaver(), + title = stringResource(R.string.data_saver), + ), + Preference.PreferenceItem.EditTextPreference( + pref = sourcePreferences.dataSaverServer(), + title = stringResource(R.string.data_saver), + subtitle = stringResource(R.string.data_saver_server_summary), + enabled = dataSaver, + ), + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.dataSaverDownloader(), + title = stringResource(R.string.data_saver_downloader), + enabled = dataSaver, + ), + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.dataSaverIgnoreJpeg(), + title = stringResource(R.string.data_saver_ignore_jpeg), + enabled = dataSaver, + ), + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.dataSaverIgnoreGif(), + title = stringResource(R.string.data_saver_ignore_gif), + enabled = dataSaver, + ), + Preference.PreferenceItem.ListPreference( + pref = sourcePreferences.dataSaverImageQuality(), + title = stringResource(R.string.data_saver_image_quality), + subtitle = stringResource(R.string.data_saver_image_quality_summary), + entries = listOf( + "10%", + "20%", + "40%", + "50%", + "70%", + "80%", + "90%", + "95%", + ).associateBy { it.trimEnd('%').toInt() }, + enabled = dataSaver, + ), + kotlin.run { + val dataSaverImageFormatJpeg by sourcePreferences.dataSaverImageFormatJpeg().collectAsState() + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.dataSaverImageFormatJpeg(), + title = stringResource(R.string.data_saver_image_format), + subtitle = if (dataSaverImageFormatJpeg) { + stringResource(R.string.data_saver_image_format_summary_on) + } else { + stringResource(R.string.data_saver_image_format_summary_off) + }, + enabled = dataSaver, + ) + }, + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.dataSaverColorBW(), + title = stringResource(R.string.data_saver_color_bw), + enabled = dataSaver, + ), + ), + ) + } + + @Composable + private fun getDeveloperToolsGroup(): Preference.PreferenceGroup { + val context = LocalContext.current + val router = LocalRouter.currentOrThrow + val sourcePreferences = remember { Injekt.get() } + val unsortedPreferences = remember { Injekt.get() } + val delegateSourcePreferences = remember { Injekt.get() } + return Preference.PreferenceGroup( + title = stringResource(R.string.developer_tools), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.isHentaiEnabled(), + title = stringResource(R.string.toggle_hentai_features), + subtitle = stringResource(R.string.toggle_hentai_features_summary), + onValueChanged = { + if (it) { + BlacklistedSources.HIDDEN_SOURCES += EH_SOURCE_ID + BlacklistedSources.HIDDEN_SOURCES += EXH_SOURCE_ID + } else { + BlacklistedSources.HIDDEN_SOURCES -= EH_SOURCE_ID + BlacklistedSources.HIDDEN_SOURCES -= EXH_SOURCE_ID + } + true + }, + ), + Preference.PreferenceItem.SwitchPreference( + pref = delegateSourcePreferences.delegateSources(), + title = stringResource(R.string.toggle_delegated_sources), + subtitle = stringResource( + R.string.toggle_delegated_sources_summary, + stringResource(R.string.app_name), + SourceManager.DELEGATED_SOURCES.values.map { it.sourceName }.distinct() + .joinToString(), + ), + ), + Preference.PreferenceItem.ListPreference( + pref = unsortedPreferences.logLevel(), + title = stringResource(R.string.log_level), + subtitle = stringResource(R.string.log_level_summary), + entries = EHLogLevel.values().mapIndexed { index, ehLogLevel -> + index to "${context.getString(ehLogLevel.nameRes)} (${ + context.getString(ehLogLevel.description) + })" + }.toMap(), + ), + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.enableSourceBlacklist(), + title = stringResource(R.string.enable_source_blacklist), + subtitle = stringResource( + R.string.enable_source_blacklist_summary, + stringResource(R.string.app_name), + ), + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.open_debug_menu), + subtitle = stringResource(R.string.open_debug_menu_summary), // todo make red + onClick = { router.pushController(SettingsDebugController()) }, + ), + ), + ) + } + + companion object { + private var job: Job? = null + } + // SY <-- +} 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 000000000..41673eff8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt @@ -0,0 +1,168 @@ +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), + // SY --> + getNavbarGroup(uiPreferences = uiPreferences), + // SY <-- + ) + } + + @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)" + }, + ), + ), + ) + } + + // SY --> + @Composable + fun getNavbarGroup(uiPreferences: UiPreferences): Preference.PreferenceGroup { + return Preference.PreferenceGroup( + stringResource(R.string.pref_category_navbar), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = uiPreferences.showNavUpdates(), + title = stringResource(R.string.pref_hide_updates_button), + ), + Preference.PreferenceItem.SwitchPreference( + pref = uiPreferences.showNavHistory(), + title = stringResource(R.string.pref_hide_history_button), + ), + Preference.PreferenceItem.SwitchPreference( + pref = uiPreferences.bottomBarLabels(), + title = stringResource(R.string.pref_show_bottom_bar_labels), + ), + ), + ) + } + // SY <-- +} + +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 000000000..ca4b05543 --- /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 000000000..9fe9a8162 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -0,0 +1,148 @@ +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 cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.UnsortedPreferences +import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.domain.ui.UiPreferences +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.presentation.util.collectAsState +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.ExtensionUpdateJob +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.category.repos.RepoController +import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryController +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() } + // SY --> + val uiPreferences = remember { Injekt.get() } + val unsortedPreferences = remember { Injekt.get() } + // SY <-- + return listOf( + Preference.PreferenceGroup( + title = stringResource(id = R.string.label_sources), + preferenceItems = listOf( + // SY --> + kotlin.run { + val router = LocalRouter.currentOrThrow + val count by sourcePreferences.sourcesTabCategories().collectAsState() + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.action_edit_categories), + subtitle = pluralStringResource(R.plurals.num_categories, count.size, count.size), + onClick = { + router.pushController(SourceCategoryController()) + }, + ) + }, + // SY <-- + 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), + ), + // SY --> + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.sourcesTabCategoriesFilter(), + title = stringResource(R.string.pref_source_source_filtering), + subtitle = stringResource(R.string.pref_source_source_filtering_summery), + ), + Preference.PreferenceItem.SwitchPreference( + pref = uiPreferences.useNewSourceNavigation(), + title = stringResource(R.string.pref_source_navigation), + subtitle = stringResource(R.string.pref_source_navigation_summery), + ), + Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.allowLocalSourceHiddenFolders(), + title = stringResource(R.string.pref_local_source_hidden_folders), + subtitle = stringResource(R.string.pref_local_source_hidden_folders_summery), + ), + // SY <-- + ), + ), + // SY --> + Preference.PreferenceGroup( + title = stringResource(R.string.feed), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = uiPreferences.feedTabInFront(), + title = stringResource(R.string.pref_feed_position), + subtitle = stringResource(R.string.pref_feed_position_summery), + ), + ), + ), + // SY <-- + 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 + }, + ), + // SY --> + kotlin.run { + val router = LocalRouter.currentOrThrow + val count by unsortedPreferences.extensionRepos().collectAsState() + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.action_edit_repos), + subtitle = pluralStringResource(R.plurals.num_repos, count.size, count.size), + onClick = { + router.pushController(RepoController()) + }, + ) + }, + // SY <-- + ), + ), + 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 000000000..dd611fcf5 --- /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/SettingsEhScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsEhScreen.kt new file mode 100644 index 000000000..ec7819a9d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsEhScreen.kt @@ -0,0 +1,1190 @@ +package eu.kanade.presentation.more.settings.screen + +import android.app.Activity +import android.content.Context +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat.startActivity +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.UnsortedPreferences +import eu.kanade.domain.manga.interactor.DeleteFavoriteEntries +import eu.kanade.domain.manga.interactor.GetExhFavoriteMangaWithMetadata +import eu.kanade.domain.manga.interactor.GetFlatMetadataById +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.presentation.util.collectAsState +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING +import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.lang.launchNonCancellable +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.toast +import exh.eh.EHentaiUpdateWorker +import exh.eh.EHentaiUpdateWorkerConstants +import exh.eh.EHentaiUpdaterStats +import exh.favorites.FavoritesIntroDialog +import exh.metadata.metadata.EHentaiSearchMetadata +import exh.uconfig.WarnConfigureDialogController +import exh.ui.login.EhLoginActivity +import exh.util.nullIfBlank +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import logcat.LogPriority +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class SettingsEhScreen : SearchableSettings { + + @ReadOnlyComposable + @Composable + override fun getTitle(): String = stringResource(R.string.pref_category_eh) + + override fun isEnabled(): Boolean = Injekt.get().isHentaiEnabled().get() + + @Composable + fun Reconfigure( + unsortedPreferences: UnsortedPreferences, + openWarnConfigureDialogController: () -> Unit, + ) { + var initialLoadGuard by remember { mutableStateOf(false) } + val useHentaiAtHome by unsortedPreferences.useHentaiAtHome().collectAsState() + val useJapaneseTitle by unsortedPreferences.useJapaneseTitle().collectAsState() + val useOriginalImages by unsortedPreferences.exhUseOriginalImages().collectAsState() + val ehTagFilterValue by unsortedPreferences.ehTagFilterValue().collectAsState() + val ehTagWatchingValue by unsortedPreferences.ehTagWatchingValue().collectAsState() + val settingsLanguages by unsortedPreferences.exhSettingsLanguages().collectAsState() + val enabledCategories by unsortedPreferences.exhEnabledCategories().collectAsState() + val imageQuality by unsortedPreferences.imageQuality().collectAsState() + DisposableEffect( + useHentaiAtHome, + useJapaneseTitle, + useOriginalImages, + ehTagFilterValue, + ehTagWatchingValue, + settingsLanguages, + enabledCategories, + imageQuality, + ) { + if (initialLoadGuard) { + openWarnConfigureDialogController() + } + initialLoadGuard = true + onDispose {} + } + } + + @Composable + override fun getPreferences(): List { + val router = LocalRouter.currentOrThrow + val openWarnConfigureDialogController = { + WarnConfigureDialogController.uploadSettings(router) + } + val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() } + val getFlatMetadataById: GetFlatMetadataById = remember { Injekt.get() } + val deleteFavoriteEntries: DeleteFavoriteEntries = remember { Injekt.get() } + val getExhFavoriteMangaWithMetadata: GetExhFavoriteMangaWithMetadata = remember { Injekt.get() } + val exhentaiEnabled by unsortedPreferences.enableExhentai().collectAsState() + + Reconfigure(unsortedPreferences, openWarnConfigureDialogController) + + return listOf( + Preference.PreferenceGroup( + stringResource(R.string.ehentai_prefs_account_settings), + preferenceItems = listOf( + getLoginPreference(unsortedPreferences, openWarnConfigureDialogController), + useHentaiAtHome(exhentaiEnabled, unsortedPreferences), + useJapaneseTitle(exhentaiEnabled, unsortedPreferences), + useOriginalImages(exhentaiEnabled, unsortedPreferences), + watchedTags(exhentaiEnabled), + tagFilterThreshold(exhentaiEnabled, unsortedPreferences), + tagWatchingThreshold(exhentaiEnabled, unsortedPreferences), + settingsLanguages(exhentaiEnabled, unsortedPreferences), + enabledCategories(exhentaiEnabled, unsortedPreferences), + watchedListDefaultState(exhentaiEnabled, unsortedPreferences), + imageQuality(exhentaiEnabled, unsortedPreferences), + enhancedEhentaiView(unsortedPreferences), + ), + ), + Preference.PreferenceGroup( + stringResource(R.string.favorites_sync), + preferenceItems = listOf( + readOnlySync(unsortedPreferences), + syncFavoriteNotes(), + lenientSync(unsortedPreferences), + forceSyncReset(deleteFavoriteEntries), + ), + ), + Preference.PreferenceGroup( + stringResource(R.string.gallery_update_checker), + preferenceItems = listOf( + updateCheckerFrequency(unsortedPreferences), + autoUpdateRequirements(unsortedPreferences), + updaterStatistics( + unsortedPreferences, + getExhFavoriteMangaWithMetadata, + getFlatMetadataById, + ), + ), + ), + ) + } + + @Composable + fun getLoginPreference( + unsortedPreferences: UnsortedPreferences, + openWarnConfigureDialogController: () -> Unit, + ): Preference.PreferenceItem.SwitchPreference { + val activityResultContract = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + // Upload settings + openWarnConfigureDialogController() + } + } + val context = LocalContext.current + val value by unsortedPreferences.enableExhentai().collectAsState() + return Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.enableExhentai(), + title = stringResource(R.string.enable_exhentai), + subtitle = if (!value) { + stringResource(R.string.requires_login) + } else { + null + }, + onValueChanged = { newVal -> + if (!newVal) { + unsortedPreferences.enableExhentai().set(false) + true + } else { + activityResultContract.launch(EhLoginActivity.newIntent(context)) + false + } + }, + ) + } + + @Composable + fun useHentaiAtHome( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.ListPreference { + return Preference.PreferenceItem.ListPreference( + pref = unsortedPreferences.useHentaiAtHome(), + title = stringResource(R.string.use_hentai_at_home), + subtitle = stringResource(R.string.use_hentai_at_home_summary), + entries = mapOf( + 0 to stringResource(R.string.use_hentai_at_home_option_1), + 1 to stringResource(R.string.use_hentai_at_home_option_2), + ), + enabled = exhentaiEnabled, + ) + } + + @Composable + fun useJapaneseTitle( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.SwitchPreference { + val value by unsortedPreferences.useJapaneseTitle().collectAsState() + return Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.useJapaneseTitle(), + title = stringResource(R.string.show_japanese_titles), + subtitle = if (value) { + stringResource(R.string.show_japanese_titles_option_1) + } else { + stringResource(R.string.show_japanese_titles_option_2) + }, + enabled = exhentaiEnabled, + ) + } + + @Composable + fun useOriginalImages( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.SwitchPreference { + val value by unsortedPreferences.exhUseOriginalImages().collectAsState() + return Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.exhUseOriginalImages(), + title = stringResource(R.string.use_original_images), + subtitle = if (value) { + stringResource(R.string.use_original_images_on) + } else { + stringResource(R.string.use_original_images_off) + }, + enabled = exhentaiEnabled, + ) + } + + @Composable + fun watchedTags(exhentaiEnabled: Boolean): Preference.PreferenceItem.TextPreference { + val context = LocalContext.current + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.watched_tags), + subtitle = stringResource(R.string.watched_tags_summary), + onClick = { + startActivity( + context, + WebViewActivity.newIntent( + context, + url = "https://exhentai.org/mytags", + title = context.getString(R.string.watched_tags_exh), + ), + null, + ) + }, + enabled = exhentaiEnabled, + ) + } + + @Composable + fun TagThresholdDialog( + onDismissRequest: () -> Unit, + title: String, + initialValue: Int, + valueRange: IntRange, + outsideRangeError: String, + onValueChange: (Int) -> Unit, + ) { + var value by remember(initialValue) { + mutableStateOf(initialValue.toString()) + } + val isValid = remember(value) { value.toIntOrNull().let { it != null && it in valueRange } } + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { onValueChange(value.toIntOrNull() ?: return@TextButton) }, + enabled = isValid, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + title = { + Text(text = title) + }, + text = { + Column( + Modifier + .fillMaxWidth() + .padding(4.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + maxLines = 1, + singleLine = true, + isError = !isValid, + modifier = Modifier.fillMaxWidth(), + trailingIcon = if (!isValid) { + { Icon(Icons.Outlined.Error, outsideRangeError) } + } else { + null + }, + ) + if (!isValid) { + Text( + text = outsideRangeError, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + }, + ) + } + + @Composable + fun tagFilterThreshold( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.TextPreference { + val value by unsortedPreferences.ehTagFilterValue().collectAsState() + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + TagThresholdDialog( + onDismissRequest = { dialogOpen = false }, + title = stringResource(R.string.tag_filtering_threshold), + initialValue = value, + valueRange = -9999..0, + outsideRangeError = stringResource(R.string.tag_filtering_threshhold_error), + onValueChange = { + dialogOpen = false + unsortedPreferences.ehTagFilterValue().set(it) + }, + ) + } + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.tag_filtering_threshold), + subtitle = stringResource(R.string.tag_filtering_threshhold_summary, value), + onClick = { + dialogOpen = true + }, + enabled = exhentaiEnabled, + ) + } + + @Composable + fun tagWatchingThreshold( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.TextPreference { + val value by unsortedPreferences.ehTagWatchingValue().collectAsState() + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + TagThresholdDialog( + onDismissRequest = { dialogOpen = false }, + title = stringResource(R.string.tag_watching_threshhold), + initialValue = value, + valueRange = 0..9999, + outsideRangeError = stringResource(R.string.tag_watching_threshhold_error), + onValueChange = { + dialogOpen = false + unsortedPreferences.ehTagWatchingValue().set(it) + }, + ) + } + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.tag_watching_threshhold), + subtitle = stringResource(R.string.tag_watching_threshhold_summary, value), + onClick = { + dialogOpen = true + }, + enabled = exhentaiEnabled, + ) + } + + class LanguageDialogState(preference: String) { + class RowState(original: ColumnState, translated: ColumnState, rewrite: ColumnState) { + var original by mutableStateOf(original) + var translated by mutableStateOf(translated) + var rewrite by mutableStateOf(rewrite) + + fun toPreference() = "${original.value}*${translated.value}*${rewrite.value}" + } + enum class ColumnState(val value: String) { + Unavailable("false"), + Enabled("true"), + Disabled("false"), + } + private fun String.toRowState(disableFirst: Boolean = false) = split("*") + .map { + if (it.toBoolean()) { + ColumnState.Enabled + } else { + ColumnState.Disabled + } + } + .let { + if (disableFirst) { + RowState(ColumnState.Unavailable, it[1], it[2]) + } else { + RowState(it[0], it[1], it[2]) + } + } + + val japanese: RowState + val english: RowState + val chinese: RowState + val dutch: RowState + val french: RowState + val german: RowState + val hungarian: RowState + val italian: RowState + val korean: RowState + val polish: RowState + val portuguese: RowState + val russian: RowState + val spanish: RowState + val thai: RowState + val vietnamese: RowState + val notAvailable: RowState + val other: RowState + + init { + val settingsLanguages = preference.split("\n") + japanese = settingsLanguages[0].toRowState(true) + english = settingsLanguages[1].toRowState() + chinese = settingsLanguages[2].toRowState() + dutch = settingsLanguages[3].toRowState() + french = settingsLanguages[4].toRowState() + german = settingsLanguages[5].toRowState() + hungarian = settingsLanguages[6].toRowState() + italian = settingsLanguages[7].toRowState() + korean = settingsLanguages[8].toRowState() + polish = settingsLanguages[9].toRowState() + portuguese = settingsLanguages[10].toRowState() + russian = settingsLanguages[11].toRowState() + spanish = settingsLanguages[12].toRowState() + thai = settingsLanguages[13].toRowState() + vietnamese = settingsLanguages[14].toRowState() + notAvailable = settingsLanguages[15].toRowState() + other = settingsLanguages[16].toRowState() + } + + fun toPreference() = listOf( + japanese, + english, + chinese, + dutch, + french, + german, + hungarian, + italian, + korean, + polish, + portuguese, + russian, + spanish, + thai, + vietnamese, + notAvailable, + other, + ).joinToString("\n") { it.toPreference() } + } + + @Composable + fun LanguageDialogRowCheckbox( + columnState: LanguageDialogState.ColumnState, + onStateChange: (LanguageDialogState.ColumnState) -> Unit, + ) { + if (columnState != LanguageDialogState.ColumnState.Unavailable) { + Checkbox( + checked = columnState == LanguageDialogState.ColumnState.Enabled, + onCheckedChange = { + if (it) { + onStateChange(LanguageDialogState.ColumnState.Disabled) + } else { + onStateChange(LanguageDialogState.ColumnState.Disabled) + } + }, + ) + } else { + Box(modifier = Modifier.size(48.dp)) + } + } + + @Composable + fun LanguageDialogRow( + language: String, + row: LanguageDialogState.RowState, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = language, + modifier = Modifier + .padding(4.dp) + .width(80.dp), + maxLines = 1, + ) + LanguageDialogRowCheckbox(row.original, onStateChange = { row.original = it }) + LanguageDialogRowCheckbox(row.translated, onStateChange = { row.translated = it }) + LanguageDialogRowCheckbox(row.rewrite, onStateChange = { row.rewrite = it }) + } + } + + @Composable + fun LanguagesDialog( + onDismissRequest: () -> Unit, + initialValue: String, + onValueChange: (String) -> Unit, + ) { + val state = remember(initialValue) { LanguageDialogState(initialValue) } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.language_filtering)) }, + text = { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Text(stringResource(R.string.language_filtering_summary)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Language", modifier = Modifier.padding(4.dp)) + Text(text = "Original", modifier = Modifier.padding(4.dp)) + Text(text = "Translated", modifier = Modifier.padding(4.dp)) + Text(text = "Rewrite", modifier = Modifier.padding(4.dp)) + } + LanguageDialogRow(language = "Japanese", row = state.japanese) + LanguageDialogRow(language = "English", row = state.english) + LanguageDialogRow(language = "Chinese", row = state.chinese) + LanguageDialogRow(language = "Dutch", row = state.dutch) + LanguageDialogRow(language = "French", row = state.french) + LanguageDialogRow(language = "German", row = state.german) + LanguageDialogRow(language = "Hungarian", row = state.hungarian) + LanguageDialogRow(language = "Italian", row = state.italian) + LanguageDialogRow(language = "Korean", row = state.korean) + LanguageDialogRow(language = "Polish", row = state.polish) + LanguageDialogRow(language = "Portuguese", row = state.portuguese) + LanguageDialogRow(language = "Russian", row = state.russian) + LanguageDialogRow(language = "Spanish", row = state.spanish) + LanguageDialogRow(language = "Thai", row = state.thai) + LanguageDialogRow(language = "Vietnamese", row = state.vietnamese) + LanguageDialogRow(language = "N/A", row = state.notAvailable) + LanguageDialogRow(language = "Other", row = state.other) + } + }, + confirmButton = { + TextButton(onClick = { onValueChange(state.toPreference()) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } + + @Composable + fun settingsLanguages( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.TextPreference { + val value by unsortedPreferences.exhSettingsLanguages().collectAsState() + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + LanguagesDialog( + onDismissRequest = { dialogOpen = false }, + initialValue = value, + onValueChange = { + dialogOpen = false + unsortedPreferences.exhSettingsLanguages().set(it) + }, + ) + } + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.language_filtering), + subtitle = stringResource(R.string.language_filtering_summary), + onClick = { + dialogOpen = true + }, + enabled = exhentaiEnabled, + ) + } + + class FrontPageCategoriesDialogState( + preference: String, + ) { + private val enabledCategories = preference.split(",").map { !it.toBoolean() } + var doujinshi by mutableStateOf(enabledCategories[0]) + var manga by mutableStateOf(enabledCategories[1]) + var artistCg by mutableStateOf(enabledCategories[2]) + var gameCg by mutableStateOf(enabledCategories[3]) + var western by mutableStateOf(enabledCategories[4]) + var nonH by mutableStateOf(enabledCategories[5]) + var imageSet by mutableStateOf(enabledCategories[6]) + var cosplay by mutableStateOf(enabledCategories[7]) + var asianPorn by mutableStateOf(enabledCategories[8]) + var misc by mutableStateOf(enabledCategories[9]) + + fun toPreference() = listOf( + doujinshi, + manga, + artistCg, + gameCg, + western, + nonH, + imageSet, + cosplay, + asianPorn, + misc, + ).joinToString(separator = ",") { (!it).toString() } + } + + @Composable + fun FrontPageCategoriesDialogRow( + title: String, + value: Boolean, + onValueChange: (Boolean) -> Unit, + ) { + Row( + Modifier + .fillMaxWidth() + .clickable { onValueChange(!value) } + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = title) + Switch(checked = value, onCheckedChange = null) + } + } + + @Composable + fun FrontPageCategoriesDialog( + onDismissRequest: () -> Unit, + initialValue: String, + onValueChange: (String) -> Unit, + ) { + val state = remember(initialValue) { FrontPageCategoriesDialogState(initialValue) } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.frong_page_categories)) }, + text = { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Text(stringResource(R.string.fromt_page_categories_summary)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Category", modifier = Modifier.padding(4.dp)) + Text(text = "Enabled", modifier = Modifier.padding(4.dp)) + } + FrontPageCategoriesDialogRow( + title = "Doujinshi", + value = state.doujinshi, + onValueChange = { state.doujinshi = it }, + ) + FrontPageCategoriesDialogRow( + title = "Manga", + value = state.manga, + onValueChange = { state.manga = it }, + ) + FrontPageCategoriesDialogRow( + title = "Artist CG", + value = state.artistCg, + onValueChange = { state.artistCg = it }, + ) + FrontPageCategoriesDialogRow( + title = "Game CG", + value = state.gameCg, + onValueChange = { state.gameCg = it }, + ) + FrontPageCategoriesDialogRow( + title = "Western", + value = state.western, + onValueChange = { state.western = it }, + ) + FrontPageCategoriesDialogRow( + title = "Non-H", + value = state.nonH, + onValueChange = { state.nonH = it }, + ) + FrontPageCategoriesDialogRow( + title = "Image Set", + value = state.imageSet, + onValueChange = { state.imageSet = it }, + ) + FrontPageCategoriesDialogRow( + title = "Cosplay", + value = state.cosplay, + onValueChange = { state.cosplay = it }, + ) + FrontPageCategoriesDialogRow( + title = "Asian Porn", + value = state.asianPorn, + onValueChange = { state.asianPorn = it }, + ) + FrontPageCategoriesDialogRow( + title = "Misc", + value = state.misc, + onValueChange = { state.misc = it }, + ) + } + }, + confirmButton = { + TextButton(onClick = { onValueChange(state.toPreference()) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } + + @Composable + fun enabledCategories( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.TextPreference { + val value by unsortedPreferences.exhEnabledCategories().collectAsState() + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + FrontPageCategoriesDialog( + onDismissRequest = { dialogOpen = false }, + initialValue = value, + onValueChange = { + dialogOpen = false + unsortedPreferences.exhEnabledCategories().set(it) + }, + ) + } + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.frong_page_categories), + subtitle = stringResource(R.string.fromt_page_categories_summary), + onClick = { + dialogOpen = true + }, + enabled = exhentaiEnabled, + ) + } + + @Composable + fun watchedListDefaultState( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.SwitchPreference { + return Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.exhWatchedListDefaultState(), + title = stringResource(R.string.watched_list_default), + subtitle = stringResource(R.string.watched_list_state_summary), + enabled = exhentaiEnabled, + ) + } + + @Composable + fun imageQuality( + exhentaiEnabled: Boolean, + unsortedPreferences: UnsortedPreferences, + ): Preference.PreferenceItem.ListPreference { + return Preference.PreferenceItem.ListPreference( + pref = unsortedPreferences.imageQuality(), + title = stringResource(R.string.eh_image_quality_summary), + subtitle = stringResource(R.string.eh_image_quality), + entries = mapOf( + "auto" to stringResource(R.string.eh_image_quality_auto), + "ovrs_2400" to stringResource(R.string.eh_image_quality_2400), + "ovrs_1600" to stringResource(R.string.eh_image_quality_1600), + "high" to stringResource(R.string.eh_image_quality_1280), + "med" to stringResource(R.string.eh_image_quality_980), + "low" to stringResource(R.string.eh_image_quality_780), + ), + enabled = exhentaiEnabled, + ) + } + + @Composable + fun enhancedEhentaiView(unsortedPreferences: UnsortedPreferences): Preference.PreferenceItem.SwitchPreference { + return Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.enhancedEHentaiView(), + title = stringResource(R.string.pref_enhanced_e_hentai_view), + subtitle = stringResource(R.string.pref_enhanced_e_hentai_view_summary), + ) + } + + @Composable + fun readOnlySync(unsortedPreferences: UnsortedPreferences): Preference.PreferenceItem.SwitchPreference { + return Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.exhReadOnlySync(), + title = stringResource(R.string.disable_favorites_uploading), + subtitle = stringResource(R.string.disable_favorites_uploading_summary), + ) + } + + @Composable + fun syncFavoriteNotes(): Preference.PreferenceItem.TextPreference { + val context = LocalContext.current + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.show_favorite_sync_notes), + subtitle = stringResource(R.string.show_favorite_sync_notes_summary), + onClick = { + FavoritesIntroDialog().show(context) + }, + ) + } + + @Composable + fun lenientSync(unsortedPreferences: UnsortedPreferences): Preference.PreferenceItem.SwitchPreference { + return Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.exhLenientSync(), + title = stringResource(R.string.ignore_sync_errors), + subtitle = stringResource(R.string.ignore_sync_errors_summary), + ) + } + + @Composable + fun SyncResetDialog( + onDismissRequest: () -> Unit, + onStartReset: () -> Unit, + ) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.favorites_sync_reset)) + }, + text = { + Text(text = stringResource(R.string.favorites_sync_reset_message)) + }, + confirmButton = { + TextButton(onClick = onStartReset) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) + } + + @Composable + fun forceSyncReset(deleteFavoriteEntries: DeleteFavoriteEntries): Preference.PreferenceItem.TextPreference { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + SyncResetDialog( + onDismissRequest = { dialogOpen = false }, + onStartReset = { + dialogOpen = false + scope.launchNonCancellable { + try { + deleteFavoriteEntries.await() + withUIContext { + context.toast(context.getString(R.string.sync_state_reset), Toast.LENGTH_LONG) + } + } catch (e: Exception) { + this@SettingsEhScreen.logcat(LogPriority.ERROR, e) + } + } + }, + ) + } + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.force_sync_state_reset), + subtitle = stringResource(R.string.force_sync_state_reset_summary), + onClick = { + dialogOpen = true + }, + ) + } + + @Composable + fun updateCheckerFrequency(unsortedPreferences: UnsortedPreferences): Preference.PreferenceItem.ListPreference { + val value by unsortedPreferences.exhAutoUpdateFrequency().collectAsState() + val context = LocalContext.current + return Preference.PreferenceItem.ListPreference( + pref = unsortedPreferences.exhAutoUpdateFrequency(), + title = stringResource(R.string.time_between_batches), + subtitle = if (value == 0) { + stringResource(R.string.time_between_batches_summary_1, stringResource(R.string.app_name)) + } else { + stringResource(R.string.time_between_batches_summary_2, stringResource(R.string.app_name), value, EHentaiUpdateWorkerConstants.UPDATES_PER_ITERATION) + }, + entries = mapOf( + 0 to stringResource(R.string.time_between_batches_never), + 1 to stringResource(R.string.time_between_batches_1_hour), + 2 to stringResource(R.string.time_between_batches_2_hours), + 3 to stringResource(R.string.time_between_batches_3_hours), + 6 to stringResource(R.string.time_between_batches_6_hours), + 12 to stringResource(R.string.time_between_batches_12_hours), + 24 to stringResource(R.string.time_between_batches_24_hours), + 48 to stringResource(R.string.time_between_batches_48_hours), + ), + onValueChanged = { interval -> + EHentaiUpdateWorker.scheduleBackground(context, prefInterval = interval) + true + }, + ) + } + + @Composable + fun autoUpdateRequirements(unsortedPreferences: UnsortedPreferences): Preference.PreferenceItem.MultiSelectListPreference { + val value by unsortedPreferences.exhAutoUpdateRequirements().collectAsState() + val context = LocalContext.current + return Preference.PreferenceItem.MultiSelectListPreference( + pref = unsortedPreferences.exhAutoUpdateRequirements(), + title = stringResource(R.string.time_between_batches), + subtitle = remember(value) { + context.getString( + R.string.restrictions, + value.sorted() + .map { + when (it) { + DEVICE_ONLY_ON_WIFI -> context.getString(R.string.connected_to_wifi) + DEVICE_CHARGING -> context.getString(R.string.charging) + else -> it + } + } + .ifEmpty { + listOf(context.getString(R.string.none)) + } + .joinToString(), + ) + }, + entries = mapOf( + DEVICE_ONLY_ON_WIFI to stringResource(R.string.connected_to_wifi), + DEVICE_CHARGING to stringResource(R.string.charging), + ), + onValueChanged = { restrictions -> + EHentaiUpdateWorker.scheduleBackground(context, prefRestrictions = restrictions) + true + }, + ) + } + + @Composable + fun UpdaterStatisticsLoadingDialog() { + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + Surface( + modifier = Modifier.sizeIn(minWidth = 280.dp, maxWidth = 560.dp), + shape = MaterialTheme.shapes.medium, + ) { + Text( + text = stringResource(R.string.gallery_updater_statistics_collection), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(40.dp), + ) + } + } + } + + @Composable + fun UpdaterStatisticsDialog( + onDismissRequest: () -> Unit, + updateInfo: String, + ) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.gallery_updater_statistics)) + }, + text = { + Text(text = updateInfo) + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } + + private fun getRelativeTimeFromNow(then: Duration): RelativeTime { + val now = System.currentTimeMillis().milliseconds + var period: Duration = now - then + val relativeTime = RelativeTime() + while (period > 0.milliseconds) { + when { + period >= 365.days -> { + (period.inWholeDays / 365).let { + relativeTime.years = it + period -= (it * 365).days + } + continue + } + period >= 30.days -> { + (period.inWholeDays / 30).let { + relativeTime.months = it + period -= (it * 30).days + } + } + period >= 7.days -> { + (period.inWholeDays / 7).let { + relativeTime.weeks = it + period -= (it * 7).days + } + } + period >= 1.days -> { + period.inWholeDays.let { + relativeTime.days = it + period -= it.days + } + } + period >= 1.hours -> { + period.inWholeHours.let { + relativeTime.hours = it + period -= it.hours + } + } + period >= 1.minutes -> { + period.inWholeMinutes.let { + relativeTime.minutes = it + period -= it.minutes + } + } + period >= 1.seconds -> { + period.inWholeSeconds.let { + relativeTime.seconds = it + period -= it.seconds + } + } + period >= 1.milliseconds -> { + period.inWholeMilliseconds.let { + relativeTime.milliseconds = it + } + period = Duration.ZERO + } + } + } + return relativeTime + } + + private fun getRelativeTimeString(relativeTime: RelativeTime, context: Context): String { + return relativeTime.years?.let { context.resources.getQuantityString(R.plurals.humanize_year, it.toInt(), it) } + ?: relativeTime.months?.let { context.resources.getQuantityString(R.plurals.humanize_month, it.toInt(), it) } + ?: relativeTime.weeks?.let { context.resources.getQuantityString(R.plurals.humanize_week, it.toInt(), it) } + ?: relativeTime.days?.let { context.resources.getQuantityString(R.plurals.humanize_day, it.toInt(), it) } + ?: relativeTime.hours?.let { context.resources.getQuantityString(R.plurals.humanize_hour, it.toInt(), it) } + ?: relativeTime.minutes?.let { context.resources.getQuantityString(R.plurals.humanize_minute, it.toInt(), it) } + ?: relativeTime.seconds?.let { context.resources.getQuantityString(R.plurals.humanize_second, it.toInt(), it) } + ?: context.getString(R.string.humanize_fallback) + } + + data class RelativeTime( + var years: Long? = null, + var months: Long? = null, + var weeks: Long? = null, + var days: Long? = null, + var hours: Long? = null, + var minutes: Long? = null, + var seconds: Long? = null, + var milliseconds: Long? = null, + ) + + @Composable + fun updaterStatistics( + unsortedPreferences: UnsortedPreferences, + getExhFavoriteMangaWithMetadata: GetExhFavoriteMangaWithMetadata, + getFlatMetadataById: GetFlatMetadataById, + ): Preference.PreferenceItem.TextPreference { + val context = LocalContext.current + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + val updateInfo by produceState(null) { + value = withIOContext { + try { + val stats = + unsortedPreferences.exhAutoUpdateStats().get().nullIfBlank()?.let { + Json.decodeFromString(it) + } + + val statsText = if (stats != null) { + context.getString(R.string.gallery_updater_stats_text, getRelativeTimeString(getRelativeTimeFromNow(stats.startTime.milliseconds), context), stats.updateCount, stats.possibleUpdates) + } else { + context.getString(R.string.gallery_updater_not_ran_yet) + } + + val allMeta = getExhFavoriteMangaWithMetadata.await() + .mapNotNull { + getFlatMetadataById.await(it.id) + ?.raise() + } + + fun metaInRelativeDuration(duration: Duration): Int { + val durationMs = duration.inWholeMilliseconds + return allMeta.asSequence().filter { + System.currentTimeMillis() - it.lastUpdateCheck < durationMs + }.count() + } + + statsText + "\n\n" + context.getString( + R.string.gallery_updater_stats_time, + metaInRelativeDuration(1.hours), + metaInRelativeDuration(6.hours), + metaInRelativeDuration(12.hours), + metaInRelativeDuration(1.days), + metaInRelativeDuration(2.days), + metaInRelativeDuration(7.days), + metaInRelativeDuration(30.days), + metaInRelativeDuration(365.days), + ) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Error loading gallery update info" } + "" + } + } + } + if (updateInfo == null) { + UpdaterStatisticsLoadingDialog() + } else { + UpdaterStatisticsDialog( + onDismissRequest = { dialogOpen = false }, + updateInfo = updateInfo.orEmpty(), + ) + } + } + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.show_updater_statistics), + onClick = { + dialogOpen = true + }, + ) + } +} 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 000000000..c1c1aa0ad --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt @@ -0,0 +1,143 @@ +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.UnsortedPreferences +import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.library.service.LibraryPreferences +import eu.kanade.domain.ui.UiPreferences +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() } + // SY --> + val uiPrefs = remember { Injekt.get() } + val unsortedPrefs = remember { Injekt.get() } + // SY <-- + 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 + }, + ), + ) + + // SY --> + add( + Preference.PreferenceGroup( + stringResource(R.string.pref_category_fork), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = uiPrefs.expandFilters(), + title = stringResource(R.string.toggle_expand_search_filters), + ), + Preference.PreferenceItem.SwitchPreference( + pref = unsortedPrefs.autoSolveCaptcha(), + title = stringResource(R.string.auto_solve_captchas), + subtitle = stringResource(R.string.auto_solve_captchas_summary), + ), + Preference.PreferenceItem.SwitchPreference( + pref = uiPrefs.recommendsInOverflow(), + title = stringResource(R.string.put_recommends_in_overflow), + subtitle = stringResource(R.string.put_recommends_in_overflow_summary), + ), + Preference.PreferenceItem.SwitchPreference( + pref = uiPrefs.mergeInOverflow(), + title = stringResource(R.string.put_merge_in_overflow), + subtitle = stringResource(R.string.put_merge_in_overflow_summary), + ), + ), + ), + ) + // SY <-- + } + } + + 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 000000000..c00a32d87 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -0,0 +1,418 @@ +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.UnsortedPreferences +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.model.GroupLibraryMode +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 eu.kanade.tachiyomi.ui.category.genre.SortTagController +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() }) + // SY --> + val unsortedPreferences = remember { Injekt.get() } + // SY <-- + + return mutableListOf( + getDisplayGroup(libraryPreferences), + getCategoriesGroup(LocalRouter.currentOrThrow, allCategories, libraryPreferences), + getGlobalUpdateGroup(allCategories, libraryPreferences), + // SY --> + getSortingCategory(libraryPreferences), + getMigrationCategory(unsortedPreferences), + // SY <-- + ) + } + + @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 }, + ), + // SY --> + Preference.PreferenceItem.ListPreference( + pref = libraryPreferences.groupLibraryUpdateType(), + title = stringResource(R.string.library_group_updates), + entries = mapOf( + GroupLibraryMode.GLOBAL to stringResource(R.string.library_group_updates_global), + GroupLibraryMode.ALL_BUT_UNGROUPED to stringResource(R.string.library_group_updates_all_but_ungrouped), + GroupLibraryMode.ALL to stringResource(R.string.library_group_updates_all), + ), + ), + // SY <-- + 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() + } + } + + // SY --> + @Composable + fun getSortingCategory(libraryPreferences: LibraryPreferences): Preference.PreferenceGroup { + val router = LocalRouter.current + val tagCount by libraryPreferences.sortTagsForLibrary().collectAsState() + return Preference.PreferenceGroup( + stringResource(R.string.pref_sorting_settings), + preferenceItems = listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.pref_tag_sorting), + subtitle = pluralStringResource(id = R.plurals.pref_tag_sorting_desc, tagCount.size, tagCount.size), + onClick = { + router?.pushController(SortTagController()) + }, + ), + ), + ) + } + + @Composable + fun getMigrationCategory(unsortedPreferences: UnsortedPreferences): Preference.PreferenceGroup { + val skipPreMigration by unsortedPreferences.skipPreMigration().collectAsState() + val migrationSources by unsortedPreferences.migrationSources().collectAsState() + return Preference.PreferenceGroup( + stringResource(R.string.migration), + enabled = skipPreMigration || migrationSources.isNotEmpty(), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = unsortedPreferences.skipPreMigration(), + title = stringResource(R.string.skip_pre_migration), + subtitle = stringResource(R.string.pref_skip_pre_migration_summary), + ), + ), + ) + } + // SY <-- +} 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 000000000..7e362290b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt @@ -0,0 +1,139 @@ +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.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.UnsortedPreferences +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.presentation.util.collectAsState +import eu.kanade.tachiyomi.R +import exh.assets.EhAssets +import exh.assets.ehassets.EhLogo +import exh.assets.ehassets.MangadexLogo +import exh.md.utils.MdUtil +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +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 + // SY --> + val isHentaiEnabled by remember { Injekt.get() }.isHentaiEnabled().collectAsState() + // SY <-- + 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()) }, + ), + // SY --> + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.pref_category_eh), + icon = EhAssets.EhLogo, + onClick = { navigator.push(SettingsEhScreen()) }, + enabled = isHentaiEnabled, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.pref_category_mangadex), + icon = EhAssets.MangadexLogo, + onClick = { navigator.push(SettingsMangadexScreen()) }, + enabled = remember { MdUtil.getEnabledMangaDexs(Injekt.get()).isNotEmpty() }, + ), + // SY <-- + 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/SettingsMangadexScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt new file mode 100644 index 000000000..4143672f7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt @@ -0,0 +1,385 @@ +package eu.kanade.presentation.more.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +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.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.runtime.toMutableStateList +import androidx.compose.ui.Alignment +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.unit.dp +import androidx.compose.ui.window.DialogProperties +import eu.kanade.domain.UnsortedPreferences +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.source.online.all.MangaDex +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.toast +import exh.log.xLogW +import exh.md.utils.MdUtil +import logcat.LogPriority +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SettingsMangadexScreen : SearchableSettings { + + @ReadOnlyComposable + @Composable + override fun getTitle(): String = stringResource(R.string.pref_category_mangadex) + + override fun isEnabled(): Boolean = MdUtil.getEnabledMangaDexs(Injekt.get()).isNotEmpty() + + @Composable + override fun getPreferences(): List { + val sourcePreferences: SourcePreferences = remember { Injekt.get() } + val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() } + val mdex = remember { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences) } ?: return emptyList() + + return listOf( + loginPreference(mdex), + preferredMangaDexId(unsortedPreferences, sourcePreferences), + syncMangaDexIntoThis(unsortedPreferences), + syncLibraryToMangaDex(), + ) + } + + @Composable + fun LoginDialog( + mdex: MangaDex, + onDismissRequest: () -> Unit, + onLoginSuccess: () -> Unit, + ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var username by remember { mutableStateOf(TextFieldValue(mdex.getUsername())) } + var password by remember { mutableStateOf(TextFieldValue(mdex.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, mdex.name)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = username, + onValueChange = { username = it }, + label = { Text(text = stringResource(R.string.username)) }, + 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(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 { + try { + inputError = false + processing = true + val result = mdex.login( + username = username.text, + password = password.text, + twoFactorCode = null, + ) + if (result) { + onDismissRequest() + onLoginSuccess() + withUIContext { + context.toast(R.string.login_success) + } + } + } catch (e: Exception) { + xLogW("Login to Mangadex error", e) + withUIContext { + e.message?.let { context.toast(it) } + } + } finally { + processing = false + } + } + }, + ) { + val id = if (processing) R.string.loading else R.string.login + Text(text = stringResource(id)) + } + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = onDismissRequest, + ) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + }, + properties = DialogProperties( + dismissOnBackPress = !processing, + dismissOnClickOutside = !processing, + ), + ) + } + + @Composable + fun LogoutDialog( + onDismissRequest: () -> Unit, + onLogoutRequest: () -> Unit, + ) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.logout)) + }, + confirmButton = { + TextButton(onClick = onLogoutRequest) { + Text(text = stringResource(R.string.logout)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } + + @Composable + fun loginPreference(mdex: MangaDex): Preference.PreferenceItem.MangaDexPreference { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var loggedIn by remember { mutableStateOf(mdex.isLogged()) } + var loginDialogOpen by remember { mutableStateOf(false) } + if (loginDialogOpen) { + LoginDialog( + mdex = mdex, + onDismissRequest = { loginDialogOpen = false }, + onLoginSuccess = { loggedIn = true }, + ) + } + var logoutDialogOpen by remember { mutableStateOf(false) } + if (logoutDialogOpen) { + LogoutDialog( + onDismissRequest = { logoutDialogOpen = false }, + onLogoutRequest = { + logoutDialogOpen = false + scope.launchIO { + try { + if (mdex.logout()) { + loggedIn = false + withUIContext { + context.toast(R.string.logout_success) + } + } else { + withUIContext { + context.toast(R.string.unknown_error) + } + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Logout error" } + withUIContext { + context.toast(R.string.unknown_error) + } + } + } + }, + ) + } + return Preference.PreferenceItem.MangaDexPreference( + title = mdex.name + " Login", + loggedIn = loggedIn, + login = { + loginDialogOpen = true + }, + logout = { + logoutDialogOpen = true + }, + ) + } + + @Composable + fun preferredMangaDexId( + unsortedPreferences: UnsortedPreferences, + sourcePreferences: SourcePreferences, + ): Preference.PreferenceItem.ListPreference { + return Preference.PreferenceItem.ListPreference( + pref = unsortedPreferences.preferredMangaDexId(), + title = stringResource(R.string.mangadex_preffered_source), + subtitle = stringResource(R.string.mangadex_preffered_source_summary), + entries = MdUtil.getEnabledMangaDexs(sourcePreferences) + .associate { it.id.toString() to it.toString() }, + ) + } + + @Composable + fun SyncMangaDexDialog( + onDismissRequest: () -> Unit, + onSelectionConfirmed: (List) -> Unit, + ) { + val context = LocalContext.current + val items = remember { + context.resources.getStringArray(R.array.md_follows_options) + .drop(1) + } + val selection = remember { + List(items.size) { index -> + index == 0 || index == 5 + }.toMutableStateList() + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.mangadex_sync_follows_to_library)) + }, + text = { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + items.forEachIndexed { index, followOption -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val checked = selection.getOrNull(index) ?: false + selection[index] = !checked + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = selection.getOrNull(index) ?: false, + onCheckedChange = null, + ) + + Text( + text = followOption, + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onSelectionConfirmed(items.filterIndexed { index, _ -> selection[index] }) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } + + @Composable + fun syncMangaDexIntoThis(unsortedPreferences: UnsortedPreferences): Preference.PreferenceItem.TextPreference { + val context = LocalContext.current + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + SyncMangaDexDialog( + onDismissRequest = { dialogOpen = false }, + onSelectionConfirmed = { items -> + dialogOpen = false + unsortedPreferences.mangadexSyncToLibraryIndexes().set( + List(items.size) { index -> (index + 1).toString() }.toSet(), + ) + LibraryUpdateService.start( + context, + target = LibraryUpdateService.Target.SYNC_FOLLOWS, + ) + }, + ) + } + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.mangadex_sync_follows_to_library), + subtitle = stringResource(R.string.mangadex_sync_follows_to_library_summary), + onClick = { dialogOpen = true }, + ) + } + + @Composable + fun syncLibraryToMangaDex(): Preference.PreferenceItem.TextPreference { + val context = LocalContext.current + return Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.mangadex_push_favorites_to_mangadex), + subtitle = stringResource(R.string.mangadex_push_favorites_to_mangadex_summary), + onClick = { + LibraryUpdateService.start( + context, + target = LibraryUpdateService.Target.PUSH_FAVORITES, + ) + }, + ) + } +} 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 000000000..2374c18a9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -0,0 +1,498 @@ +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.ReaderBottomButton +import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences +import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType +import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig +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() } + // SY --> + val forceHorizontalSeekbar by readerPref.forceHorizontalSeekbar().collectAsState() + // SY <-- + 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), + ), + // SY --> + Preference.PreferenceItem.SwitchPreference( + pref = readerPref.forceHorizontalSeekbar(), + title = stringResource(id = R.string.pref_force_horz_seekbar), + subtitle = stringResource(id = R.string.pref_force_horz_seekbar_summary), + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPref.forceHorizontalSeekbar(), + title = stringResource(id = R.string.pref_show_vert_seekbar_landscape), + subtitle = stringResource(id = R.string.pref_show_vert_seekbar_landscape_summary), + enabled = !forceHorizontalSeekbar, + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPref.forceHorizontalSeekbar(), + title = stringResource(id = R.string.pref_left_handed_vertical_seekbar), + subtitle = stringResource(id = R.string.pref_left_handed_vertical_seekbar_summary), + enabled = !forceHorizontalSeekbar, + ), + // SY <-- + 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, + ), + /* SY --> + Preference.PreferenceItem.SwitchPreference( + pref = readerPref.pageTransitions(), + title = stringResource(id = R.string.pref_page_transitions), + ), + SY <-- */ + getDisplayGroup(readerPreferences = readerPref), + getPagedGroup(readerPreferences = readerPref), + getWebtoonGroup(readerPreferences = readerPref), + // SY --> + getContinuousVerticalGroup(readerPreferences = readerPref), + // SY <-- + getNavigationGroup(readerPreferences = readerPref), + getActionsGroup(readerPreferences = readerPref), + // SY --> + getPageDownloadingGroup(readerPreferences = readerPref), + getForkSettingsGroup(readerPreferences = readerPref), + // SY <-- + ) + } + + @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), + ), + // SY --> + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.pageTransitionsPager(), + title = stringResource(id = R.string.pref_page_transitions), + ), + // SY <-- + 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), + ), + // SY --> + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.pageTransitionsWebtoon(), + title = stringResource(id = R.string.pref_page_transitions), + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.webtoonEnableZoomOut(), + title = stringResource(id = R.string.enable_zoom_out), + ), + // SY <-- + ), + ) + } + + // SY --> + @Composable + private fun getContinuousVerticalGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { + return Preference.PreferenceGroup( + title = stringResource(id = R.string.vertical_plus_viewer), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.continuousVerticalTappingByPage(), + title = stringResource(id = R.string.tap_scroll_page), + subtitle = stringResource(id = R.string.tap_scroll_page_summary), + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.cropBordersContinuousVertical(), + title = stringResource(id = R.string.pref_crop_borders), + ), + ), + ) + } + // SY <-- + + @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), + ), + ), + ) + } + + // SY --> + @Composable + private fun getPageDownloadingGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { + return Preference.PreferenceGroup( + title = stringResource(id = R.string.page_downloading), + preferenceItems = listOf( + Preference.PreferenceItem.ListPreference( + pref = readerPreferences.preloadSize(), + title = stringResource(R.string.reader_preload_amount), + subtitle = stringResource(R.string.reader_preload_amount_summary), + entries = mapOf( + 4 to stringResource(R.string.reader_preload_amount_4_pages), + 6 to stringResource(R.string.reader_preload_amount_6_pages), + 8 to stringResource(R.string.reader_preload_amount_8_pages), + 10 to stringResource(R.string.reader_preload_amount_10_pages), + 12 to stringResource(R.string.reader_preload_amount_12_pages), + 14 to stringResource(R.string.reader_preload_amount_14_pages), + 16 to stringResource(R.string.reader_preload_amount_16_pages), + 20 to stringResource(R.string.reader_preload_amount_20_pages), + ), + ), + Preference.PreferenceItem.ListPreference( + pref = readerPreferences.readerThreads(), + title = stringResource(R.string.download_threads), + subtitle = stringResource(R.string.download_threads_summary), + entries = List(5) { it }.associateWith { it.toString() }, + ), + Preference.PreferenceItem.ListPreference( + pref = readerPreferences.cacheSize(), + title = stringResource(R.string.reader_cache_size), + subtitle = stringResource(R.string.reader_cache_size_summary), + entries = mapOf( + "50" to "50 MB", + "75" to "75 MB", + "100" to "100 MB", + "150" to "150 MB", + "250" to "250 MB", + "500" to "500 MB", + "750" to "750 MB", + "1000" to "1 GB", + "1500" to "1.5 GB", + "2000" to "2 GB", + "2500" to "2.5 GB", + "3000" to "3 GB", + "3500" to "3.5 GB", + "4000" to "4 GB", + "4500" to "4.5 GB", + "5000" to "5 GB", + ), + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.aggressivePageLoading(), + title = stringResource(R.string.aggressively_load_pages), + subtitle = stringResource(R.string.aggressively_load_pages_summary), + ), + ), + ) + } + + @Composable + private fun getForkSettingsGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { + val pageLayout by readerPreferences.pageLayout().collectAsState() + return Preference.PreferenceGroup( + title = stringResource(id = R.string.pref_category_fork), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.readerInstantRetry(), + title = stringResource(R.string.skip_queue_on_retry), + subtitle = stringResource(R.string.skip_queue_on_retry_summary), + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.preserveReadingPosition(), + title = stringResource(R.string.preserve_reading_position), + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.useAutoWebtoon(), + title = stringResource(R.string.auto_webtoon_mode), + subtitle = stringResource(R.string.auto_webtoon_mode_summary), + ), + Preference.PreferenceItem.MultiSelectListPreference( + pref = readerPreferences.readerBottomButtons(), + title = stringResource(R.string.reader_bottom_buttons), + subtitle = stringResource(R.string.reader_bottom_buttons_summary), + entries = ReaderBottomButton.values() + .associate { it.value to stringResource(it.stringRes) }, + ), + Preference.PreferenceItem.ListPreference( + pref = readerPreferences.pageLayout(), + title = stringResource(R.string.page_layout), + subtitle = stringResource(R.string.automatic_can_still_switch), + entries = mapOf( + 0 to stringResource(R.string.single_page), + 1 to stringResource(R.string.double_pages), + 2 to stringResource(R.string.automatic_orientation), + ), + ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.invertDoublePages(), + title = stringResource(R.string.invert_double_pages), + enabled = pageLayout != PagerConfig.PageLayout.SINGLE_PAGE, + ), + Preference.PreferenceItem.ListPreference( + pref = readerPreferences.centerMarginType(), + title = stringResource(R.string.center_margin), + subtitle = stringResource(R.string.automatic_can_still_switch), + entries = mapOf( + 0 to stringResource(R.string.center_margin_none), + 1 to stringResource(R.string.center_margin_double_page), + 2 to stringResource(R.string.center_margin_wide_page), + 3 to stringResource(R.string.center_margin_double_and_wide_page), + ), + ), + ), + ) + } + // SY <-- +} 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 000000000..9a6ad3feb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt @@ -0,0 +1,310 @@ +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 + // SY --> + .filter(SearchableSettings::isEnabled) + // SY <-- + .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(), + // SY --> + SettingsEhScreen(), + SettingsMangadexScreen(), + // SY <-- + 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 000000000..1a70e36cb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt @@ -0,0 +1,233 @@ +package eu.kanade.presentation.more.settings.screen + +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.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.fragment.app.FragmentActivity +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.presentation.util.collectAsState +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate +import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesController +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) }, + ), + // SY --> + kotlin.run { + val router = LocalRouter.currentOrThrow + val count by securityPreferences.authenticatorTimeRanges().collectAsState() + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.action_edit_biometric_lock_times), + subtitle = pluralStringResource( + R.plurals.num_lock_times, + count.size, + count.size, + ), + onClick = { + router.pushController(BiometricTimesController()) + }, + enabled = useAuth, + ) + }, + kotlin.run { + val selection by securityPreferences.authenticatorDays().collectAsState() + var dialogOpen by remember { mutableStateOf(false) } + if (dialogOpen) { + SetLockedDaysDialog( + onDismissRequest = { dialogOpen = false }, + initialSelection = selection, + onDaysSelected = { + dialogOpen = false + securityPreferences.authenticatorDays().set(it) + }, + ) + } + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.biometric_lock_days), + subtitle = stringResource(R.string.biometric_lock_days_summary), + onClick = { dialogOpen = true }, + enabled = useAuth, + ) + }, + // SY <-- + Preference.infoPreference(stringResource(id = R.string.secure_screen_summary)), + ) + } + + // SY --> + enum class DayOption(val day: Int, val stringRes: Int) { + Sunday(SecureActivityDelegate.LOCK_SUNDAY, R.string.sunday), + Monday(SecureActivityDelegate.LOCK_MONDAY, R.string.monday), + Tuesday(SecureActivityDelegate.LOCK_TUESDAY, R.string.tuesday), + Wednesday(SecureActivityDelegate.LOCK_WEDNESDAY, R.string.wednesday), + Thursday(SecureActivityDelegate.LOCK_THURSDAY, R.string.thursday), + Friday(SecureActivityDelegate.LOCK_FRIDAY, R.string.friday), + Saturday(SecureActivityDelegate.LOCK_SATURDAY, R.string.saturday), + } + + @Composable + fun SetLockedDaysDialog( + onDismissRequest: () -> Unit, + initialSelection: Int, + onDaysSelected: (Int) -> Unit, + ) { + val selected = remember(initialSelection) { + DayOption.values().filter { it.day and initialSelection == it.day } + .toMutableStateList() + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.biometric_lock_days)) }, + text = { + LazyColumn { + DayOption.values().forEach { day -> + item { + val isSelected = selected.contains(day) + val onSelectionChanged = { + when (!isSelected) { + true -> selected.add(day) + false -> selected.remove(day) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelectionChanged() }, + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { onSelectionChanged() }, + ) + Text( + text = stringResource(day.stringRes), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmButton = { + TextButton( + onClick = { + onDaysSelected( + selected.fold(0) { i, day -> + i or day.day + }, + ) + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } + // SY <-- +} + +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 000000000..dcedb6e4e --- /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 000000000..6b4523497 --- /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 000000000..b27246d6d --- /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 000000000..bc0c7e64f --- /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 000000000..2a61e4fc9 --- /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 000000000..d4a3e8edc --- /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 000000000..f825df342 --- /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 000000000..b1e6f8f66 --- /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 000000000..a5198ac7b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TextPreferenceWidget.kt @@ -0,0 +1,48 @@ +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.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 000000000..2af0497bf --- /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 000000000..3a11f76f7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/TriStateListDialog.kt @@ -0,0 +1,138 @@ +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 + +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 1bc02a7f1..1e092c657 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 84409babd..30304c78b 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 000000000..92fdc37f8 --- /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 000000000..73888ed07 --- /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 3fb57878f..16b12725c 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 496850147..6f96f0cde 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 @@ -9,7 +9,6 @@ import eu.kanade.tachiyomi.ui.category.CategoryController import eu.kanade.tachiyomi.ui.download.DownloadController import eu.kanade.tachiyomi.ui.recent.history.HistoryController import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController -import eu.kanade.tachiyomi.ui.setting.SettingsBackupController import eu.kanade.tachiyomi.ui.setting.SettingsMainController import exh.ui.batchadd.BatchAddController @@ -25,7 +24,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()) }, // SY --> 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 df168e969..14d2f97eb 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,116 +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.runtime.remember -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource -import eu.kanade.domain.UnsortedPreferences -import eu.kanade.domain.source.service.SourcePreferences -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 exh.md.utils.MdUtil -import uy.kohesive.injekt.injectLazy +import soup.compose.material.motion.animation.materialSharedAxisZ -class SettingsMainController : BasicFullComposeController() { +class SettingsMainController : BasicFullComposeController { - private val preferences: UnsortedPreferences by injectLazy() + @Suppress("unused") + constructor(bundle: Bundle) : this(bundle.getBoolean(TO_BACKUP_SCREEN)) - // SY --> - private val sourcePreferences: SourcePreferences by injectLazy() - // SY <-- + 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 = listOfNotNull( - 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()) }, - ), - // SY --> - if (remember { preferences.isHentaiEnabled().get() }) { - SettingsSection( - titleRes = R.string.pref_category_eh, - painter = painterResource(R.drawable.eh_ic_ehlogo_red_24dp), - onClick = { router.pushController(SettingsEhController()) }, - ) - } else { - null + 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) }, + ) + } }, - if (remember { MdUtil.getEnabledMangaDexs(sourcePreferences).isNotEmpty() }) { - SettingsSection( - titleRes = R.string.pref_category_mangadex, - painter = painterResource(R.drawable.ic_tracker_mangadex_logo_24dp), - onClick = { router.pushController(SettingsMangaDexController()) }, - ) - } else { - null - }, - // SY <-- - 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()) }, ) } + + 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 f33c11566..379c776ed 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/app/src/main/java/exh/assets/__EhAssets.kt b/app/src/main/java/exh/assets/__EhAssets.kt new file mode 100644 index 000000000..535a752c2 --- /dev/null +++ b/app/src/main/java/exh/assets/__EhAssets.kt @@ -0,0 +1,21 @@ +package exh.assets + +import androidx.compose.ui.graphics.vector.ImageVector +import exh.assets.ehassets.AllAssets +import exh.assets.ehassets.EhLogo +import exh.assets.ehassets.Exh +import exh.assets.ehassets.MangadexLogo +import kotlin.collections.List as ____KtList + +public object EhAssets + +private var __AllAssets: ____KtList? = null + +public val EhAssets.AllAssets: ____KtList + get() { + if (__AllAssets != null) { + return __AllAssets!! + } + __AllAssets = Exh.AllAssets + listOf(EhLogo, MangadexLogo) + return __AllAssets!! + } diff --git a/app/src/main/java/exh/assets/ehassets/EhLogo.kt b/app/src/main/java/exh/assets/ehassets/EhLogo.kt new file mode 100644 index 000000000..df086854a --- /dev/null +++ b/app/src/main/java/exh/assets/ehassets/EhLogo.kt @@ -0,0 +1,168 @@ +package exh.assets.ehassets + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import exh.assets.EhAssets + +public val EhAssets.EhLogo: ImageVector + get() { + if (_ehLogo != null) { + return _ehLogo!! + } + _ehLogo = Builder( + name = "EhLogo", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 8.0f, + viewportHeight = 7.0f, + ).apply { + group { + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(0.0f, 0.0f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-3.0f) + close() + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(0.0f, 1.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(1.0f) + verticalLineToRelative(-2.0f) + close() + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(0.0f, 3.0f) + horizontalLineToRelative(2.25f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-2.25f) + close() + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(0.0f, 4.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(1.0f) + verticalLineToRelative(-2.0f) + close() + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(0.0f, 6.0f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-3.0f) + close() + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(3.0f, 3.0f) + horizontalLineToRelative(1.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-1.0f) + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(4.75f, 0.0f) + horizontalLineToRelative(1.0f) + verticalLineToRelative(7.0f) + horizontalLineToRelative(-1.0f) + close() + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(5.75f, 3.0f) + horizontalLineToRelative(1.25f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-1.25f) + close() + } + path( + fill = SolidColor(Color(0xFF660611)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(7.0f, 0.0f) + horizontalLineToRelative(1.0f) + verticalLineToRelative(7.0f) + horizontalLineToRelative(-1.0f) + close() + } + } + } + .build() + return _ehLogo!! + } + +private var _ehLogo: ImageVector? = null diff --git a/app/src/main/java/exh/assets/ehassets/MangadexLogo.kt b/app/src/main/java/exh/assets/ehassets/MangadexLogo.kt new file mode 100644 index 000000000..4b8af95cd --- /dev/null +++ b/app/src/main/java/exh/assets/ehassets/MangadexLogo.kt @@ -0,0 +1,430 @@ +package exh.assets.ehassets + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import exh.assets.EhAssets + +public val EhAssets.MangadexLogo: ImageVector + get() { + if (_mangadexLogo != null) { + return _mangadexLogo!! + } + _mangadexLogo = Builder( + name = "MangadexLogo", + defaultWidth = 24.0.dp, + defaultHeight = 20.471165.dp, + viewportWidth = 19.94664f, + viewportHeight = 17.01379f, + ).apply { + path( + fill = SolidColor(Color(0xFFf79421)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(17.9068f, 10.823f) + lineTo(3.4203f, 10.823f) + arcToRelative(0.3219f, 0.3219f, 0.0f, false, true, 0.0f, -0.6437f) + horizontalLineToRelative(14.4865f) + arcToRelative(0.3219f, 0.3219f, 0.0f, false, true, 0.0f, 0.6437f) + } + path( + fill = SolidColor(Color(0xFFf79421)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(17.9068f, 12.0434f) + lineTo(3.4203f, 12.0434f) + arcToRelative(0.3218f, 0.3218f, 0.0f, false, true, 0.0f, -0.6436f) + horizontalLineToRelative(14.4865f) + arcToRelative(0.3218f, 0.3218f, 0.0f, false, true, 0.0f, 0.6436f) + } + path( + fill = SolidColor(Color(0xFFf79421)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(15.536f, 11.681f) + lineToRelative(0.0f, 2.153f) + lineToRelative(0.922f, -0.534f) + lineToRelative(0.922f, 0.535f) + lineToRelative(0.0f, -2.154f) + lineToRelative(-1.844f, 0.0f) + close() + } + path( + fill = SolidColor(Color(0xFF272b30)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(16.1293f, 4.8787f) + horizontalLineToRelative(2.4227f) + verticalLineToRelative(1.3432f) + horizontalLineToRelative(-2.4227f) + close() + } + path( + fill = SolidColor(Color(0xFFf1f1f1)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(19.8539f, 6.5632f) + lineToRelative(-0.0363f, -0.0305f) + arcToRelative(3.3007f, 3.3007f, 0.0f, false, true, -0.6965f, -0.8454f) + lineToRelative(-0.0047f, -0.0096f) + horizontalLineToRelative(-9.0E-4f) + arcTo(3.2515f, 3.2515f, 0.0f, false, true, 18.7198f, 4.4946f) + lineToRelative(-0.0083f, -0.0773f) + lineTo(18.7108f, 4.4126f) + arcToRelative(0.8328f, 0.8328f, 0.0f, false, false, -0.1796f, -0.3989f) + lineToRelative(-0.0263f, -0.0298f) + arcToRelative(5.7398f, 5.7398f, 0.0f, false, false, -2.9173f, -1.7599f) + curveToRelative(-0.0026f, -0.0547f, -0.0039f, -0.1101f, -0.0039f, -0.1656f) + arcToRelative(3.6171f, 3.6171f, 0.0f, false, true, 0.4497f, -1.7517f) + arcToRelative(0.2081f, 0.2081f, 0.0f, false, false, -0.1825f, -0.3065f) + lineToRelative(-0.0145f, 9.0E-4f) + arcToRelative(0.2327f, 0.2327f, 0.0f, false, false, -0.0354f, 0.0056f) + arcTo(7.5849f, 7.5849f, 0.0f, false, false, 10.8316f, 3.1586f) + quadToRelative(-0.2133f, 0.3063f, -0.3956f, 0.6329f) + curveToRelative(-0.0725f, -0.0394f, -0.1467f, -0.0771f, -0.2213f, -0.1134f) + arcToRelative(6.9453f, 6.9453f, 0.0f, false, false, -0.7262f, -0.3073f) + arcTo(7.0757f, 7.0757f, 0.0f, false, false, 7.0963f, 2.9575f) + curveToRelative(-0.2203f, 0.0f, -0.4391f, 0.0105f, -0.6547f, 0.0306f) + arcToRelative(7.0239f, 7.0239f, 0.0f, false, false, -2.2916f, 0.608f) + arcToRelative(7.0974f, 7.0974f, 0.0f, false, false, -4.1283f, 5.9069f) + curveToRelative(-0.0145f, 0.1819f, -0.0217f, 0.366f, -0.0217f, 0.5511f) + verticalLineToRelative(1.7791f) + arcToRelative(3.0466f, 3.0466f, 0.0f, false, false, 2.9671f, 3.0452f) + lineTo(7.4866f, 14.8783f) + curveToRelative(0.0225f, -0.0017f, 0.045f, -0.0026f, 0.0675f, -0.0026f) + curveToRelative(0.0217f, 0.0f, 0.0443f, 9.0E-4f, 0.0661f, 0.0026f) + lineToRelative(0.0216f, 0.0017f) + lineToRelative(0.0355f, 0.003f) + arcToRelative(1.0846f, 1.0846f, 0.0f, false, true, 0.7705f, 0.4745f) + arcToRelative(1.0569f, 1.0569f, 0.0f, false, true, 0.1648f, 0.4382f) + lineToRelative(0.0033f, 0.0236f) + arcToRelative(0.7079f, 0.7079f, 0.0f, false, true, 0.0072f, 0.1012f) + lineToRelative(7.0E-4f, 0.025f) + lineToRelative(-7.0E-4f, 0.0203f) + verticalLineToRelative(0.0023f) + arcToRelative(1.0421f, 1.0421f, 0.0f, false, false, 0.0764f, 0.3758f) + arcToRelative(1.0733f, 1.0733f, 0.0f, false, false, 0.4287f, 0.5105f) + arcToRelative(1.0673f, 1.0673f, 0.0f, false, false, 1.6094f, -0.7084f) + arcToRelative(0.8794f, 0.8794f, 0.0f, false, false, 0.0168f, -0.1248f) + curveToRelative(9.0E-4f, -0.0144f, 0.0019f, -0.0281f, 0.0019f, -0.0428f) + lineToRelative(6.0E-4f, -0.0329f) + verticalLineToRelative(-0.0265f) + arcToRelative(3.2008f, 3.2008f, 0.0f, false, false, -3.2036f, -3.2031f) + lineTo(3.786f, 12.716f) + lineToRelative(-0.0273f, 0.001f) + lineToRelative(-0.0273f, -0.001f) + arcToRelative(1.6051f, 1.6051f, 0.0f, false, true, -1.5757f, -1.4973f) + curveToRelative(-0.0025f, -0.0363f, -0.004f, -0.0725f, -0.004f, -0.1085f) + arcToRelative(1.6066f, 1.6066f, 0.0f, false, true, 1.6071f, -1.6072f) + horizontalLineToRelative(14.365f) + arcToRelative(0.8361f, 0.8361f, 0.0f, false, false, 0.794f, -0.5774f) + curveToRelative(0.003f, -0.013f, 0.0073f, -0.0268f, 0.0112f, -0.0393f) + arcToRelative(0.8321f, 0.8321f, 0.0f, false, true, 0.2783f, -0.4054f) + lineToRelative(0.0017f, -9.0E-4f) + arcToRelative(0.3559f, 0.3559f, 0.0f, false, true, 0.0324f, -0.024f) + arcToRelative(0.5178f, 0.5178f, 0.0f, false, true, 0.045f, -0.0305f) + arcTo(1.6423f, 1.6423f, 0.0f, false, false, 19.9363f, 7.289f) + arcToRelative(1.4932f, 1.4932f, 0.0f, false, false, 0.0104f, -0.1819f) + arcToRelative(1.642f, 1.642f, 0.0f, false, false, -0.0927f, -0.5439f) + moveToRelative(-1.9193f, -0.641f) + arcToRelative(0.208f, 0.208f, 0.0f, false, true, -0.0964f, -0.024f) + arcToRelative(1.1209f, 1.1209f, 0.0f, false, false, -0.4713f, -0.1465f) + curveToRelative(-0.0306f, -0.0024f, -0.0613f, -0.004f, -0.0925f, -0.004f) + arcToRelative(1.1499f, 1.1499f, 0.0f, false, false, -0.5463f, 0.14f) + lineToRelative(-0.0046f, 0.0032f) + arcToRelative(0.0841f, 0.0841f, 0.0f, false, false, -0.0177f, 0.0104f) + arcToRelative(0.1982f, 0.1982f, 0.0f, false, true, -0.0902f, 0.021f) + arcToRelative(0.2029f, 0.2029f, 0.0f, false, true, -0.2026f, -0.2026f) + arcToRelative(0.2007f, 0.2007f, 0.0f, false, true, 0.0894f, -0.1674f) + lineToRelative(0.0202f, -0.0121f) + arcToRelative(1.5377f, 1.5377f, 0.0f, false, true, 0.4231f, -0.1608f) + arcToRelative(1.5366f, 1.5366f, 0.0f, false, true, 1.0035f, 0.1206f) + curveToRelative(0.0266f, 0.0121f, 0.0521f, 0.0258f, 0.078f, 0.0402f) + lineToRelative(0.0202f, 0.0121f) + arcToRelative(0.2023f, 0.2023f, 0.0f, false, true, -0.1125f, 0.37f) + } + path( + fill = SolidColor(Color(0xFFe6e6e6)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(19.8539f, 6.5632f) + lineToRelative(-0.0363f, -0.0305f) + arcToRelative(3.3007f, 3.3007f, 0.0f, false, true, -0.6965f, -0.8454f) + lineToRelative(-0.0047f, -0.0096f) + horizontalLineToRelative(-9.0E-4f) + arcTo(3.2515f, 3.2515f, 0.0f, false, true, 18.7198f, 4.4946f) + lineToRelative(-0.0083f, -0.0773f) + lineTo(18.7108f, 4.4126f) + arcToRelative(0.8328f, 0.8328f, 0.0f, false, false, -0.1796f, -0.3989f) + lineToRelative(-0.0263f, -0.0298f) + arcToRelative(5.7398f, 5.7398f, 0.0f, false, false, -2.9173f, -1.7599f) + curveToRelative(-0.0026f, -0.0547f, -0.0039f, -0.1101f, -0.0039f, -0.1656f) + arcToRelative(3.6171f, 3.6171f, 0.0f, false, true, 0.4497f, -1.7517f) + arcToRelative(0.2081f, 0.2081f, 0.0f, false, false, -0.1825f, -0.3065f) + lineToRelative(-0.0145f, 9.0E-4f) + arcToRelative(0.2327f, 0.2327f, 0.0f, false, false, -0.0354f, 0.0056f) + arcTo(7.5849f, 7.5849f, 0.0f, false, false, 10.8316f, 3.1586f) + quadToRelative(-0.2133f, 0.3063f, -0.3956f, 0.6329f) + curveToRelative(-0.0725f, -0.0394f, -0.1467f, -0.0771f, -0.2213f, -0.1134f) + arcToRelative(6.9453f, 6.9453f, 0.0f, false, false, -0.7262f, -0.3073f) + arcTo(7.0757f, 7.0757f, 0.0f, false, false, 7.0963f, 2.9575f) + curveToRelative(-0.2203f, 0.0f, -0.4391f, 0.0105f, -0.6547f, 0.0306f) + arcToRelative(7.0239f, 7.0239f, 0.0f, false, false, -2.2916f, 0.608f) + arcToRelative(7.0974f, 7.0974f, 0.0f, false, false, -4.1283f, 5.9069f) + curveToRelative(-0.0145f, 0.1819f, -0.0217f, 0.366f, -0.0217f, 0.5511f) + verticalLineToRelative(1.7791f) + arcToRelative(3.0466f, 3.0466f, 0.0f, false, false, 2.9671f, 3.0452f) + lineTo(7.4866f, 14.8783f) + curveToRelative(0.0225f, -0.0017f, 0.045f, -0.0026f, 0.0675f, -0.0026f) + curveToRelative(0.0217f, 0.0f, 0.0443f, 9.0E-4f, 0.0661f, 0.0026f) + lineToRelative(0.0216f, 0.0017f) + lineToRelative(0.0355f, 0.003f) + arcToRelative(1.0846f, 1.0846f, 0.0f, false, true, 0.7705f, 0.4745f) + arcToRelative(1.0569f, 1.0569f, 0.0f, false, true, 0.1648f, 0.4382f) + lineToRelative(0.0033f, 0.0236f) + arcToRelative(0.7079f, 0.7079f, 0.0f, false, true, 0.0072f, 0.1012f) + lineToRelative(7.0E-4f, 0.025f) + lineToRelative(-7.0E-4f, 0.0203f) + verticalLineToRelative(0.0023f) + arcToRelative(1.0421f, 1.0421f, 0.0f, false, false, 0.0764f, 0.3758f) + arcToRelative(1.0733f, 1.0733f, 0.0f, false, false, 0.4287f, 0.5105f) + arcToRelative(1.0673f, 1.0673f, 0.0f, false, false, 1.6094f, -0.7084f) + arcToRelative(0.8794f, 0.8794f, 0.0f, false, false, 0.0168f, -0.1248f) + curveToRelative(9.0E-4f, -0.0144f, 0.0019f, -0.0281f, 0.0019f, -0.0428f) + lineToRelative(6.0E-4f, -0.0329f) + verticalLineToRelative(-0.0265f) + arcToRelative(3.2008f, 3.2008f, 0.0f, false, false, -3.2036f, -3.2031f) + lineTo(3.786f, 12.716f) + lineToRelative(-0.0273f, 0.001f) + lineToRelative(-0.0273f, -0.001f) + arcToRelative(1.6051f, 1.6051f, 0.0f, false, true, -1.5757f, -1.4973f) + curveToRelative(-0.0025f, -0.0363f, -0.004f, -0.0725f, -0.004f, -0.1085f) + arcToRelative(1.6066f, 1.6066f, 0.0f, false, true, 1.6071f, -1.6072f) + horizontalLineToRelative(14.365f) + arcToRelative(0.8361f, 0.8361f, 0.0f, false, false, 0.794f, -0.5774f) + curveToRelative(0.003f, -0.013f, 0.0073f, -0.0268f, 0.0112f, -0.0393f) + arcToRelative(0.8321f, 0.8321f, 0.0f, false, true, 0.2783f, -0.4054f) + lineToRelative(0.0017f, -9.0E-4f) + arcToRelative(0.3559f, 0.3559f, 0.0f, false, true, 0.0324f, -0.024f) + arcToRelative(0.5178f, 0.5178f, 0.0f, false, true, 0.045f, -0.0305f) + arcTo(1.6423f, 1.6423f, 0.0f, false, false, 19.9363f, 7.289f) + arcToRelative(1.4932f, 1.4932f, 0.0f, false, false, 0.0104f, -0.1819f) + arcToRelative(1.642f, 1.642f, 0.0f, false, false, -0.0927f, -0.5439f) + moveToRelative(-1.9193f, -0.641f) + arcToRelative(0.208f, 0.208f, 0.0f, false, true, -0.0964f, -0.024f) + arcToRelative(1.1209f, 1.1209f, 0.0f, false, false, -0.4713f, -0.1465f) + curveToRelative(-0.0306f, -0.0024f, -0.0613f, -0.004f, -0.0925f, -0.004f) + arcToRelative(1.1499f, 1.1499f, 0.0f, false, false, -0.5463f, 0.14f) + lineToRelative(-0.0046f, 0.0032f) + arcToRelative(0.0841f, 0.0841f, 0.0f, false, false, -0.0177f, 0.0104f) + arcToRelative(0.1982f, 0.1982f, 0.0f, false, true, -0.0902f, 0.021f) + arcToRelative(0.2029f, 0.2029f, 0.0f, false, true, -0.2026f, -0.2026f) + arcToRelative(0.2007f, 0.2007f, 0.0f, false, true, 0.0894f, -0.1674f) + lineToRelative(0.0202f, -0.0121f) + arcToRelative(1.5377f, 1.5377f, 0.0f, false, true, 0.4231f, -0.1608f) + arcToRelative(1.5366f, 1.5366f, 0.0f, false, true, 1.0035f, 0.1206f) + curveToRelative(0.0266f, 0.0121f, 0.0521f, 0.0258f, 0.078f, 0.0402f) + lineToRelative(0.0202f, 0.0121f) + arcToRelative(0.2023f, 0.2023f, 0.0f, false, true, -0.1125f, 0.37f) + } + path( + fill = SolidColor(Color(0xFFf79421)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(10.4577f, 3.7549f) + arcToRelative(4.0457f, 4.0457f, 0.0f, false, false, 3.9329f, 3.2546f) + arcToRelative(3.9656f, 3.9656f, 0.0f, false, false, 2.8954f, -1.2611f) + lineToRelative(-0.0118f, -6.0E-4f) + arcToRelative(1.1516f, 1.1516f, 0.0f, false, false, -0.5463f, 0.1399f) + lineToRelative(-0.0046f, 0.0033f) + arcToRelative(0.0737f, 0.0737f, 0.0f, false, false, -0.0177f, 0.0104f) + arcToRelative(0.2001f, 0.2001f, 0.0f, false, true, -0.0902f, 0.021f) + arcTo(0.2031f, 0.2031f, 0.0f, false, true, 16.4128f, 5.7196f) + arcToRelative(0.2005f, 0.2005f, 0.0f, false, true, 0.0894f, -0.1672f) + arcToRelative(0.1656f, 0.1656f, 0.0f, false, true, 0.0202f, -0.0121f) + arcToRelative(1.536f, 1.536f, 0.0f, false, true, 1.0739f, -0.162f) + arcToRelative(4.1373f, 4.1373f, 0.0f, false, false, 0.7204f, -1.5944f) + arcToRelative(5.7308f, 5.7308f, 0.0f, false, false, -2.7292f, -1.5597f) + curveToRelative(-0.0026f, -0.0547f, -0.0039f, -0.1102f, -0.0039f, -0.1656f) + arcTo(3.6172f, 3.6172f, 0.0f, false, true, 16.0333f, 0.3065f) + arcToRelative(0.208f, 0.208f, 0.0f, false, false, -0.1825f, -0.3064f) + lineToRelative(-0.0145f, 9.0E-4f) + arcToRelative(0.2337f, 0.2337f, 0.0f, false, false, -0.0354f, 0.0056f) + arcTo(7.5859f, 7.5859f, 0.0f, false, false, 10.8316f, 3.1586f) + curveToRelative(-0.1341f, 0.1926f, -0.2582f, 0.3914f, -0.3739f, 0.5962f) + } + path( + fill = SolidColor(Color(0xFF272b30)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(18.7884f, 7.5973f) + curveToRelative(-0.1747f, -0.0518f, -0.3484f, -0.1036f, -0.5253f, -0.1426f) + curveToRelative(-0.1753f, -0.0449f, -0.352f, -0.0829f, -0.5295f, -0.116f) + arcToRelative(9.2543f, 9.2543f, 0.0f, false, false, -1.0704f, -0.1366f) + arcToRelative(6.7012f, 6.7012f, 0.0f, false, false, -1.0762f, 0.0124f) + lineToRelative(-0.2681f, 0.0301f) + curveToRelative(-0.0892f, 0.0125f, -0.1767f, 0.0337f, -0.2655f, 0.0495f) + curveToRelative(-0.1807f, 0.0219f, -0.3497f, 0.0875f, -0.5293f, 0.1252f) + arcToRelative(3.9496f, 3.9496f, 0.0f, false, true, 1.049f, -0.3146f) + arcTo(4.938f, 4.938f, 0.0f, false, true, 16.6723f, 7.0586f) + arcToRelative(6.1318f, 6.1318f, 0.0f, false, true, 1.085f, 0.1725f) + arcToRelative(5.3256f, 5.3256f, 0.0f, false, true, 1.0311f, 0.3661f) + } + path( + fill = SolidColor(Color(0xFF272b30)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(18.6307f, 7.9274f) + curveToRelative(-0.1805f, -0.0253f, -0.3598f, -0.0507f, -0.5408f, -0.0629f) + curveToRelative(-0.1802f, -0.0184f, -0.3605f, -0.0298f, -0.541f, -0.0361f) + arcToRelative(9.2334f, 9.2334f, 0.0f, false, false, -1.0788f, 0.024f) + arcToRelative(6.6967f, 6.6967f, 0.0f, false, false, -1.0622f, 0.172f) + lineToRelative(-0.2609f, 0.0697f) + curveToRelative(-0.0862f, 0.0255f, -0.1697f, 0.0594f, -0.255f, 0.0882f) + curveToRelative(-0.1753f, 0.0485f, -0.3327f, 0.1384f, -0.5046f, 0.2025f) + arcToRelative(3.9492f, 3.9492f, 0.0f, false, true, 0.9906f, -0.4669f) + arcToRelative(4.9323f, 4.9323f, 0.0f, false, true, 1.0802f, -0.2088f) + arcToRelative(6.1421f, 6.1421f, 0.0f, false, true, 1.0986f, 0.0094f) + arcToRelative(5.3552f, 5.3552f, 0.0f, false, true, 1.0739f, 0.209f) + } + path( + fill = SolidColor(Color(0xFF272b30)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(18.6059f, 8.3121f) + curveToRelative(-0.1819f, 0.0144f, -0.3624f, 0.0282f, -0.5418f, 0.0553f) + curveToRelative(-0.1796f, 0.021f, -0.3582f, 0.0488f, -0.5359f, 0.0816f) + arcToRelative(9.2664f, 9.2664f, 0.0f, false, false, -1.048f, 0.2563f) + arcToRelative(6.6644f, 6.6644f, 0.0f, false, false, -1.0001f, 0.3975f) + lineToRelative(-0.2396f, 0.1242f) + curveToRelative(-0.0787f, 0.0436f, -0.153f, 0.0945f, -0.2301f, 0.141f) + curveToRelative(-0.1608f, 0.0853f, -0.295f, 0.2071f, -0.4491f, 0.3067f) + arcToRelative(3.9463f, 3.9463f, 0.0f, false, true, 0.8665f, -0.6695f) + arcToRelative(4.9177f, 4.9177f, 0.0f, false, true, 1.0097f, -0.4371f) + arcToRelative(6.1229f, 6.1229f, 0.0f, false, true, 1.0746f, -0.2281f) + arcToRelative(5.328f, 5.328f, 0.0f, false, true, 1.0939f, -0.0279f) + } + path( + fill = SolidColor(Color(0xFFf27baa)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(19.8177f, 6.5327f) + curveToRelative(-0.0607f, -0.0527f, -0.1192f, -0.1081f, -0.176f, -0.1651f) + arcToRelative(0.5325f, 0.5325f, 0.0f, false, false, 0.275f, 0.9886f) + lineToRelative(0.0083f, -4.0E-4f) + curveToRelative(0.0032f, -0.0225f, 0.0085f, -0.0442f, 0.0108f, -0.0669f) + arcToRelative(1.5214f, 1.5214f, 0.0f, false, false, 0.0104f, -0.1818f) + arcToRelative(1.6228f, 1.6228f, 0.0f, false, false, -0.0927f, -0.5439f) + lineToRelative(-0.0358f, -0.0305f) + } + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(15.4035f, 2.4984f) + curveToRelative(0.0f, 0.0249f, 0.0f, 0.0507f, 9.0E-4f, 0.0755f) + arcToRelative(5.7281f, 5.7281f, 0.0f, false, false, -1.7421f, 1.11f) + arcToRelative(3.0567f, 3.0567f, 0.0f, false, true, 2.3911f, -3.4281f) + arcToRelative(4.1731f, 4.1731f, 0.0f, false, false, -0.6499f, 2.2425f) + } + path( + fill = SolidColor(Color(0xFFf79421)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(9.8022f, 13.638f) + curveToRelative(-0.0705f, -0.0694f, -0.1454f, -0.1347f, -0.2221f, -0.1973f) + arcToRelative(2.4827f, 2.4827f, 0.0f, false, false, -1.786f, 1.4633f) + arcToRelative(1.0684f, 1.0684f, 0.0f, false, true, 0.578f, 0.353f) + arcToRelative(1.0855f, 1.0855f, 0.0f, false, true, 0.0756f, 0.1006f) + arcToRelative(1.0534f, 1.0534f, 0.0f, false, true, 0.1649f, 0.4385f) + lineToRelative(0.0032f, 0.0233f) + arcToRelative(0.6866f, 0.6866f, 0.0f, false, true, 0.0073f, 0.1012f) + lineToRelative(7.0E-4f, 0.025f) + lineToRelative(-7.0E-4f, 0.02f) + verticalLineToRelative(0.0026f) + arcToRelative(1.0409f, 1.0409f, 0.0f, false, false, 0.0763f, 0.3755f) + arcToRelative(1.0755f, 1.0755f, 0.0f, false, false, 0.4287f, 0.511f) + arcToRelative(1.0678f, 1.0678f, 0.0f, false, false, 1.6095f, -0.7089f) + arcToRelative(0.8711f, 0.8711f, 0.0f, false, false, 0.0168f, -0.1245f) + curveToRelative(9.0E-4f, -0.0147f, 0.0016f, -0.0281f, 0.0016f, -0.0425f) + lineToRelative(9.0E-4f, -0.0331f) + lineTo(10.757f, 15.9193f) + arcToRelative(3.1918f, 3.1918f, 0.0f, false, false, -0.9547f, -2.2813f) + } + } + .build() + return _mangadexLogo!! + } + +private var _mangadexLogo: ImageVector? = null diff --git a/app/src/main/java/exh/assets/ehassets/__Exh.kt b/app/src/main/java/exh/assets/ehassets/__Exh.kt new file mode 100644 index 000000000..63b7cb6b5 --- /dev/null +++ b/app/src/main/java/exh/assets/ehassets/__Exh.kt @@ -0,0 +1,23 @@ +package exh.assets.ehassets + +import androidx.compose.ui.graphics.vector.ImageVector +import exh.assets.EhAssets +import exh.assets.ehassets.exh.AllAssets +import exh.assets.ehassets.exh.Assets +import kotlin.collections.List as ____KtList + +public object ExhGroup + +public val EhAssets.Exh: ExhGroup + get() = ExhGroup + +private var __AllAssets: ____KtList? = null + +public val ExhGroup.AllAssets: ____KtList + get() { + if (__AllAssets != null) { + return __AllAssets!! + } + __AllAssets = Assets.AllAssets + listOf() + return __AllAssets!! + } diff --git a/app/src/main/java/exh/assets/ehassets/exh/__Assets.kt b/app/src/main/java/exh/assets/ehassets/exh/__Assets.kt new file mode 100644 index 000000000..063319c91 --- /dev/null +++ b/app/src/main/java/exh/assets/ehassets/exh/__Assets.kt @@ -0,0 +1,23 @@ +package exh.assets.ehassets.exh + +import androidx.compose.ui.graphics.vector.ImageVector +import exh.assets.ehassets.ExhGroup +import exh.assets.ehassets.exh.assets.AllAssets +import exh.assets.ehassets.exh.assets.Ehassets +import kotlin.collections.List as ____KtList + +public object AssetsGroup + +public val ExhGroup.Assets: AssetsGroup + get() = AssetsGroup + +private var __AllAssets: ____KtList? = null + +public val AssetsGroup.AllAssets: ____KtList + get() { + if (__AllAssets != null) { + return __AllAssets!! + } + __AllAssets = Ehassets.AllAssets + listOf() + return __AllAssets!! + } diff --git a/app/src/main/java/exh/assets/ehassets/exh/assets/__Ehassets.kt b/app/src/main/java/exh/assets/ehassets/exh/assets/__Ehassets.kt new file mode 100644 index 000000000..80520f242 --- /dev/null +++ b/app/src/main/java/exh/assets/ehassets/exh/assets/__Ehassets.kt @@ -0,0 +1,21 @@ +package exh.assets.ehassets.exh.assets + +import androidx.compose.ui.graphics.vector.ImageVector +import exh.assets.ehassets.exh.AssetsGroup +import kotlin.collections.List as ____KtList + +public object EhassetsGroup + +public val AssetsGroup.Ehassets: EhassetsGroup + get() = EhassetsGroup + +private var __AllAssets: ____KtList? = null + +public val EhassetsGroup.AllAssets: ____KtList + get() { + if (__AllAssets != null) { + return __AllAssets!! + } + __AllAssets = listOf() + return __AllAssets!! + } diff --git a/app/src/main/java/exh/eh/EHentaiUpdateWorker.kt b/app/src/main/java/exh/eh/EHentaiUpdateWorker.kt index 01ab4fa84..df4624970 100644 --- a/app/src/main/java/exh/eh/EHentaiUpdateWorker.kt +++ b/app/src/main/java/exh/eh/EHentaiUpdateWorker.kt @@ -235,11 +235,11 @@ class EHentaiUpdateWorker(private val context: Context, workerParams: WorkerPara WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder().build()) } - fun scheduleBackground(context: Context, prefInterval: Int? = null) { + fun scheduleBackground(context: Context, prefInterval: Int? = null, prefRestrictions: Set? = null) { val preferences = Injekt.get() val interval = prefInterval ?: preferences.exhAutoUpdateFrequency().get() if (interval > 0) { - val restrictions = preferences.exhAutoUpdateRequirements().get() + val restrictions = prefRestrictions ?: preferences.exhAutoUpdateRequirements().get() val acRestriction = DEVICE_CHARGING in restrictions val constraints = Constraints.Builder() 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 2a167dea0..48951372e 100755 --- a/core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -73,6 +73,6 @@ open /* SY <-- */ 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 caea579e9..b75a13897 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 f92f0b8ba..4ae47aa97 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" }