Use Voyager on Category screen (#8472)

This commit is contained in:
Andreas 2022-11-08 04:13:14 +01:00 committed by GitHub
parent 9c9357639a
commit bf9edda04c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 253 additions and 204 deletions

View file

@ -4,31 +4,28 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.category.components.CategoryContent import eu.kanade.presentation.category.components.CategoryContent
import eu.kanade.presentation.category.components.CategoryCreateDialog
import eu.kanade.presentation.category.components.CategoryDeleteDialog
import eu.kanade.presentation.category.components.CategoryFloatingActionButton import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.category.components.CategoryRenameDialog
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.topPaddingValues import eu.kanade.presentation.util.topPaddingValues
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.category.CategoryPresenter import eu.kanade.tachiyomi.ui.category.CategoryScreenState
import eu.kanade.tachiyomi.ui.category.CategoryPresenter.Dialog
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable @Composable
fun CategoryScreen( fun CategoryScreen(
presenter: CategoryPresenter, state: CategoryScreenState.Success,
onClickCreate: () -> Unit,
onClickRename: (Category) -> Unit,
onClickDelete: (Category) -> Unit,
onClickMoveUp: (Category) -> Unit,
onClickMoveDown: (Category) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@ -43,63 +40,26 @@ fun CategoryScreen(
floatingActionButton = { floatingActionButton = {
CategoryFloatingActionButton( CategoryFloatingActionButton(
lazyListState = lazyListState, lazyListState = lazyListState,
onCreate = { presenter.dialog = Dialog.Create }, onCreate = onClickCreate,
) )
}, },
) { paddingValues -> ) { paddingValues ->
val context = LocalContext.current if (state.isEmpty) {
when { EmptyScreen(
presenter.isLoading -> LoadingScreen()
presenter.isEmpty -> EmptyScreen(
textResource = R.string.information_empty_category, textResource = R.string.information_empty_category,
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
) )
else -> { return@Scaffold
CategoryContent(
state = presenter,
lazyListState = lazyListState,
paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
onMoveUp = { presenter.moveUp(it) },
onMoveDown = { presenter.moveDown(it) },
)
}
} }
val onDismissRequest = { presenter.dialog = null } CategoryContent(
when (val dialog = presenter.dialog) { categories = state.categories,
Dialog.Create -> { lazyListState = lazyListState,
CategoryCreateDialog( paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
onDismissRequest = onDismissRequest, onClickRename = onClickRename,
onCreate = { presenter.createCategory(it) }, onClickDelete = onClickDelete,
) onMoveUp = onClickMoveUp,
} onMoveDown = onClickMoveDown,
is Dialog.Rename -> { )
CategoryRenameDialog(
onDismissRequest = onDismissRequest,
onRename = { presenter.renameCategory(dialog.category, it) },
category = dialog.category,
)
}
is Dialog.Delete -> {
CategoryDeleteDialog(
onDismissRequest = onDismissRequest,
onDelete = { presenter.deleteCategory(dialog.category) },
category = dialog.category,
)
}
else -> {}
}
LaunchedEffect(Unit) {
presenter.events.collectLatest { event ->
when (event) {
is CategoryPresenter.Event.CategoryWithNameAlreadyExists -> {
context.toast(R.string.error_category_exists)
}
is CategoryPresenter.Event.InternalError -> {
context.toast(R.string.internal_error)
}
}
}
}
} }
} }

View file

@ -1,28 +0,0 @@
package eu.kanade.presentation.category
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.ui.category.CategoryPresenter
@Stable
interface CategoryState {
val isLoading: Boolean
var dialog: CategoryPresenter.Dialog?
val categories: List<Category>
val isEmpty: Boolean
}
fun CategoryState(): CategoryState {
return CategoryStateImpl()
}
class CategoryStateImpl : CategoryState {
override var isLoading: Boolean by mutableStateOf(true)
override var dialog: CategoryPresenter.Dialog? by mutableStateOf(null)
override var categories: List<Category> by mutableStateOf(emptyList())
override val isEmpty: Boolean by derivedStateOf { categories.isEmpty() }
}

View file

