Use Voyager on Source Category screen

This commit is contained in:
Jobobby04 2022-11-10 23:17:20 -05:00
parent 0d638b1c1e
commit 02954670d4
7 changed files with 256 additions and 203 deletions

View File

@ -1,32 +1,28 @@
package eu.kanade.presentation.category package eu.kanade.presentation.category
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.category.components.sources.SourceCategoryContent import eu.kanade.presentation.category.components.sources.SourceCategoryContent
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.sources.SourceCategoryPresenter import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreenState
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryPresenter.Dialog
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable @Composable
fun SourceCategoryScreen( fun SourceCategoryScreen(
presenter: SourceCategoryPresenter, state: SourceCategoryScreenState.Success,
onClickCreate: () -> Unit,
onClickRename: (String) -> Unit,
onClickDelete: (String) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@ -41,63 +37,24 @@ fun SourceCategoryScreen(
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() textResource = R.string.information_empty_category,
presenter.isEmpty -> EmptyScreen(textResource = R.string.information_empty_category) modifier = Modifier.padding(paddingValues),
else -> { )
SourceCategoryContent( return@Scaffold
state = presenter,
lazyListState = lazyListState,
paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
)
}
} }
val onDismissRequest = { presenter.dialog = null } SourceCategoryContent(
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,
title = stringResource(R.string.action_add_category), )
)
}
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) },
title = stringResource(R.string.delete_category),
text = stringResource(R.string.delete_category_confirmation, dialog.category),
)
}
else -> {}
}
LaunchedEffect(Unit) {
presenter.events.collectLatest { event ->
when (event) {
is SourceCategoryPresenter.Event.CategoryExists -> {
context.toast(R.string.error_category_exists)
}
is SourceCategoryPresenter.Event.InternalError -> {
context.toast(R.string.internal_error)
}
SourceCategoryPresenter.Event.InvalidName -> {
context.toast(R.string.invalid_category_name)
}
}
}
}
} }
} }

View File

@ -1,27 +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.tachiyomi.ui.category.sources.SourceCategoryPresenter
@Stable
interface SourceCategoryState {
val isLoading: Boolean
var dialog: SourceCategoryPresenter.Dialog?
val categories: List<String>
val isEmpty: Boolean
}
fun SourceCategoryState(): SourceCategoryState {
return SourceCategoryStateImpl()
}
class SourceCategoryStateImpl : SourceCategoryState {
override var isLoading: Boolean by mutableStateOf(true)
override var dialog: SourceCategoryPresenter.Dialog? by mutableStateOf(null)
override var categories: List<String> by mutableStateOf(emptyList())
override val isEmpty: Boolean by derivedStateOf { categories.isEmpty() }
}

View File

@ -7,28 +7,27 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable 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.presentation.category.SourceCategoryState
import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.LazyColumn
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryPresenter
@Composable @Composable
fun SourceCategoryContent( fun SourceCategoryContent(
state: SourceCategoryState, categories: List<String>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
onClickRename: (String) -> Unit,
onClickDelete: (String) -> Unit,
) { ) {
val categories = state.categories
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = paddingValues, contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(categories) { category -> items(categories, key = { it }) { category ->
SourceCategoryListItem( SourceCategoryListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
category = category, category = category,
onRename = { state.dialog = SourceCategoryPresenter.Dialog.Rename(category) }, onRename = { onClickRename(category) },
onDelete = { state.dialog = SourceCategoryPresenter.Dialog.Delete(category) }, onDelete = { onClickDelete(category) },
) )
} }
} }

View File

@ -1,21 +1,20 @@
package eu.kanade.tachiyomi.ui.category.sources package eu.kanade.tachiyomi.ui.category.sources
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import eu.kanade.presentation.category.SourceCategoryScreen 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
/** /**
* Controller to manage the categories for the users' library. * Controller to manage the categories for the users' library.
*/ */
class SourceCategoryController : FullComposeController<SourceCategoryPresenter>() { class SourceCategoryController : BasicFullComposeController() {
override fun createPresenter() = SourceCategoryPresenter()
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
SourceCategoryScreen( CompositionLocalProvider(LocalRouter provides router) {
presenter = presenter, Navigator(screen = SourceCategoryScreen())
navigateUp = router::popCurrentController, }
)
} }
} }

