Coverage Summary for Class: CsvBackupViewModel (com.georgeci.moneysurfer.feature.settings.csv)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CsvBackupViewModel |
77.8%
(7/9)
|
68.8%
(11/16)
|
85.7%
(24/28)
|
74.1%
(149/201)
|
| CsvBackupViewModel$handleOpenSource$3 |
100%
(1/1)
|
|
100%
(8/8)
|
100%
(59/59)
|
| CsvBackupViewModel$handleSaveSink$3 |
100%
(1/1)
|
|
100%
(3/3)
|
100%
(58/58)
|
| Total |
81.8%
(9/11)
|
68.8%
(11/16)
|
89.7%
(35/39)
|
83.6%
(266/318)
|
package com.georgeci.moneysurfer.feature.settings.csv
import com.georgeci.moneysurfer.domain.csv.CsvImportReport
import com.georgeci.moneysurfer.domain.csv.ExportTransactionsUseCase
import com.georgeci.moneysurfer.domain.csv.ImportTransactionsUseCase
import com.georgeci.moneysurfer.domain.csv.TransactionCsvError
import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
import com.georgeci.moneysurfer.utils.MviViewModel
import kotlinx.coroutines.CancellationException
import okio.BufferedSink
import okio.BufferedSource
import okio.use
import org.koin.core.annotation.KoinViewModel
@KoinViewModel
class CsvBackupViewModel(
private val exportTransactions: ExportTransactionsUseCase,
private val importTransactions: ImportTransactionsUseCase,
private val clock: ClockUseCase,
) : MviViewModel<CsvBackupState, CsvBackupEvent, CsvBackupEffect>(initialState = CsvBackupState()) {
override fun onEvent(event: CsvBackupEvent) {
when (event) {
CsvBackupEvent.OnBackClick -> postSideEffect(CsvBackupEffect.NavigateBack)
CsvBackupEvent.OnExportClick ->
postSideEffect(CsvBackupEffect.RequestSaveFile(suggestedFilename()))
is CsvBackupEvent.OnSaveSinkChosen -> handleSaveSink(event.sink)
CsvBackupEvent.OnImportClick -> postSideEffect(CsvBackupEffect.RequestOpenFile)
is CsvBackupEvent.OnOpenSourceChosen -> handleOpenSource(event.source)
}
}
private fun handleSaveSink(sink: BufferedSink?) {
if (sink == null) return
updateState { copy(phase = CsvBackupPhase.Exporting) }
launch(
onError = { error ->
// MviViewModel.launch catches Exception (including cancellation);
// rethrow so a cleared ViewModel doesn't surface as a fake error.
if (error is CancellationException) throw error
updateState { copy(phase = CsvBackupPhase.Idle) }
postSideEffect(CsvBackupEffect.Notify(CsvBackupNotice.fromError(error)))
runCatching { sink.close() }
},
) {
val count = sink.use { exportTransactions(it).getOrThrow() }
updateState { copy(phase = CsvBackupPhase.Idle) }
postSideEffect(CsvBackupEffect.Notify(CsvBackupNotice.ExportSuccess(count)))
}
}
private fun handleOpenSource(source: BufferedSource?) {
if (source == null) return
updateState { copy(phase = CsvBackupPhase.Importing) }
launch(
onError = { error ->
if (error is CancellationException) throw error
updateState { copy(phase = CsvBackupPhase.Idle) }
postSideEffect(CsvBackupEffect.Notify(CsvBackupNotice.fromError(error)))
runCatching { source.close() }
},
) {
val report = source.use { importTransactions(it).getOrThrow() }
updateState { copy(phase = CsvBackupPhase.Idle, lastImportReport = report) }
postSideEffect(
CsvBackupEffect.Notify(
CsvBackupNotice.ImportFinished(
imported = report.imported,
skipped = report.skippedDuplicates,
failed = report.errors.size,
),
),
)
}
}
private fun suggestedFilename(): String {
val now = clock.now().toEpochMilliseconds()
return "moneysurfer-transactions-$now.csv"
}
}
data class CsvBackupState(
val phase: CsvBackupPhase = CsvBackupPhase.Idle,
val lastImportReport: CsvImportReport? = null,
)
enum class CsvBackupPhase { Idle, Exporting, Importing }
/**
* User-facing notice raised by the CSV flow. Resolution to localised strings
* happens in the screen layer via Compose resources — keeping the ViewModel
* free of platform string APIs.
*/
sealed interface CsvBackupNotice {
data class ExportSuccess(val count: Int) : CsvBackupNotice
data class ImportFinished(val imported: Int, val skipped: Int, val failed: Int) : CsvBackupNotice
data object InvalidFile : CsvBackupNotice
data object FileTooLarge : CsvBackupNotice
data object Generic : CsvBackupNotice
companion object {
fun fromError(error: Throwable): CsvBackupNotice = when (error) {
is TransactionCsvError.EmptyFile,
is TransactionCsvError.HeaderMismatch,
-> InvalidFile
is TransactionCsvError.FileTooLarge -> FileTooLarge
else -> Generic
}
}
}
sealed interface CsvBackupEvent {
data object OnBackClick : CsvBackupEvent
data object OnExportClick : CsvBackupEvent
data class OnSaveSinkChosen(val sink: BufferedSink?) : CsvBackupEvent
data object OnImportClick : CsvBackupEvent
data class OnOpenSourceChosen(val source: BufferedSource?) : CsvBackupEvent
}
sealed interface CsvBackupEffect {
data object NavigateBack : CsvBackupEffect
data class RequestSaveFile(val suggestedName: String) : CsvBackupEffect
data object RequestOpenFile : CsvBackupEffect
data class Notify(val notice: CsvBackupNotice) : CsvBackupEffect
}