@ -8,19 +8,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.domain.category.model.Category import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.category.CategoryState
import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.LazyColumn
import eu.kanade.tachiyomi.ui.category.CategoryPresenter.Dialog
@Composable @Composable
fun CategoryContent( fun CategoryContent(
state: CategoryState, categories: List<Category>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
onClickRename: (Category) -> Unit,
onClickDelete: (Category) -> Unit,
onMoveUp: (Category) -> Unit, onMoveUp: (Category) -> Unit,
onMoveDown: (Category) -> Unit, onMoveDown: (Category) -> Unit,
) { ) {
val categories = state.categories
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = paddingValues, contentPadding = paddingValues,
@ -37,8 +36,8 @@ fun CategoryContent(
canMoveDown = index != categories.lastIndex, canMoveDown = index != categories.lastIndex,
onMoveUp = onMoveUp, onMoveUp = onMoveUp,
onMoveDown = onMoveDown, onMoveDown = onMoveDown,
onRename = { state.dialog = Dialog.Rename(category) }, onRename = { onClickRename(category) },
onDelete = { state.dialog = Dialog.Delete(category) }, onDelete = { onClickDelete(category) },
) )
} }
} }

View file

@ -1,18 +1,17 @@
package eu.kanade.tachiyomi.ui.category package eu.kanade.tachiyomi.ui.category
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import eu.kanade.presentation.category.CategoryScreen import androidx.compose.runtime.CompositionLocalProvider
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
class CategoryController : FullComposeController<CategoryPresenter>() { class CategoryController : BasicFullComposeController() {
override fun createPresenter() = CategoryPresenter()
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
CategoryScreen( CompositionLocalProvider(LocalRouter provides router) {
presenter = presenter, Navigator(screen = CategoryScreen())
navigateUp = router::popCurrentController, }
)
} }
} }

View file

