diff --git a/app/src/main/java/eu/kanade/core/prefs/PreferenceMutableState.kt b/app/src/main/java/eu/kanade/core/prefs/PreferenceMutableState.kt new file mode 100644 index 0000000000..ba73475d60 --- /dev/null +++ b/app/src/main/java/eu/kanade/core/prefs/PreferenceMutableState.kt @@ -0,0 +1,38 @@ +package eu.kanade.core.prefs + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import com.fredporciuncula.flow.preferences.Preference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class PreferenceMutableState( + private val preference: Preference, + scope: CoroutineScope, +) : MutableState { + + private val state = mutableStateOf(preference.get()) + + init { + preference.asFlow() + .distinctUntilChanged() + .onEach { state.value = it } + .launchIn(scope) + } + + override var value: T + get() = state.value + set(value) { + preference.set(value) + } + + override fun component1(): T { + return state.value + } + + override fun component2(): (T) -> Unit { + return { preference.set(it) } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/components/Preferences.kt b/app/src/main/java/eu/kanade/presentation/components/Preferences.kt index 74343b4e1a..d0be06dede 100644 --- a/app/src/main/java/eu/kanade/presentation/components/Preferences.kt +++ b/app/src/main/java/eu/kanade/presentation/components/Preferences.kt @@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch 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.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp +import eu.kanade.core.prefs.PreferenceMutableState import eu.kanade.presentation.util.horizontalPadding @Composable @@ -29,7 +31,7 @@ fun Divider() { @Composable fun PreferenceRow( title: String, - icon: ImageVector? = null, + painter: Painter? = null, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, subtitle: String? = null, @@ -50,18 +52,18 @@ fun PreferenceRow( .heightIn(min = height) .combinedClickable( onLongClick = onLongClick, - onClick = onClick + onClick = onClick, ), verticalAlignment = Alignment.CenterVertically ) { - if (icon != null) { + if (painter != null) { Icon( - imageVector = icon, + painter = painter, modifier = Modifier .padding(horizontal = horizontalPadding) .size(24.dp), tint = MaterialTheme.colorScheme.primary, - contentDescription = null + contentDescription = null, ) } Column( @@ -88,3 +90,23 @@ fun PreferenceRow( } } } + +@Composable +fun SwitchPreference( + preference: PreferenceMutableState, + title: String, + subtitle: String? = null, + painter: Painter? = null, +) { + PreferenceRow( + title = title, + subtitle = subtitle, + painter = painter, + action = { + Switch(checked = preference.value, onCheckedChange = null) + // TODO: remove this once switch checked state is fixed: https://issuetracker.google.com/issues/228336571 + Text(preference.value.toString()) + }, + onClick = { preference.value = !preference.value }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt new file mode 100644 index 0000000000..945af6f141 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -0,0 +1,131 @@ +package eu.kanade.presentation.more + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudOff +import androidx.compose.material.icons.outlined.GetApp +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Label +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SettingsBackupRestore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.SwitchPreference +import eu.kanade.presentation.util.quantityStringResource +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.more.DownloadQueueState +import eu.kanade.tachiyomi.ui.more.MoreController +import eu.kanade.tachiyomi.ui.more.MorePresenter + +@Composable +fun MoreScreen( + nestedScrollInterop: NestedScrollConnection, + presenter: MorePresenter, + onClickDownloadQueue: () -> Unit, + onClickCategories: () -> Unit, + onClickBackupAndRestore: () -> Unit, + onClickSettings: () -> Unit, + onClickAbout: () -> Unit, +) { + val uriHandler = LocalUriHandler.current + val downloadQueueState by presenter.downloadQueueState.collectAsState() + + LazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + ) { + item { + LogoHeader() + } + + item { + SwitchPreference( + preference = presenter.downloadedOnly, + title = stringResource(R.string.label_downloaded_only), + subtitle = stringResource(R.string.downloaded_only_summary), + painter = rememberVectorPainter(Icons.Outlined.CloudOff), + ) + } + item { + SwitchPreference( + preference = presenter.incognitoMode, + title = stringResource(R.string.pref_incognito_mode), + subtitle = stringResource(R.string.pref_incognito_mode_summary), + painter = painterResource(R.drawable.ic_glasses_24dp), + ) + } + + item { Divider() } + + item { + PreferenceRow( + title = stringResource(R.string.label_download_queue), + subtitle = when (downloadQueueState) { + DownloadQueueState.Stopped -> null + is DownloadQueueState.Paused -> { + val pending = (downloadQueueState as DownloadQueueState.Paused).pending + if (pending == 0) { + stringResource(R.string.paused) + } else { + "${stringResource(R.string.paused)} • ${quantityStringResource(R.plurals.download_queue_summary, pending, pending)}" + } + } + is DownloadQueueState.Downloading -> { + val pending = (downloadQueueState as DownloadQueueState.Downloading).pending + quantityStringResource(R.plurals.download_queue_summary, pending, pending) + } + }, + painter = rememberVectorPainter(Icons.Outlined.GetApp), + onClick = { onClickDownloadQueue() }, + ) + } + item { + PreferenceRow( + title = stringResource(R.string.categories), + painter = rememberVectorPainter(Icons.Outlined.Label), + onClick = { onClickCategories() }, + ) + } + item { + PreferenceRow( + title = stringResource(R.string.label_backup), + painter = rememberVectorPainter(Icons.Outlined.SettingsBackupRestore), + onClick = { onClickBackupAndRestore() }, + ) + } + + item { Divider() } + + item { + PreferenceRow( + title = stringResource(R.string.label_settings), + painter = rememberVectorPainter(Icons.Outlined.Settings), + onClick = { onClickSettings() }, + ) + } + item { + PreferenceRow( + title = stringResource(R.string.pref_category_about), + painter = rememberVectorPainter(Icons.Outlined.Info), + onClick = { onClickAbout() }, + ) + } + item { + PreferenceRow( + title = stringResource(R.string.label_help), + painter = rememberVectorPainter(Icons.Outlined.HelpOutline), + onClick = { uriHandler.openUri(MoreController.URL_HELP) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Resources.kt b/app/src/main/java/eu/kanade/presentation/util/Resources.kt new file mode 100644 index 0000000000..51bdbe5a77 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Resources.kt @@ -0,0 +1,32 @@ +package eu.kanade.presentation.util + +import androidx.annotation.PluralsRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +/** + * Load a quantity string resource. + * + * @param id the resource identifier + * @param quantity The number used to get the string for the current language's plural rules. + * @return the string data associated with the resource + */ +@Composable +fun quantityStringResource(@PluralsRes id: Int, quantity: Int): String { + val context = LocalContext.current + return context.resources.getQuantityString(id, quantity, quantity) +} + +/** + * Load a quantity string resource with formatting. + * + * @param id the resource identifier + * @param quantity The number used to get the string for the current language's plural rules. + * @param formatArgs the format arguments + * @return the string data associated with the resource + */ +@Composable +fun quantityStringResource(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String { + val context = LocalContext.current + return context.resources.getQuantityString(id, quantity, *formatArgs) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt index c69e56024c..3a68f6479b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.ui.base.presenter import android.os.Bundle +import com.fredporciuncula.flow.preferences.Preference +import eu.kanade.core.prefs.PreferenceMutableState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel @@ -10,7 +12,7 @@ import rx.Observable open class BasePresenter : RxPresenter() { - lateinit var presenterScope: CoroutineScope + var presenterScope: CoroutineScope = MainScope() /** * Query from the view where applicable @@ -20,7 +22,6 @@ open class BasePresenter : RxPresenter() { override fun onCreate(savedState: Bundle?) { try { super.onCreate(savedState) - presenterScope = MainScope() } catch (e: NullPointerException) { // Swallow this error. This should be fixed in the library but since it's not critical // (only used by restartables) it should be enough. It saves me a fork. @@ -38,6 +39,8 @@ open class BasePresenter : RxPresenter() { return super.getView() } + fun Preference.asState() = PreferenceMutableState(this, presenterScope) + /** * Subscribes an observable with [deliverFirst] and adds it to the presenter's lifecycle * subscription list. 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 9cc77dcd42..1b09c6be22 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 @@ -1,187 +1,38 @@ package eu.kanade.tachiyomi.ui.more -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.preference.Preference -import androidx.preference.PreferenceScreen +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import eu.kanade.presentation.more.MoreScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.category.CategoryController import eu.kanade.tachiyomi.ui.download.DownloadController import eu.kanade.tachiyomi.ui.setting.SettingsBackupController -import eu.kanade.tachiyomi.ui.setting.SettingsController import eu.kanade.tachiyomi.ui.setting.SettingsMainController -import eu.kanade.tachiyomi.util.preference.add -import eu.kanade.tachiyomi.util.preference.bindTo -import eu.kanade.tachiyomi.util.preference.iconRes -import eu.kanade.tachiyomi.util.preference.iconTint -import eu.kanade.tachiyomi.util.preference.onClick -import eu.kanade.tachiyomi.util.preference.preference -import eu.kanade.tachiyomi.util.preference.preferenceCategory -import eu.kanade.tachiyomi.util.preference.summaryRes -import eu.kanade.tachiyomi.util.preference.switchPreference -import eu.kanade.tachiyomi.util.preference.titleRes -import eu.kanade.tachiyomi.util.system.getResourceColor -import eu.kanade.tachiyomi.util.system.openInBrowser -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.subscriptions.CompositeSubscription -import uy.kohesive.injekt.injectLazy class MoreController : - SettingsController(), + ComposeController(), RootController, NoAppBarElevationController { - private val downloadManager: DownloadManager by injectLazy() - private var isDownloading: Boolean = false - private var downloadQueueSize: Int = 0 - - private var untilDestroySubscriptions = CompositeSubscription() - private set - - override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { - titleRes = R.string.label_more - - val tintColor = context.getResourceColor(R.attr.colorAccent) - - add(MoreHeaderPreference(context)) - - switchPreference { - bindTo(preferences.downloadedOnly()) - titleRes = R.string.label_downloaded_only - summaryRes = R.string.downloaded_only_summary - iconRes = R.drawable.ic_cloud_off_24dp - iconTint = tintColor - } - - switchPreference { - bindTo(preferences.incognitoMode()) - summaryRes = R.string.pref_incognito_mode_summary - titleRes = R.string.pref_incognito_mode - iconRes = R.drawable.ic_glasses_24dp - iconTint = tintColor - - preferences.incognitoMode().asFlow() - .onEach { isChecked = it } - .launchIn(viewScope) - } - - preferenceCategory { - preference { - titleRes = R.string.label_download_queue - - if (downloadManager.queue.isNotEmpty()) { - initDownloadQueueSummary(this) - } - - iconRes = R.drawable.ic_get_app_24dp - iconTint = tintColor - onClick { - router.pushController(DownloadController()) - } - } - preference { - titleRes = R.string.categories - iconRes = R.drawable.ic_label_24dp - iconTint = tintColor - onClick { - router.pushController(CategoryController()) - } - } - preference { - titleRes = R.string.label_backup - iconRes = R.drawable.ic_settings_backup_restore_24dp - iconTint = tintColor - onClick { - router.pushController(SettingsBackupController()) - } - } - } - - preferenceCategory { - preference { - titleRes = R.string.label_settings - iconRes = R.drawable.ic_settings_24dp - iconTint = tintColor - onClick { - router.pushController(SettingsMainController()) - } - } - preference { - iconRes = R.drawable.ic_info_24dp - iconTint = tintColor - titleRes = R.string.pref_category_about - onClick { - router.pushController(AboutController()) - } - } - preference { - titleRes = R.string.label_help - iconRes = R.drawable.ic_help_24dp - iconTint = tintColor - onClick { - activity?.openInBrowser(URL_HELP) - } - } - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View { - if (untilDestroySubscriptions.isUnsubscribed) { - untilDestroySubscriptions = CompositeSubscription() - } - - return super.onCreateView(inflater, container, savedInstanceState) - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - untilDestroySubscriptions.unsubscribe() - } - - private fun initDownloadQueueSummary(preference: Preference) { - // Handle running/paused status change - DownloadService.runningRelay - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { isRunning -> - isDownloading = isRunning - updateDownloadQueueSummary(preference) - } - - // Handle queue progress updating - downloadManager.queue.getUpdatedObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { - downloadQueueSize = it.size - updateDownloadQueueSummary(preference) - } - } - - private fun updateDownloadQueueSummary(preference: Preference) { - var pendingDownloadExists = downloadQueueSize != 0 - var pauseMessage = resources?.getString(R.string.paused) - var numberOfPendingDownloads = resources?.getQuantityString(R.plurals.download_queue_summary, downloadQueueSize, downloadQueueSize) - - preference.summary = when { - !pendingDownloadExists -> null - !isDownloading && !pendingDownloadExists -> pauseMessage - !isDownloading && pendingDownloadExists -> "$pauseMessage • $numberOfPendingDownloads" - else -> numberOfPendingDownloads - } - } - - private fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { - return subscribe(onNext).also { untilDestroySubscriptions.add(it) } + override fun getTitle() = resources?.getString(R.string.label_more) + + override fun createPresenter() = MorePresenter() + + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + MoreScreen( + nestedScrollInterop = nestedScrollInterop, + presenter = presenter, + onClickDownloadQueue = { router.pushController(DownloadController()) }, + onClickCategories = { router.pushController(CategoryController()) }, + onClickBackupAndRestore = { router.pushController(SettingsBackupController()) }, + onClickSettings = { router.pushController(SettingsMainController()) }, + onClickAbout = { router.pushController(AboutController()) }, + ) } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MorePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MorePresenter.kt new file mode 100644 index 0000000000..9ceef98567 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MorePresenter.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.ui.more + +import android.os.Bundle +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.subscriptions.CompositeSubscription +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class MorePresenter( + private val downloadManager: DownloadManager = Injekt.get(), + preferencesHelper: PreferencesHelper = Injekt.get(), +) : BasePresenter() { + + val downloadedOnly = preferencesHelper.downloadedOnly().asState() + val incognitoMode = preferencesHelper.incognitoMode().asState() + + private var _state: MutableStateFlow = MutableStateFlow(DownloadQueueState.Stopped) + val downloadQueueState: StateFlow = _state + + private var isDownloading: Boolean = false + private var downloadQueueSize: Int = 0 + private var untilDestroySubscriptions = CompositeSubscription() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + if (untilDestroySubscriptions.isUnsubscribed) { + untilDestroySubscriptions = CompositeSubscription() + } + + initDownloadQueueSummary() + } + + override fun onDestroy() { + super.onDestroy() + untilDestroySubscriptions.unsubscribe() + } + + private fun initDownloadQueueSummary() { + // Handle running/paused status change + DownloadService.runningRelay + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { isRunning -> + isDownloading = isRunning + updateDownloadQueueState() + } + + // Handle queue progress updating + downloadManager.queue.getUpdatedObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { + downloadQueueSize = it.size + updateDownloadQueueState() + } + } + + private fun updateDownloadQueueState() { + presenterScope.launchIO { + val pendingDownloadExists = downloadQueueSize != 0 + _state.emit( + when { + !pendingDownloadExists -> DownloadQueueState.Stopped + !isDownloading && !pendingDownloadExists -> DownloadQueueState.Paused(0) + !isDownloading && pendingDownloadExists -> DownloadQueueState.Paused(downloadQueueSize) + else -> DownloadQueueState.Downloading(downloadQueueSize) + } + ) + } + } + + private fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { + return subscribe(onNext).also { untilDestroySubscriptions.add(it) } + } +} + +sealed class DownloadQueueState { + object Stopped : DownloadQueueState() + data class Paused(val pending: Int) : DownloadQueueState() + data class Downloading(val pending: Int) : DownloadQueueState() +}