fix(#7): move DB/IO off main thread in ScreenModels

screenModelScope is backed by PlatformMainDispatcher (Main.immediate),
so direct interactor calls run DB queries and file I/O on the UI
thread. Switch reactive flows with .flowOn(Dispatchers.IO) and wrap
suspend calls in withContext(Dispatchers.IO).
This commit is contained in:
Achmad Setyabudi Susilo
2026-06-28 18:10:35 +07:00
parent f6860544e4
commit 7782df8b36
3 changed files with 41 additions and 18 deletions
@@ -7,10 +7,13 @@ import dev.achmad.ledgerr.domain.category.interactor.DeleteCategory
import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
import dev.achmad.ledgerr.domain.category.model.Category
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CategoryScreenModel(
private val getCategories: GetCategories = inject(),
@@ -19,6 +22,7 @@ class CategoryScreenModel(
) : ScreenModel {
val categories: StateFlow<List<Category>> = getCategories.subscribeAll()
.flowOn(Dispatchers.IO)
.stateIn(
scope = screenModelScope,
started = SharingStarted.Eagerly,
@@ -27,13 +31,17 @@ class CategoryScreenModel(
fun upsert(category: Category) {
screenModelScope.launch {
withContext(Dispatchers.IO) {
upsertCategory.await(category)
}
}
}
fun delete(id: Long) {
screenModelScope.launch {
withContext(Dispatchers.IO) {
runCatching { deleteCategory.await(id) }
}
}
}
}
@@ -11,6 +11,7 @@ import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.model.Category
import dev.achmad.ledgerr.domain.expense.interactor.InsertExpenses
import dev.achmad.ledgerr.domain.expense.model.Expense
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -18,9 +19,11 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
sealed interface ImportState {
data class BankPicker(val importers: List<BankStatementImporter>) : ImportState
@@ -44,6 +47,7 @@ class ImportBankStatementScreenModel(
val snackbar: SharedFlow<String> = _snackbar.asSharedFlow()
val categories: StateFlow<List<Category>> = getCategories.subscribeAll()
.flowOn(Dispatchers.IO)
.stateIn(
scope = screenModelScope,
started = SharingStarted.Eagerly,
@@ -55,7 +59,9 @@ class ImportBankStatementScreenModel(
fun processPdf(uri: Uri, importer: BankStatementImporter) {
screenModelScope.launch {
_state.value = ImportState.Processing(importer.bankName)
val result = importer.await(uri)
val result = withContext(Dispatchers.IO) {
importer.await(uri)
}
result
.onSuccess { rows ->
if (rows.isEmpty()) {
@@ -105,14 +111,15 @@ class ImportBankStatementScreenModel(
fun confirm(navigator: Navigator) {
screenModelScope.launch {
val confirmation = _state.value as? ImportState.Confirmation ?: return@launch
if (defaultCategoryId == 0L) {
defaultCategoryId = getCategories.awaitDefault().id
}
val selected = confirmation.rows.filter { it.isSelected }
if (selected.isEmpty()) {
_snackbar.tryEmit("No items selected")
return@launch
}
withContext(Dispatchers.IO) {
if (defaultCategoryId == 0L) {
defaultCategoryId = getCategories.awaitDefault().id
}
val expenses = selected.map { pending ->
Expense(
amount = pending.amount,
@@ -122,6 +129,7 @@ class ImportBankStatementScreenModel(
)
}
insertExpenses.awaitAll(expenses)
}
navigator.pop()
}
}
@@ -8,7 +8,9 @@ import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories
import dev.achmad.ledgerr.domain.data.interactor.ClearAllData
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsScreenModel(
private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
@@ -18,16 +20,21 @@ class SettingsScreenModel(
fun exportToCsv(range: DateRange, uri: Uri, onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch {
onResult(exportExpensesToCsv.await(range, uri))
val result = withContext(Dispatchers.IO) {
exportExpensesToCsv.await(range, uri)
}
onResult(result)
}
}
fun clearData(onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch {
val result = runCatching {
val result = withContext(Dispatchers.IO) {
runCatching {
clearAllData.await()
seedDefaultCategories.await()
}
}
onResult(result)
}
}