refactor(extension/repo): Use ScreenModel instead of ViewModel

This commit is contained in:
Ahmad Ansori Palembani 2025-01-07 07:56:43 +07:00
parent d655c3e09a
commit d0d322fd67
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
3 changed files with 205 additions and 201 deletions

View file

@ -1,22 +1,22 @@
package yokai.presentation.extension.repo
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.CrossfadeTransition
import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController
class ExtensionRepoController() :
BaseComposeController() {
private var repoUrl: String? = null
constructor(repoUrl: String) : this() {
this.repoUrl = repoUrl
}
class ExtensionRepoController(private val repoUrl: String? = null) : BaseComposeController() {
@Composable
override fun ScreenContent() {
ExtensionRepoScreen(
title = "Extension Repos",
repoUrl = repoUrl,
Navigator(
screen = ExtensionRepoScreen(
title = "Extension Repos",
repoUrl = repoUrl,
),
content = {
CrossfadeTransition(navigator = it)
},
)
}
}

View file

@ -25,7 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import dev.icerock.moko.resources.compose.stringResource
import eu.kanade.tachiyomi.util.compose.LocalBackPress
import eu.kanade.tachiyomi.util.compose.LocalDialogHostState
@ -43,190 +43,197 @@ import yokai.presentation.component.EmptyScreen
import yokai.presentation.component.ToolTipButton
import yokai.presentation.extension.repo.component.ExtensionRepoInput
import yokai.presentation.extension.repo.component.ExtensionRepoItem
import yokai.util.Screen
import android.R as AR
@Composable
fun ExtensionRepoScreen(
title: String,
viewModel: ExtensionRepoViewModel = viewModel(),
repoUrl: String? = null,
) {
val onBackPress = LocalBackPress.currentOrThrow
val context = LocalContext.current
val alertDialog = LocalDialogHostState.currentOrThrow
val scope = rememberCoroutineScope()
class ExtensionRepoScreen(
private val title: String,
private var repoUrl: String? = null,
): Screen() {
@Composable
override fun Content() {
val onBackPress = LocalBackPress.currentOrThrow
val context = LocalContext.current
val alertDialog = LocalDialogHostState.currentOrThrow
val repoState by viewModel.repoState.collectAsState()
var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val screenModel = rememberScreenModel { ExtensionRepoScreenModel() }
val state by screenModel.state.collectAsState()
YokaiScaffold(
onNavigationIconClicked = onBackPress,
title = title,
appBarType = AppBarType.SMALL,
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 },
),
actions = {
ToolTipButton(
toolTipLabel = stringResource(MR.strings.refresh),
icon = Icons.Outlined.Refresh,
buttonClicked = {
context.toast("Refreshing...") // TODO: Should be loading animation instead
viewModel.refreshRepos()
},
)
},
) { innerPadding ->
if (repoState is ExtensionRepoState.Loading) return@YokaiScaffold
var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val repos = (repoState as ExtensionRepoState.Success).repos
alertDialog.value?.invoke()
LazyColumn(
modifier = Modifier.padding(innerPadding),
userScrollEnabled = true,
verticalArrangement = Arrangement.Top,
state = listState,
) {
item {
ExtensionRepoInput(
inputText = inputText,
inputHint = stringResource(MR.strings.label_add_repo),
onInputChange = { inputText = it },
onAddClick = { viewModel.addRepo(it) },
YokaiScaffold(
onNavigationIconClicked = onBackPress,
title = title,
appBarType = AppBarType.SMALL,
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 },
),
actions = {
ToolTipButton(
toolTipLabel = stringResource(MR.strings.refresh),
icon = Icons.Outlined.Refresh,
buttonClicked = {
context.toast("Refreshing...") // TODO: Should be loading animation instead
screenModel.refreshRepos()
},
)
}
},
) { innerPadding ->
if (state is ExtensionRepoScreenModel.State.Loading) return@YokaiScaffold
if (repos.isEmpty()) {
val repos = (state as ExtensionRepoScreenModel.State.Success).repos
alertDialog.value?.invoke()
LazyColumn(
modifier = Modifier.padding(innerPadding),
userScrollEnabled = true,
verticalArrangement = Arrangement.Top,
state = listState,
) {
item {
EmptyScreen(
modifier = Modifier.fillParentMaxSize(),
image = Icons.Filled.ExtensionOff,
message = stringResource(MR.strings.information_empty_repos),
isTablet = isTablet(),
ExtensionRepoInput(
inputText = inputText,
inputHint = stringResource(MR.strings.label_add_repo),
onInputChange = { inputText = it },
onAddClick = { screenModel.addRepo(it) },
)
}
return@LazyColumn
}
repos.forEach { repo ->
item {
ExtensionRepoItem(
extensionRepo = repo,
onDeleteClick = { repoToDelete ->
scope.launch { alertDialog.awaitExtensionRepoDeletePrompt(repoToDelete, viewModel) }
},
)
if (repos.isEmpty()) {
item {
EmptyScreen(
modifier = Modifier.fillParentMaxSize(),
image = Icons.Filled.ExtensionOff,
message = stringResource(MR.strings.information_empty_repos),
isTablet = isTablet(),
)
}
return@LazyColumn
}
repos.forEach { repo ->
item {
ExtensionRepoItem(
extensionRepo = repo,
onDeleteClick = { repoToDelete ->
scope.launch { alertDialog.awaitExtensionRepoDeletePrompt(repoToDelete, screenModel) }
},
)
}
}
}
}
}
LaunchedEffect(repoUrl) {
repoUrl?.let { viewModel.addRepo(repoUrl) }
}
LaunchedEffect(repoUrl) {
repoUrl?.let {
screenModel.addRepo(repoUrl!!)
repoUrl = null
}
}
LaunchedEffect(Unit) {
viewModel.event.collectLatest { event ->
when (event) {
is ExtensionRepoEvent.NoOp -> {}
is ExtensionRepoEvent.LocalizedMessage -> context.toast(event.stringRes)
is ExtensionRepoEvent.Success -> inputText = ""
is ExtensionRepoEvent.ShowDialog -> {
when(event.dialog) {
is RepoDialog.Conflict -> {
alertDialog.awaitExtensionRepoReplacePrompt(
oldRepo = event.dialog.oldRepo,
newRepo = event.dialog.newRepo,
onMigrate = { viewModel.replaceRepo(event.dialog.newRepo) },
)
LaunchedEffect(Unit) {
screenModel.event.collectLatest { event ->
when (event) {
is ExtensionRepoEvent.NoOp -> {}
is ExtensionRepoEvent.LocalizedMessage -> context.toast(event.stringRes)
is ExtensionRepoEvent.Success -> inputText = ""
is ExtensionRepoEvent.ShowDialog -> {
when(event.dialog) {
is RepoDialog.Conflict -> {
alertDialog.awaitExtensionRepoReplacePrompt(
oldRepo = event.dialog.oldRepo,
newRepo = event.dialog.newRepo,
onMigrate = { screenModel.replaceRepo(event.dialog.newRepo) },
)
}
}
}
}
}
}
}
}
suspend fun DialogHostState.awaitExtensionRepoReplacePrompt(
oldRepo: ExtensionRepo,
newRepo: ExtensionRepo,
onMigrate: () -> Unit,
): Unit = dialog { cont ->
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(
onClick = {
onMigrate()
cont.cancel()
},
) {
Text(text = stringResource(MR.strings.action_replace_repo))
}
},
dismissButton = {
TextButton(onClick = { cont.cancel() }) {
Text(text = stringResource(AR.string.cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_replace_repo_title))
},
text = {
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
},
)
}
suspend fun DialogHostState.awaitExtensionRepoDeletePrompt(
repoToDelete: String,
viewModel: ExtensionRepoViewModel,
): Unit = dialog { cont ->
AlertDialog(
containerColor = MaterialTheme.colorScheme.surface,
title = {
Text(
text = stringResource(MR.strings.confirm_delete_repo_title),
fontStyle = MaterialTheme.typography.titleMedium.fontStyle,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 24.sp,
)
},
text = {
Text(
text = stringResource(MR.strings.confirm_delete_repo, repoToDelete),
fontStyle = MaterialTheme.typography.bodyMedium.fontStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
)
},
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(
onClick = {
viewModel.deleteRepo(repoToDelete)
cont.cancel()
private suspend fun DialogHostState.awaitExtensionRepoReplacePrompt(
oldRepo: ExtensionRepo,
newRepo: ExtensionRepo,
onMigrate: () -> Unit,
): Unit = dialog { cont ->
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(
onClick = {
onMigrate()
cont.cancel()
},
) {
Text(text = stringResource(MR.strings.action_replace_repo))
}
) {
},
dismissButton = {
TextButton(onClick = { cont.cancel() }) {
Text(text = stringResource(AR.string.cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_replace_repo_title))
},
text = {
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
},
)
}
private suspend fun DialogHostState.awaitExtensionRepoDeletePrompt(
repoToDelete: String,
screenModel: ExtensionRepoScreenModel,
): Unit = dialog { cont ->
AlertDialog(
containerColor = MaterialTheme.colorScheme.surface,
title = {
Text(
text = stringResource(MR.strings.delete),
color = MaterialTheme.colorScheme.primary,
text = stringResource(MR.strings.confirm_delete_repo_title),
fontStyle = MaterialTheme.typography.titleMedium.fontStyle,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 24.sp,
)
},
text = {
Text(
text = stringResource(MR.strings.confirm_delete_repo, repoToDelete),
fontStyle = MaterialTheme.typography.bodyMedium.fontStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
)
}
},
dismissButton = {
TextButton(onClick = { cont.cancel() }) {
Text(
text = stringResource(MR.strings.cancel),
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
}
},
)
},
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(
onClick = {
screenModel.deleteRepo(repoToDelete)
cont.cancel()
}
) {
Text(
text = stringResource(MR.strings.delete),
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
}
},
dismissButton = {
TextButton(onClick = { cont.cancel() }) {
Text(
text = stringResource(MR.strings.cancel),
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
}
},
)
}
}

View file

@ -1,8 +1,8 @@
package yokai.presentation.extension.repo
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.util.system.launchIO
@ -22,8 +22,7 @@ import yokai.domain.extension.repo.interactor.UpdateExtensionRepo
import yokai.domain.extension.repo.model.ExtensionRepo
import yokai.i18n.MR
class ExtensionRepoViewModel :
ViewModel() {
class ExtensionRepoScreenModel : StateScreenModel<ExtensionRepoScreenModel.State>(State.Loading) {
private val extensionManager: ExtensionManager by injectLazy()
@ -33,23 +32,20 @@ class ExtensionRepoViewModel :
private val replaceExtensionRepo: ReplaceExtensionRepo by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val mutableRepoState: MutableStateFlow<ExtensionRepoState> = MutableStateFlow(ExtensionRepoState.Loading)
val repoState: StateFlow<ExtensionRepoState> = mutableRepoState.asStateFlow()
private val internalEvent: MutableStateFlow<ExtensionRepoEvent> = MutableStateFlow(ExtensionRepoEvent.NoOp)
val event: StateFlow<ExtensionRepoEvent> = internalEvent.asStateFlow()
init {
viewModelScope.launchIO {
screenModelScope.launchIO {
getExtensionRepo.subscribeAll().collectLatest { repos ->
mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) }
mutableState.update { State.Success(repos = repos.toImmutableList()) }
extensionManager.refreshTrust()
}
}
}
fun addRepo(url: String) {
viewModelScope.launchIO {
screenModelScope.launchIO {
when (val result = createExtensionRepo.await(url)) {
is CreateExtensionRepo.Result.Success -> internalEvent.value = ExtensionRepoEvent.Success
is CreateExtensionRepo.Result.Error -> internalEvent.value = ExtensionRepoEvent.InvalidUrl
@ -63,26 +59,41 @@ class ExtensionRepoViewModel :
}
fun replaceRepo(newRepo: ExtensionRepo) {
viewModelScope.launchIO {
screenModelScope.launchIO {
replaceExtensionRepo.await(newRepo)
}
}
fun refreshRepos() {
val status = repoState.value
val status = state.value
if (status is ExtensionRepoState.Success) {
viewModelScope.launchIO {
if (status is State.Success) {
screenModelScope.launchIO {
updateExtensionRepo.awaitAll()
}
}
}
fun deleteRepo(url: String) {
viewModelScope.launchIO {
screenModelScope.launchIO {
deleteExtensionRepo.await(url)
}
}
sealed interface State {
@Immutable
data object Loading : State
@Immutable
data class Success(
val repos: ImmutableList<ExtensionRepo>,
) : State {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}
}
sealed class RepoDialog {
@ -98,17 +109,3 @@ sealed class ExtensionRepoEvent {
data object Success : ExtensionRepoEvent()
}
sealed class ExtensionRepoState {
@Immutable
data object Loading : ExtensionRepoState()
@Immutable
data class Success(
val repos: ImmutableList<ExtensionRepo>,
) : ExtensionRepoState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}