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.GetCategories
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
import dev.achmad.ledgerr.domain.category.model.Category import dev.achmad.ledgerr.domain.category.model.Category
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CategoryScreenModel( class CategoryScreenModel(
private val getCategories: GetCategories = inject(), private val getCategories: GetCategories = inject(),
@@ -19,6 +22,7 @@ class CategoryScreenModel(
) : ScreenModel { ) : ScreenModel {
val categories: StateFlow<List<Category>> = getCategories.subscribeAll() val categories: StateFlow<List<Category>> = getCategories.subscribeAll()
.flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope = screenModelScope, scope = screenModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
@@ -27,13 +31,17 @@ class CategoryScreenModel(
fun upsert(category: Category) { fun upsert(category: Category) {
screenModelScope.launch { screenModelScope.launch {
upsertCategory.await(category) withContext(Dispatchers.IO) {
upsertCategory.await(category)
}
} }
} }
fun delete(id: Long) { fun delete(id: Long) {
screenModelScope.launch { screenModelScope.launch {
runCatching { deleteCategory.await(id) } 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.category.model.Category
import dev.achmad.ledgerr.domain.expense.interactor.InsertExpenses import dev.achmad.ledgerr.domain.expense.interactor.InsertExpenses
import dev.achmad.ledgerr.domain.expense.model.Expense import dev.achmad.ledgerr.domain.expense.model.Expense
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -18,9 +19,11 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
sealed interface ImportState { sealed interface ImportState {
data class BankPicker(val importers: List<BankStatementImporter>) : ImportState data class BankPicker(val importers: List<BankStatementImporter>) : ImportState
@@ -44,6 +47,7 @@ class ImportBankStatementScreenModel(
val snackbar: SharedFlow<String> = _snackbar.asSharedFlow() val snackbar: SharedFlow<String> = _snackbar.asSharedFlow()
val categories: StateFlow<List<Category>> = getCategories.subscribeAll() val categories: StateFlow<List<Category>> = getCategories.subscribeAll()
.flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope = screenModelScope, scope = screenModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
@@ -55,7 +59,9 @@ class ImportBankStatementScreenModel(
fun processPdf(uri: Uri, importer: BankStatementImporter) { fun processPdf(uri: Uri, importer: BankStatementImporter) {
screenModelScope.launch { screenModelScope.launch {
_state.value = ImportState.Processing(importer.bankName) _state.value = ImportState.Processing(importer.bankName)
val result = importer.await(uri) val result = withContext(Dispatchers.IO) {
importer.await(uri)
}
result result
.onSuccess { rows -> .onSuccess { rows ->
if (rows.isEmpty()) { if (rows.isEmpty()) {
@@ -105,23 +111,25 @@ class ImportBankStatementScreenModel(
fun confirm(navigator: Navigator) { fun confirm(navigator: Navigator) {
screenModelScope.launch { screenModelScope.launch {
val confirmation = _state.value as? ImportState.Confirmation ?: return@launch val confirmation = _state.value as? ImportState.Confirmation ?: return@launch
if (defaultCategoryId == 0L) {
defaultCategoryId = getCategories.awaitDefault().id
}
val selected = confirmation.rows.filter { it.isSelected } val selected = confirmation.rows.filter { it.isSelected }
if (selected.isEmpty()) { if (selected.isEmpty()) {
_snackbar.tryEmit("No items selected") _snackbar.tryEmit("No items selected")
return@launch return@launch
} }
val expenses = selected.map { pending -> withContext(Dispatchers.IO) {
Expense( if (defaultCategoryId == 0L) {
amount = pending.amount, defaultCategoryId = getCategories.awaitDefault().id
categoryId = pending.suggestedCategoryId ?: defaultCategoryId, }
date = pending.date, val expenses = selected.map { pending ->
note = pending.description, Expense(
) amount = pending.amount,
categoryId = pending.suggestedCategoryId ?: defaultCategoryId,
date = pending.date,
note = pending.description,
)
}
insertExpenses.awaitAll(expenses)
} }
insertExpenses.awaitAll(expenses)
navigator.pop() 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.data.interactor.ClearAllData
import dev.achmad.ledgerr.domain.expense.model.DateRange import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsScreenModel( class SettingsScreenModel(
private val exportExpensesToCsv: ExportExpensesToCsv = inject(), private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
@@ -18,15 +20,20 @@ class SettingsScreenModel(
fun exportToCsv(range: DateRange, uri: Uri, onResult: (Result<Unit>) -> Unit) { fun exportToCsv(range: DateRange, uri: Uri, onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch { screenModelScope.launch {
onResult(exportExpensesToCsv.await(range, uri)) val result = withContext(Dispatchers.IO) {
exportExpensesToCsv.await(range, uri)
}
onResult(result)
} }
} }
fun clearData(onResult: (Result<Unit>) -> Unit) { fun clearData(onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch { screenModelScope.launch {
val result = runCatching { val result = withContext(Dispatchers.IO) {
clearAllData.await() runCatching {
seedDefaultCategories.await() clearAllData.await()
seedDefaultCategories.await()
}
} }
onResult(result) onResult(result)
} }