Coverage Summary for Class: WorkspaceCreationEffect (com.georgeci.moneysurfer.feature.workspace.creation)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| WorkspaceCreationEffect$NavigateBack |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(2/2)
|
| Total |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(2/2)
|
package com.georgeci.moneysurfer.feature.workspace.creation
import arrow.optics.optics
import co.touchlab.kermit.Logger
import com.georgeci.moneysurfer.domain.config.HostCapabilities
import com.georgeci.moneysurfer.domain.model.Currency
import com.georgeci.moneysurfer.domain.model.WorkspaceRole
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
import com.georgeci.moneysurfer.domain.usecase.CreateWorkspaceError
import com.georgeci.moneysurfer.domain.usecase.CreateWorkspaceUseCase
import com.georgeci.moneysurfer.domain.usecase.GetCurrenciesUseCase
import com.georgeci.moneysurfer.utils.MviViewModel
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.KoinViewModel
@KoinViewModel
class WorkspaceCreationViewModel(
private val workspaceRepository: WorkspaceRepository,
private val createWorkspace: CreateWorkspaceUseCase,
private val getCurrencies: GetCurrenciesUseCase,
hostCapabilities: HostCapabilities,
) : MviViewModel<WorkspaceCreationState, WorkspaceCreationEvent, WorkspaceCreationEffect>(
initialState = WorkspaceCreationState.Loading,
) {
private val isOffline: Boolean = hostCapabilities.isOffline
private val log = Logger.withTag(TAG)
init { loadCurrencies() }
fun init(workspaceId: WorkspaceId?) {
if (workspaceId == null) return
val current = currentState
if (current is WorkspaceCreationState.Content && current.workspaceId == workspaceId) return
launch {
val workspace = workspaceRepository.getById(workspaceId) ?: return@launch
updateState {
when (this) {
is WorkspaceCreationState.Loading -> WorkspaceCreationState.Content(
workspaceId = workspace.id,
name = workspace.name,
description = workspace.description,
currency = workspace.baseCurrency.value,
currencies = emptyList(),
members = PREVIEW_MEMBERS,
isEditing = true,
isOffline = isOffline,
)
is WorkspaceCreationState.Content -> copy(
workspaceId = workspace.id,
name = workspace.name,
description = workspace.description,
currency = workspace.baseCurrency.value,
isEditing = true,
)
}
}
}
}
override fun onEvent(event: WorkspaceCreationEvent) {
when (event) {
is WorkspaceCreationEvent.OnNameChanged ->
updateState {
WorkspaceCreationState.content.modify(this) { it.copy(name = event.name, error = null) }
}
is WorkspaceCreationEvent.OnDescriptionChanged ->
updateState {
WorkspaceCreationState.content.modify(this) {
it.copy(description = event.description, error = null)
}
}
is WorkspaceCreationEvent.OnCurrencyChanged ->
updateState {
WorkspaceCreationState.content.modify(this) {
it.copy(currency = event.currency, error = null)
}
}
WorkspaceCreationEvent.OnInviteMemberClick -> Unit
WorkspaceCreationEvent.OnSaveClick -> saveWorkspace()
WorkspaceCreationEvent.OnBackClick -> postSideEffect(WorkspaceCreationEffect.NavigateBack)
}
}
private fun loadCurrencies() {
launch {
val currencies = getCurrencies().first()
val defaultCurrency = currencies.firstOrNull()?.code?.value.orEmpty()
updateState {
when (this) {
is WorkspaceCreationState.Loading -> WorkspaceCreationState.Content(
workspaceId = null,
name = "",
description = "",
currency = defaultCurrency,
currencies = currencies,
members = PREVIEW_MEMBERS,
isEditing = false,
isOffline = isOffline,
)
is WorkspaceCreationState.Content -> copy(
currencies = currencies,
currency = currency.ifBlank { defaultCurrency },
)
}
}
}
}
private fun saveWorkspace() {
val state = currentState as? WorkspaceCreationState.Content ?: return
if (state.name.isBlank()) return
if (state.isSaving) return
launch(
onError = { err ->
log.w(err) { "[save] unexpected exception" }
updateState {
WorkspaceCreationState.content.modify(this) {
it.copy(isSaving = false, error = WorkspaceCreationError.Unknown)
}
}
},
) {
updateState {
WorkspaceCreationState.content.modify(this) {
it.copy(isSaving = true, error = null)
}
}
val existingId = state.workspaceId
if (existingId != null) {
val existing = workspaceRepository.getById(existingId) ?: run {
updateState {
WorkspaceCreationState.content.modify(this) {
it.copy(isSaving = false, error = WorkspaceCreationError.Unknown)
}
}
return@launch
}
workspaceRepository.update(
existing.copy(
name = state.name.trim(),
description = state.description.trim(),
baseCurrency = CurrencyCode(state.currency),
),
)
postSideEffect(WorkspaceCreationEffect.NavigateBack)
} else {
createWorkspace(
CreateWorkspaceUseCase.Params(
name = state.name,
description = state.description,
baseCurrency = CurrencyCode(state.currency),
),
).fold(
ifLeft = { err ->
val mapped = err.toUi()
log.w { "[save] failed -> $mapped" }
updateState {
WorkspaceCreationState.content.modify(this) {
it.copy(isSaving = false, error = mapped)
}
}
},
ifRight = {
log.i { "[save] ok wid=${it.value}" }
postSideEffect(WorkspaceCreationEffect.NavigateBack)
},
)
}
}
}
private companion object {
const val TAG = "WorkspaceCreationVM"
}
}
private fun CreateWorkspaceError.toUi(): WorkspaceCreationError = when (this) {
is CreateWorkspaceError.NoCurrentUser -> WorkspaceCreationError.Unknown
is CreateWorkspaceError.LocalWriteFailed -> WorkspaceCreationError.Unknown
is CreateWorkspaceError.RemoteSyncFailed -> WorkspaceCreationError.RemoteSyncFailed
}
@optics
sealed interface WorkspaceCreationState {
data object Loading : WorkspaceCreationState
@optics
data class Content(
val workspaceId: WorkspaceId?,
val name: String,
val description: String,
val currency: String,
val currencies: List<Currency>,
val members: List<WorkspaceMemberUi>,
val isEditing: Boolean,
val isSaving: Boolean = false,
val error: WorkspaceCreationError? = null,
val isOffline: Boolean = false,
) : WorkspaceCreationState {
/**
* The Members section (invite row + member list) is remote-only. Offline workspaces
* are always single-user, so the section is hidden in the offline build to avoid
* implying collaboration that isn't supported.
*/
val showMembersSection: Boolean
get() = isEditing && !isOffline
companion object
}
companion object
}
enum class WorkspaceCreationError {
/** Remote `syncWorkspace` push failed — workspace exists locally but isn't synced. */
RemoteSyncFailed,
/** Catch-all for unclassified failures (no current user, local Room write blew up, etc.). */
Unknown,
}
data class WorkspaceMemberUi(
val name: String,
val role: WorkspaceRole,
val initial: String,
)
sealed interface WorkspaceCreationEvent {
data class OnNameChanged(val name: String) : WorkspaceCreationEvent
data class OnDescriptionChanged(val description: String) : WorkspaceCreationEvent
data class OnCurrencyChanged(val currency: String) : WorkspaceCreationEvent
data object OnInviteMemberClick : WorkspaceCreationEvent
data object OnSaveClick : WorkspaceCreationEvent
data object OnBackClick : WorkspaceCreationEvent
}
sealed interface WorkspaceCreationEffect {
data object NavigateBack : WorkspaceCreationEffect
}
// Placeholder members — replace with real data once a members feed is wired for the
// creation/edit flow. The screen stays useful for design review in the meantime.
private val PREVIEW_MEMBERS = listOf(
WorkspaceMemberUi("Kasia Nowak", WorkspaceRole.OWNER, "K"),
WorkspaceMemberUi("Marek N.", WorkspaceRole.EDITOR, "M"),
)