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 package yokai.presentation.extension.repo
import androidx.compose.runtime.Composable 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 import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController
class ExtensionRepoController() : class ExtensionRepoController(private val repoUrl: String? = null) : BaseComposeController() {
BaseComposeController() {
private var repoUrl: String? = null
constructor(repoUrl: String) : this() {
this.repoUrl = repoUrl
}
@Composable @Composable
override fun ScreenContent() { override fun ScreenContent() {
ExtensionRepoScreen( Navigator(
title = "Extension Repos", screen = ExtensionRepoScreen(
repoUrl = repoUrl, 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp 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 dev.icerock.moko.resources.compose.stringResource
import eu.kanade.tachiyomi.util.compose.LocalBackPress import eu.kanade.tachiyomi.util.compose.LocalBackPress
import eu.kanade.tachiyomi.util.compose.LocalDialogHostState import eu.kanade.tachiyomi.util.compose.LocalDialogHostState
@ -43,190 +43,197 @@ import yokai.presentation.component.EmptyScreen
import yokai.presentation.component.ToolTipButton import yokai.presentation.component.ToolTipButton
import yokai.presentation.extension.repo.component.ExtensionRepoInput import yokai.presentation.extension.repo.component.ExtensionRepoInput
import yokai.presentation.extension.repo.component.ExtensionRepoItem import yokai.presentation.extension.repo.component.ExtensionRepoItem
import yokai.util.Screen
import android.R as AR import android.R as AR
@Composable class ExtensionRepoScreen(
fun ExtensionRepoScreen( private val title: String,
title: String, private var repoUrl: String? = null,
viewModel: ExtensionRepoViewModel = viewModel(), ): Screen() {
repoUrl: String? = null, @Composable
) { override fun Content() {
val onBackPress = LocalBackPress.currentOrThrow val onBackPress = LocalBackPress.currentOrThrow
val context = LocalContext.current val context = LocalContext.current
val alertDialog = LocalDialogHostState.currentOrThrow val alertDialog = LocalDialogHostState.currentOrThrow
val scope = rememberCoroutineScope()
val repoState by viewModel.repoState.collectAsState() val scope = rememberCoroutineScope()
var inputText by remember { mutableStateOf("") } val screenModel = rememberScreenModel { ExtensionRepoScreenModel() }
val listState = rememberLazyListState() val state by screenModel.state.collectAsState()
YokaiScaffold( var inputText by remember { mutableStateOf("") }
onNavigationIconClicked = onBackPress, val listState = rememberLazyListState()
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
val repos = (repoState as ExtensionRepoState.Success).repos YokaiScaffold(
onNavigationIconClicked = onBackPress,
alertDialog.value?.invoke() title = title,
appBarType = AppBarType.SMALL,
LazyColumn( scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
modifier = Modifier.padding(innerPadding), state = rememberTopAppBarState(),
userScrollEnabled = true, canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 },
verticalArrangement = Arrangement.Top, ),
state = listState, actions = {
) { ToolTipButton(
item { toolTipLabel = stringResource(MR.strings.refresh),
ExtensionRepoInput( icon = Icons.Outlined.Refresh,
inputText = inputText, buttonClicked = {
inputHint = stringResource(MR.strings.label_add_repo), context.toast("Refreshing...") // TODO: Should be loading animation instead
onInputChange = { inputText = it }, screenModel.refreshRepos()
onAddClick = { viewModel.addRepo(it) }, },
) )
} },
) { 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 { item {
EmptyScreen( ExtensionRepoInput(
modifier = Modifier.fillParentMaxSize(), inputText = inputText,
image = Icons.Filled.ExtensionOff, inputHint = stringResource(MR.strings.label_add_repo),
message = stringResource(MR.strings.information_empty_repos), onInputChange = { inputText = it },
isTablet = isTablet(), onAddClick = { screenModel.addRepo(it) },
) )
} }
return@LazyColumn
}
repos.forEach { repo -> if (repos.isEmpty()) {
item { item {
ExtensionRepoItem( EmptyScreen(
extensionRepo = repo, modifier = Modifier.fillParentMaxSize(),
onDeleteClick = { repoToDelete -> image = Icons.Filled.ExtensionOff,
scope.launch { alertDialog.awaitExtensionRepoDeletePrompt(repoToDelete, viewModel) } 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) { LaunchedEffect(repoUrl) {
repoUrl?.let { viewModel.addRepo(repoUrl) } repoUrl?.let {
} screenModel.addRepo(repoUrl!!)
repoUrl = null
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.event.collectLatest { event -> screenModel.event.collectLatest { event ->
when (event) { when (event) {
is ExtensionRepoEvent.NoOp -> {} is ExtensionRepoEvent.NoOp -> {}
is ExtensionRepoEvent.LocalizedMessage -> context.toast(event.stringRes) is ExtensionRepoEvent.LocalizedMessage -> context.toast(event.stringRes)
is ExtensionRepoEvent.Success -> inputText = "" is ExtensionRepoEvent.Success -> inputText = ""
is ExtensionRepoEvent.ShowDialog -> { is ExtensionRepoEvent.ShowDialog -> {
when(event.dialog) { when(event.dialog) {
is RepoDialog.Conflict -> { is RepoDialog.Conflict -> {
alertDialog.awaitExtensionRepoReplacePrompt( alertDialog.awaitExtensionRepoReplacePrompt(
oldRepo = event.dialog.oldRepo, oldRepo = event.dialog.oldRepo,
newRepo = event.dialog.newRepo, newRepo = event.dialog.newRepo,
onMigrate = { viewModel.replaceRepo(event.dialog.newRepo) }, onMigrate = { screenModel.replaceRepo(event.dialog.newRepo) },
) )
}
} }
} }
} }
} }
} }
} }
}
suspend fun DialogHostState.awaitExtensionRepoReplacePrompt( private suspend fun DialogHostState.awaitExtensionRepoReplacePrompt(
oldRepo: ExtensionRepo, oldRepo: ExtensionRepo,
newRepo: ExtensionRepo, newRepo: ExtensionRepo,
onMigrate: () -> Unit, onMigrate: () -> Unit,
): Unit = dialog { cont -> ): Unit = dialog { cont ->
AlertDialog( AlertDialog(
onDismissRequest = { cont.cancel() }, onDismissRequest = { cont.cancel() },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
onMigrate() onMigrate()
cont.cancel() cont.cancel()
}, },
) { ) {
Text(text = stringResource(MR.strings.action_replace_repo)) 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()
} }
) { },
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(
text = stringResource(MR.strings.delete), text = stringResource(MR.strings.confirm_delete_repo_title),
color = MaterialTheme.colorScheme.primary, 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, fontSize = 14.sp,
) )
} },
}, onDismissRequest = { cont.cancel() },
dismissButton = { confirmButton = {
TextButton(onClick = { cont.cancel() }) { TextButton(
Text( onClick = {
text = stringResource(MR.strings.cancel), screenModel.deleteRepo(repoToDelete)
color = MaterialTheme.colorScheme.primary, cont.cancel()
fontSize = 14.sp, }
) ) {
} 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 package yokai.presentation.extension.repo
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import cafe.adriel.voyager.core.model.StateScreenModel
import androidx.lifecycle.viewModelScope import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.util.system.launchIO 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.domain.extension.repo.model.ExtensionRepo
import yokai.i18n.MR import yokai.i18n.MR
class ExtensionRepoViewModel : class ExtensionRepoScreenModel : StateScreenModel<ExtensionRepoScreenModel.State>(State.Loading) {
ViewModel() {
private val extensionManager: ExtensionManager by injectLazy() private val extensionManager: ExtensionManager by injectLazy()
@ -33,23 +32,20 @@ class ExtensionRepoViewModel :
private val replaceExtensionRepo: ReplaceExtensionRepo by injectLazy() private val replaceExtensionRepo: ReplaceExtensionRepo by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo 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) private val internalEvent: MutableStateFlow<ExtensionRepoEvent> = MutableStateFlow(ExtensionRepoEvent.NoOp)
val event: StateFlow<ExtensionRepoEvent> = internalEvent.asStateFlow() val event: StateFlow<ExtensionRepoEvent> = internalEvent.asStateFlow()
init { init {
viewModelScope.launchIO { screenModelScope.launchIO {
getExtensionRepo.subscribeAll().collectLatest { repos -> getExtensionRepo.subscribeAll().collectLatest { repos ->
mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) } mutableState.update { State.Success(repos = repos.toImmutableList()) }
extensionManager.refreshTrust() extensionManager.refreshTrust()
} }
} }
} }
fun addRepo(url: String) { fun addRepo(url: String) {
viewModelScope.launchIO { screenModelScope.launchIO {
when (val result = createExtensionRepo.await(url)) { when (val result = createExtensionRepo.await(url)) {
is CreateExtensionRepo.Result.Success -> internalEvent.value = ExtensionRepoEvent.Success is CreateExtensionRepo.Result.Success -> internalEvent.value = ExtensionRepoEvent.Success
is CreateExtensionRepo.Result.Error -> internalEvent.value = ExtensionRepoEvent.InvalidUrl is CreateExtensionRepo.Result.Error -> internalEvent.value = ExtensionRepoEvent.InvalidUrl
@ -63,26 +59,41 @@ class ExtensionRepoViewModel :
} }
fun replaceRepo(newRepo: ExtensionRepo) { fun replaceRepo(newRepo: ExtensionRepo) {
viewModelScope.launchIO { screenModelScope.launchIO {
replaceExtensionRepo.await(newRepo) replaceExtensionRepo.await(newRepo)
} }
} }
fun refreshRepos() { fun refreshRepos() {
val status = repoState.value val status = state.value
if (status is ExtensionRepoState.Success) { if (status is State.Success) {
viewModelScope.launchIO { screenModelScope.launchIO {
updateExtensionRepo.awaitAll() updateExtensionRepo.awaitAll()
} }
} }
} }
fun deleteRepo(url: String) { fun deleteRepo(url: String) {
viewModelScope.launchIO { screenModelScope.launchIO {
deleteExtensionRepo.await(url) 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 { sealed class RepoDialog {
@ -98,17 +109,3 @@ sealed class ExtensionRepoEvent {
data object Success : 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()
}
}