View File

@ -1,96 +0,0 @@
package eu.kanade.tachiyomi.ui.category.sources
import android.os.Bundle
import eu.kanade.domain.source.interactor.CreateSourceCategory
import eu.kanade.domain.source.interactor.DeleteSourceCategory
import eu.kanade.domain.source.interactor.GetSourceCategories
import eu.kanade.domain.source.interactor.RenameSourceCategory
import eu.kanade.presentation.category.SourceCategoryState
import eu.kanade.presentation.category.SourceCategoryStateImpl
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
/**
* Presenter of [SourceCategoryController]. Used to manage the categories of the library.
*/
class SourceCategoryPresenter(
private val state: SourceCategoryStateImpl = SourceCategoryState() as SourceCategoryStateImpl,
private val getSourceCategories: GetSourceCategories = Injekt.get(),
private val createSourceCategory: CreateSourceCategory = Injekt.get(),
private val renameSourceCategory: RenameSourceCategory = Injekt.get(),
private val deleteSourceCategory: DeleteSourceCategory = Injekt.get(),
) : BasePresenter<SourceCategoryController>(), SourceCategoryState 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 {
getSourceCategories.subscribe()
.collectLatest {
state.isLoading = false
state.categories = it
}
}
}
/**
* Creates and adds a new category to the database.
*
* @param name The name of the category to create.
*/
fun createCategory(name: String) {
presenterScope.launchIO {
when (createSourceCategory.await(name)) {
is CreateSourceCategory.Result.CategoryExists -> _events.send(Event.CategoryExists)
is CreateSourceCategory.Result.InvalidName -> _events.send(Event.InvalidName)
else -> {}
}
}
}
/**
* Deletes the given categories from the database.
*
* @param categories The list of categories to delete.
*/
fun deleteCategory(categories: String) {
presenterScope.launchIO {
deleteSourceCategory.await(categories)
}
}
/**
* Renames a category.
*
* @param categoryOld The category to rename.
* @param categoryNew The new name of the category.
*/
fun renameCategory(categoryOld: String, categoryNew: String) {
presenterScope.launchIO {
when (renameSourceCategory.await(categoryOld, categoryNew)) {
is CreateSourceCategory.Result.CategoryExists -> _events.send(Event.CategoryExists)
is CreateSourceCategory.Result.InvalidName -> _events.send(Event.InvalidName)
else -> {}
}
}
}
sealed class Event {
object CategoryExists : Event()
object InvalidName : Event()
object InternalError : Event()
}
sealed class Dialog {
object Create : Dialog()
data class Rename(val category: String) : Dialog()
data class Delete(val category: String) : Dialog()
}
}

View File

@ -0,0 +1,87 @@
package eu.kanade.tachiyomi.ui.category.sources
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 androidx.compose.ui.res.stringResource
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.SourceCategoryScreen
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.R
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
class SourceCategoryScreen : Screen {
@Composable
override fun Content() {
val context = LocalContext.current
val router = LocalRouter.currentOrThrow
val screenModel = rememberScreenModel { SourceCategoryScreenModel() }
val state by screenModel.state.collectAsState()
if (state is SourceCategoryScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as SourceCategoryScreenState.Success
SourceCategoryScreen(
state = successState,
onClickCreate = { screenModel.showDialog(SourceCategoryDialog.Create) },
onClickRename = { screenModel.showDialog(SourceCategoryDialog.Rename(it)) },
onClickDelete = { screenModel.showDialog(SourceCategoryDialog.Delete(it)) },
navigateUp = router::popCurrentController,
)
when (val dialog = successState.dialog) {
null -> {}
SourceCategoryDialog.Create -> {
CategoryCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createCategory(it) },
// SY -->
title = stringResource(R.string.action_add_category),
// SY <--
)
}
is SourceCategoryDialog.Rename -> {
CategoryRenameDialog(
onDismissRequest = screenModel::dismissDialog,
onRename = { screenModel.renameCategory(dialog.category, it) },
// SY -->
category = dialog.category,
// SY <--
)
}
is SourceCategoryDialog.Delete -> {
CategoryDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteCategory(dialog.category) },
// SY -->
title = stringResource(R.string.delete_category),
text = stringResource(R.string.delete_category_confirmation, dialog.category),
// SY <--
)
}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is SourceCategoryEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
}
}