@ -1,100 +0,0 @@
package eu.kanade.tachiyomi.ui.category
import android.os.Bundle
import eu.kanade.domain.category.interactor.CreateCategoryWithName
import eu.kanade.domain.category.interactor.DeleteCategory
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.RenameCategory
import eu.kanade.domain.category.interactor.ReorderCategory
import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.category.CategoryState
import eu.kanade.presentation.category.CategoryStateImpl
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class CategoryPresenter(
private val state: CategoryStateImpl = CategoryState() as CategoryStateImpl,
private val getCategories: GetCategories = Injekt.get(),
private val createCategoryWithName: CreateCategoryWithName = Injekt.get(),
private val renameCategory: RenameCategory = Injekt.get(),
private val reorderCategory: ReorderCategory = Injekt.get(),
private val deleteCategory: DeleteCategory = Injekt.get(),
) : BasePresenter<CategoryController>(), CategoryState by state {
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events = _events.consumeAsFlow()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
presenterScope.launchIO {
getCategories.subscribe()
.collectLatest {
state.isLoading = false
state.categories = it.filterNot(Category::isSystemCategory)
}
}
}
fun createCategory(name: String) {
presenterScope.launchIO {
when (createCategoryWithName.await(name)) {
is CreateCategoryWithName.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
is CreateCategoryWithName.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
fun deleteCategory(category: Category) {
presenterScope.launchIO {
when (deleteCategory.await(category.id)) {
is DeleteCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
fun moveUp(category: Category) {
presenterScope.launchIO {
when (reorderCategory.await(category, category.order - 1)) {
is ReorderCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
fun moveDown(category: Category) {
presenterScope.launchIO {
when (reorderCategory.await(category, category.order + 1)) {
is ReorderCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
fun renameCategory(category: Category, name: String) {
presenterScope.launchIO {
when (renameCategory.await(category, name)) {
RenameCategory.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
is RenameCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
sealed class Dialog {
object Create : Dialog()
data class Rename(val category: Category) : Dialog()
data class Delete(val category: Category) : Dialog()
}
sealed class Event {
object CategoryWithNameAlreadyExists : Event()
object InternalError : Event()
}
}

View file

@ -0,0 +1,79 @@
package eu.kanade.tachiyomi.ui.category
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.category.CategoryScreen
import eu.kanade.presentation.category.components.CategoryCreateDialog
import eu.kanade.presentation.category.components.CategoryDeleteDialog
import eu.kanade.presentation.category.components.CategoryRenameDialog
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
class CategoryScreen : Screen {
@Composable
override fun Content() {
val context = LocalContext.current
val router = LocalRouter.currentOrThrow
val screenModel = rememberScreenModel { CategoryScreenModel() }
val state by screenModel.state.collectAsState()
if (state is CategoryScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as CategoryScreenState.Success
CategoryScreen(
state = successState,
onClickCreate = { screenModel.showDialog(CategoryDialog.Create) },
onClickRename = { screenModel.showDialog(CategoryDialog.Rename(it)) },
onClickDelete = { screenModel.showDialog(CategoryDialog.Delete(it)) },
onClickMoveUp = screenModel::moveUp,
onClickMoveDown = screenModel::moveDown,
navigateUp = router::popCurrentController,
)
when (val dialog = successState.dialog) {
null -> {}
CategoryDialog.Create -> {
CategoryCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createCategory(it) },
)
}
is CategoryDialog.Rename -> {
CategoryRenameDialog(
onDismissRequest = screenModel::dismissDialog,
onRename = { screenModel.renameCategory(dialog.category, it) },
category = dialog.category,
)
}
is CategoryDialog.Delete -> {
CategoryDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteCategory(dialog.category.id) },
category = dialog.category,
)
}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is CategoryEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
}
}

View file

@ -0,0 +1,140 @@
package eu.kanade.tachiyomi.ui.category
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.domain.category.interactor.CreateCategoryWithName
import eu.kanade.domain.category.interactor.DeleteCategory
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.RenameCategory
import eu.kanade.domain.category.interactor.ReorderCategory
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.R
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class CategoryScreenModel(
private val getCategories: GetCategories = Injekt.get(),
private val createCategoryWithName: CreateCategoryWithName = Injekt.get(),
private val deleteCategory: DeleteCategory = Injekt.get(),
private val reorderCategory: ReorderCategory = Injekt.get(),
private val renameCategory: RenameCategory = Injekt.get(),
) : StateScreenModel<CategoryScreenState>(CategoryScreenState.Loading) {
private val _events: Channel<CategoryEvent> = Channel()
val events = _events.consumeAsFlow()
init {
coroutineScope.launch {
getCategories.subscribe()
.collectLatest { categories ->
mutableState.update {
CategoryScreenState.Success(
categories = categories.filterNot(Category::isSystemCategory),
)
}
}
}
}
fun createCategory(name: String) {
coroutineScope.launch {
when (createCategoryWithName.await(name)) {
is CreateCategoryWithName.Result.InternalError -> _events.send(CategoryEvent.InternalError)
CreateCategoryWithName.Result.NameAlreadyExistsError -> _events.send(CategoryEvent.CategoryWithNameAlreadyExists)
CreateCategoryWithName.Result.Success -> {}
}
}
}
fun deleteCategory(categoryId: Long) {
coroutineScope.launch {
when (deleteCategory.await(categoryId = categoryId)) {
is DeleteCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError)
DeleteCategory.Result.Success -> {}
}
}
}
fun moveUp(category: Category) {
coroutineScope.launch {
when (reorderCategory.await(category, category.order - 1)) {
is ReorderCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError)
ReorderCategory.Result.Success -> {}
ReorderCategory.Result.Unchanged -> {}
}
}
}
fun moveDown(category: Category) {
coroutineScope.launch {
when (reorderCategory.await(category, category.order + 1)) {
is ReorderCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError)
ReorderCategory.Result.Success -> {}
ReorderCategory.Result.Unchanged -> {}
}
}
}
fun renameCategory(category: Category, name: String) {
coroutineScope.launch {
when (renameCategory.await(category, name)) {
is RenameCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError)
RenameCategory.Result.NameAlreadyExistsError -> _events.send(CategoryEvent.CategoryWithNameAlreadyExists)
RenameCategory.Result.Success -> {}
}
}
}
fun showDialog(dialog: CategoryDialog) {
mutableState.update {
when (it) {
CategoryScreenState.Loading -> it
is CategoryScreenState.Success -> it.copy(dialog = dialog)
}
}
}
fun dismissDialog() {
mutableState.update {
when (it) {
CategoryScreenState.Loading -> it
is CategoryScreenState.Success -> it.copy(dialog = null)
}
}
}
}
sealed class CategoryDialog {
object Create : CategoryDialog()
data class Rename(val category: Category) : CategoryDialog()
data class Delete(val category: Category) : CategoryDialog()
}
sealed class CategoryEvent {
sealed class LocalizedMessage(@StringRes val stringRes: Int) : CategoryEvent()
object CategoryWithNameAlreadyExists : LocalizedMessage(R.string.error_category_exists)
object InternalError : LocalizedMessage(R.string.internal_error)
}
sealed class CategoryScreenState {
@Immutable
object Loading : CategoryScreenState()
@Immutable
data class Success(
val categories: List<Category>,
val dialog: CategoryDialog? = null,
) : CategoryScreenState() {
val isEmpty: Boolean
get() = categories.isEmpty()
}
}