Migrate More screen to Compose (#6990)

This commit is contained in:
arkon 2022-04-23 15:51:50 -04:00 committed by GitHub
parent 8933b41937
commit c25cffafc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 341 additions and 175 deletions

View file

@ -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<T>(
private val preference: Preference<T>,
scope: CoroutineScope,
) : MutableState<T> {
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) }
}
}

View file

@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import eu.kanade.core.prefs.PreferenceMutableState
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
@Composable @Composable
@ -29,7 +31,7 @@ fun Divider() {
@Composable @Composable
fun PreferenceRow( fun PreferenceRow(
title: String, title: String,
icon: ImageVector? = null, painter: Painter? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
subtitle: String? = null, subtitle: String? = null,
@ -50,18 +52,18 @@ fun PreferenceRow(
.heightIn(min = height) .heightIn(min = height)
.combinedClickable( .combinedClickable(
onLongClick = onLongClick, onLongClick = onLongClick,
onClick = onClick onClick = onClick,
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (icon != null) { if (painter != null) {
Icon( Icon(
imageVector = icon, painter = painter,
modifier = Modifier modifier = Modifier
.padding(horizontal = horizontalPadding) .padding(horizontal = horizontalPadding)
.size(24.dp), .size(24.dp),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
contentDescription = null contentDescription = null,
) )
} }
Column( Column(
@ -88,3 +90,23 @@ fun PreferenceRow(
} }
} }
} }
@Composable
fun SwitchPreference(
preference: PreferenceMutableState<Boolean>,
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 },
)
}

View file

@ -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) },
)
}
}
}

View file

@ -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)
}

View file

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.ui.base.presenter package eu.kanade.tachiyomi.ui.base.presenter
import android.os.Bundle import android.os.Bundle
import com.fredporciuncula.flow.preferences.Preference
import eu.kanade.core.prefs.PreferenceMutableState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -10,7 +12,7 @@ import rx.Observable
open class BasePresenter<V> : RxPresenter<V>() { open class BasePresenter<V> : RxPresenter<V>() {
lateinit var presenterScope: CoroutineScope var presenterScope: CoroutineScope = MainScope()
/** /**
* Query from the view where applicable * Query from the view where applicable
@ -20,7 +22,6 @@ open class BasePresenter<V> : RxPresenter<V>() {
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
try { try {
super.onCreate(savedState) super.onCreate(savedState)
presenterScope = MainScope()
} catch (e: NullPointerException) { } catch (e: NullPointerException) {
// Swallow this error. This should be fixed in the library but since it's not critical // 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. // (only used by restartables) it should be enough. It saves me a fork.
@ -38,6 +39,8 @@ open class BasePresenter<V> : RxPresenter<V>() {
return super.getView() return super.getView()
} }
fun <T> Preference<T>.asState() = PreferenceMutableState(this, presenterScope)
/** /**
* Subscribes an observable with [deliverFirst] and adds it to the presenter's lifecycle * Subscribes an observable with [deliverFirst] and adds it to the presenter's lifecycle
* subscription list. * subscription list.

View file

@ -1,187 +1,38 @@
package eu.kanade.tachiyomi.ui.more package eu.kanade.tachiyomi.ui.more
import android.os.Bundle import androidx.compose.runtime.Composable
import android.view.LayoutInflater import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import android.view.View import eu.kanade.presentation.more.MoreScreen
import android.view.ViewGroup
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.ui.base.controller.ComposeController
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController
import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.category.CategoryController import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.download.DownloadController import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.setting.SettingsBackupController 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.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 : class MoreController :
SettingsController(), ComposeController<MorePresenter>(),
RootController, RootController,
NoAppBarElevationController { NoAppBarElevationController {
private val downloadManager: DownloadManager by injectLazy() override fun getTitle() = resources?.getString(R.string.label_more)
private var isDownloading: Boolean = false
private var downloadQueueSize: Int = 0
private var untilDestroySubscriptions = CompositeSubscription() override fun createPresenter() = MorePresenter()
private set
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { @Composable
titleRes = R.string.label_more override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
MoreScreen(
val tintColor = context.getResourceColor(R.attr.colorAccent) nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
add(MoreHeaderPreference(context)) onClickDownloadQueue = { router.pushController(DownloadController()) },
onClickCategories = { router.pushController(CategoryController()) },
switchPreference { onClickBackupAndRestore = { router.pushController(SettingsBackupController()) },
bindTo(preferences.downloadedOnly()) onClickSettings = { router.pushController(SettingsMainController()) },
titleRes = R.string.label_downloaded_only onClickAbout = { router.pushController(AboutController()) },
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 <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription {
return subscribe(onNext).also { untilDestroySubscriptions.add(it) }
} }
companion object { companion object {

View file

@ -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<MoreController>() {
val downloadedOnly = preferencesHelper.downloadedOnly().asState()
val incognitoMode = preferencesHelper.incognitoMode().asState()
private var _state: MutableStateFlow<DownloadQueueState> = MutableStateFlow(DownloadQueueState.Stopped)
val downloadQueueState: StateFlow<DownloadQueueState> = _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 <T> Observable<T>.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()
}