View File

@ -0,0 +1,134 @@
package eu.kanade.tachiyomi.ui.category.sources
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.source.interactor.CreateSourceCategory
import eu.kanade.domain.source.interactor.DeleteSourceCategory
import eu.kanade.domain.source.interactor.GetSourceCategories
import eu.kanade.domain.source.interactor.RenameSourceCategory
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.update
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of [SourceCategoryController]. Used to manage the categories of the library.
*/
class SourceCategoryScreenModel(
private val getSourceCategories: GetSourceCategories = Injekt.get(),
private val createSourceCategory: CreateSourceCategory = Injekt.get(),
private val renameSourceCategory: RenameSourceCategory = Injekt.get(),
private val deleteSourceCategory: DeleteSourceCategory = Injekt.get(),
) : StateScreenModel<SourceCategoryScreenState>(SourceCategoryScreenState.Loading) {
private val _events: Channel<SourceCategoryEvent> = Channel(Int.MAX_VALUE)
val events = _events.consumeAsFlow()
init {
coroutineScope.launchIO {
getSourceCategories.subscribe()
.collectLatest { categories ->
mutableState.update {
SourceCategoryScreenState.Success(
categories = categories,
)
}
}
}
}
/**
* Creates and adds a new category to the database.
*
* @param name The name of the category to create.
*/
fun createCategory(name: String) {
coroutineScope.launchIO {
when (createSourceCategory.await(name)) {
is CreateSourceCategory.Result.CategoryExists -> _events.send(SourceCategoryEvent.CategoryExists)
is CreateSourceCategory.Result.InvalidName -> _events.send(SourceCategoryEvent.InvalidName)
else -> {}
}
}
}
/**
* Deletes the given categories from the database.
*
* @param categories The list of categories to delete.
*/
fun deleteCategory(categories: String) {
coroutineScope.launchIO {
deleteSourceCategory.await(categories)
}
}
/**
* Renames a category.
*
* @param categoryOld The category to rename.
* @param categoryNew The new name of the category.
*/
fun renameCategory(categoryOld: String, categoryNew: String) {
coroutineScope.launchIO {
when (renameSourceCategory.await(categoryOld, categoryNew)) {
is CreateSourceCategory.Result.CategoryExists -> _events.send(SourceCategoryEvent.CategoryExists)
is CreateSourceCategory.Result.InvalidName -> _events.send(SourceCategoryEvent.InvalidName)
else -> {}
}
}
}
fun showDialog(dialog: SourceCategoryDialog) {
mutableState.update {
when (it) {
SourceCategoryScreenState.Loading -> it
is SourceCategoryScreenState.Success -> it.copy(dialog = dialog)
}
}
}
fun dismissDialog() {
mutableState.update {
when (it) {
SourceCategoryScreenState.Loading -> it
is SourceCategoryScreenState.Success -> it.copy(dialog = null)
}
}
}
}
sealed class SourceCategoryEvent {
sealed class LocalizedMessage(@StringRes val stringRes: Int) : SourceCategoryEvent()
object CategoryExists : LocalizedMessage(R.string.error_category_exists)
object InvalidName : LocalizedMessage(R.string.invalid_category_name)
object InternalError : LocalizedMessage(R.string.internal_error)
}
sealed class SourceCategoryDialog {
object Create : SourceCategoryDialog()
data class Rename(val category: String) : SourceCategoryDialog()
data class Delete(val category: String) : SourceCategoryDialog()
}
sealed class SourceCategoryScreenState {
@Immutable
object Loading : SourceCategoryScreenState()
@Immutable
data class Success(
val categories: List<String>,
val dialog: SourceCategoryDialog? = null,
) : SourceCategoryScreenState() {
val isEmpty: Boolean
get() = categories.isEmpty()
}
}