Coverage Summary for Class: WorkspaceSelectorScreenKt (com.georgeci.moneysurfer.feature.workspace.selector)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| WorkspaceSelectorScreenKt |
0%
(0/25)
|
0%
(0/82)
|
0%
(0/163)
|
0%
(0/1674)
|
| WorkspaceSelectorScreenKt$WorkspaceSelectorContent$1$3$1$1$4$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/9)
|
| WorkspaceSelectorScreenKt$WorkspaceSelectorContent$1$3$1$1$4$3$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/9)
|
| WorkspaceSelectorScreenKt$WorkspaceSelectorContent$1$3$1$1$4$4$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/9)
|
| WorkspaceSelectorScreenKt$WorkspaceSelectorContent$lambda$0$2$0$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| WorkspaceSelectorScreenKt$WorkspaceSelectorContent$lambda$0$2$0$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| WorkspaceSelectorScreenKt$WorkspaceSelectorContent$lambda$0$2$0$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| WorkspaceSelectorScreenKt$WorkspaceSelectorContent$lambda$0$2$0$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| Total |
0%
(0/32)
|
0%
(0/82)
|
0%
(0/166)
|
0%
(0/1701)
|
package com.georgeci.moneysurfer.feature.workspace.selector
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.Workspace
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.UserId
import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
import com.georgeci.moneysurfer.uikit.components.SurferButton
import com.georgeci.moneysurfer.uikit.components.SurferButtonSize
import com.georgeci.moneysurfer.uikit.components.SurferFullScreenLoader
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
import com.georgeci.moneysurfer.uikit.components.workspace.SurferCreateWorkspaceRow
import com.georgeci.moneysurfer.uikit.components.workspace.SurferWorkspaceRow
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.utils.HandleSideEffect
import moneysurfer.feature.workspace.generated.resources.Res
import moneysurfer.feature.workspace.generated.resources.workspace_selector_action_edit
import moneysurfer.feature.workspace.generated.resources.workspace_selector_action_members
import moneysurfer.feature.workspace.generated.resources.workspace_selector_cloud_unavailable
import moneysurfer.feature.workspace.generated.resources.workspace_selector_continue
import moneysurfer.feature.workspace.generated.resources.workspace_selector_create_subtitle
import moneysurfer.feature.workspace.generated.resources.workspace_selector_create_title
import moneysurfer.feature.workspace.generated.resources.workspace_selector_empty
import moneysurfer.feature.workspace.generated.resources.workspace_selector_heading_subtitle
import moneysurfer.feature.workspace.generated.resources.workspace_selector_heading_subtitle_offline
import moneysurfer.feature.workspace.generated.resources.workspace_selector_heading_title
import moneysurfer.feature.workspace.generated.resources.workspace_selector_sign_out
import moneysurfer.feature.workspace.generated.resources.workspace_selector_use_workspace
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.time.Instant
/** Stable selectors for the workspace selector screen — see docs/testing/testing-strategy.md. */
object WorkspaceSelectorTestTags {
const val Root = "workspaceSelector:root"
const val Create = "workspaceSelector:create"
const val Confirm = "workspaceSelector:confirm"
}
/**
* The exits [WorkspaceSelectorScreen] offers, one per [WorkspaceSelectorEffect]. Grouped into a
* holder so the entry point stays under the parameter limit as destinations are added — see
* AGENTS.md → UI Rules.
*/
data class WorkspaceSelectorNavigation(
val onNavigateBack: () -> Unit,
val onNavigateToDashboard: () -> Unit,
val onNavigateToSignIn: () -> Unit,
val onNavigateToWorkspaceCreation: () -> Unit,
val onNavigateToWorkspaceEdit: (WorkspaceId) -> Unit,
val onNavigateToWorkspaceMembers: (WorkspaceId) -> Unit,
)
/**
* [cloudDataUnavailable] comes straight from the route: the account owns workspaces that the
* post-auth pull never brought down, so the empty list below is a hydration failure rather than
* a new account. Pure presentation — nothing in the view model depends on it (issue #342).
*/
@Composable
fun WorkspaceSelectorScreen(
showActions: Boolean,
navigation: WorkspaceSelectorNavigation,
cloudDataUnavailable: Boolean = false,
viewModel: WorkspaceSelectorViewModel = koinViewModel(
key = "selector:$showActions",
) { parametersOf(showActions) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
WorkspaceSelectorEffect.NavigateBack -> navigation.onNavigateBack()
WorkspaceSelectorEffect.NavigateToDashboard -> navigation.onNavigateToDashboard()
WorkspaceSelectorEffect.NavigateToSignIn -> navigation.onNavigateToSignIn()
WorkspaceSelectorEffect.NavigateToWorkspaceCreation -> navigation.onNavigateToWorkspaceCreation()
is WorkspaceSelectorEffect.NavigateToWorkspaceEdit ->
navigation.onNavigateToWorkspaceEdit(effect.workspaceId)
is WorkspaceSelectorEffect.NavigateToWorkspaceMembers ->
navigation.onNavigateToWorkspaceMembers(effect.workspaceId)
}
}
when (val current = state) {
is WorkspaceSelectorState.Loading -> WorkspaceSelectorLoading(
state = current,
onEvent = viewModel::onEvent,
)
is WorkspaceSelectorState.Content -> WorkspaceSelectorContent(
state = current,
cloudDataUnavailable = cloudDataUnavailable,
onEvent = viewModel::onEvent,
)
}
}
@Composable
private fun WorkspaceSelectorLoading(
state: WorkspaceSelectorState.Loading,
onEvent: (WorkspaceSelectorEvent) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier.surferSafeInsets(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(Res.string.workspace_selector_heading_title),
// Same rule as the loaded screen, so the arrow does not pop in once the
// workspaces arrive — and so a slow load is not a dead end either.
onBack = backAction(state.showActions, onEvent),
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding))
}
SurferFullScreenLoader(modifier = Modifier.fillMaxSize())
}
}
/**
* The toolbar's back action, or null. Only the pushed-from-Settings selector (`showActions`) has
* somewhere to go back to; as a start route it is the bottom of the stack, and the sign-out action
* is the way out instead.
*/
private fun backAction(
showActions: Boolean,
onEvent: (WorkspaceSelectorEvent) -> Unit,
): (() -> Unit)? = if (showActions) {
{ onEvent(WorkspaceSelectorEvent.OnBackClick) }
} else {
null
}
@Composable
private fun WorkspaceSelectorContent(
state: WorkspaceSelectorState.Content,
onEvent: (WorkspaceSelectorEvent) -> Unit,
cloudDataUnavailable: Boolean = false,
) {
val editLabel = stringResource(Res.string.workspace_selector_action_edit)
val membersLabel = stringResource(Res.string.workspace_selector_action_members)
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier
.testTag(WorkspaceSelectorTestTags.Root)
.surferTestTagAsId(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(Res.string.workspace_selector_heading_title),
onBack = backAction(state.showActions, onEvent),
actions = {
// The only exit when the selector is the root of the stack: no back
// entry, and Settings sits behind Dashboard, which needs a workspace.
if (state.canSignOut) {
IconButton(onClick = { onEvent(WorkspaceSelectorEvent.OnSignOutClick) }) {
Icon(
imageVector = SurferIcons.Logout,
contentDescription = stringResource(
Res.string.workspace_selector_sign_out,
),
)
}
}
},
)
},
bottomBar = {
ConfirmBar(
workspace = state.activeWorkspace,
enabled = state.activeWorkspace != null && !state.isSelecting,
onClick = { onEvent(WorkspaceSelectorEvent.OnConfirmClick) },
)
},
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
contentPadding = PaddingValues(
bottom = padding.calculateBottomPadding() + AppTheme.spacing.default,
),
) {
item(key = "header") {
Text(
text = stringResource(
if (state.isOffline) {
Res.string.workspace_selector_heading_subtitle_offline
} else {
Res.string.workspace_selector_heading_subtitle
},
),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = AppTheme.spacing.large,
vertical = AppTheme.spacing.xSmall,
),
)
}
if (state.workspaces.isEmpty()) {
item(key = "empty") {
Text(
text = stringResource(
if (cloudDataUnavailable) {
Res.string.workspace_selector_cloud_unavailable
} else {
Res.string.workspace_selector_empty
},
),
style = AppTheme.typography.bodyLarge,
color = if (cloudDataUnavailable) {
AppTheme.materialColors.error
} else {
AppTheme.materialColors.onSurfaceVariant
},
modifier = Modifier.padding(
horizontal = AppTheme.spacing.large,
vertical = AppTheme.spacing.small,
),
)
}
} else {
items(state.workspaces, key = { it.id.value }) { workspace ->
val subtitle = workspace.description.takeIf { it.isNotBlank() } ?: workspace.baseCurrency.value
SurferWorkspaceRow(
name = workspace.name,
subtitle = subtitle,
selected = workspace.id == state.activeWorkspaceId,
enabled = !state.isSelecting,
showActions = state.showActions,
editLabel = editLabel,
membersLabel = membersLabel.takeIf { state.showMemberActions },
onClick = { onEvent(WorkspaceSelectorEvent.OnWorkspaceClick(workspace)) },
onEditClick = { onEvent(WorkspaceSelectorEvent.OnEditWorkspaceClick(workspace)) },
onMembersClick = { onEvent(WorkspaceSelectorEvent.OnMembersClick(workspace)) },
modifier = Modifier.padding(horizontal = AppTheme.spacing.default),
)
}
}
item(key = "create") {
SurferCreateWorkspaceRow(
title = stringResource(Res.string.workspace_selector_create_title),
subtitle = stringResource(Res.string.workspace_selector_create_subtitle),
onClick = { onEvent(WorkspaceSelectorEvent.OnCreateWorkspaceClick) },
modifier = Modifier
.padding(
horizontal = AppTheme.spacing.default,
vertical = AppTheme.spacing.small,
)
.testTag(WorkspaceSelectorTestTags.Create),
)
}
}
}
if (state.isSelecting) {
SurferFullScreenLoader(modifier = Modifier.fillMaxSize())
}
}
}
@Composable
private fun ConfirmBar(
workspace: Workspace?,
enabled: Boolean,
onClick: () -> Unit,
) {
val label = if (workspace != null) {
stringResource(Res.string.workspace_selector_use_workspace, workspace.name)
} else {
stringResource(Res.string.workspace_selector_continue)
}
Box(
modifier = Modifier
.fillMaxWidth()
.background(AppTheme.materialColors.surface)
.navigationBarsPadding()
// Background and system insets stay full-bleed; only the button takes the content cap,
// so it lines up with the list above instead of stretching across the window.
.surferContentContainer()
.padding(horizontal = AppTheme.spacing.default)
.padding(top = AppTheme.spacing.medium, bottom = AppTheme.spacing.large),
) {
SurferButton(
text = label,
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.testTag(WorkspaceSelectorTestTags.Confirm),
size = SurferButtonSize.Biggest,
enabled = enabled,
endIcon = SurferIcons.ChevronRight,
)
}
}
@Preview
@Composable
private fun WorkspaceSelectorPreviewNoActions() {
AppTheme {
WorkspaceSelectorContent(
state = WorkspaceSelectorState.Content(
workspaces = previewWorkspaces,
selectedWorkspaceId = WorkspaceId("preview-ws-2"),
pendingWorkspaceId = WorkspaceId("preview-ws-2"),
showActions = false,
isSelecting = false,
),
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceSelectorPreviewWithActions() {
AppTheme {
WorkspaceSelectorContent(
state = WorkspaceSelectorState.Content(
workspaces = previewWorkspaces,
selectedWorkspaceId = WorkspaceId("preview-ws-2"),
pendingWorkspaceId = WorkspaceId("preview-ws-2"),
showActions = true,
isSelecting = false,
),
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceSelectorEmptyPreview() {
AppTheme {
WorkspaceSelectorContent(
state = WorkspaceSelectorState.Content(
workspaces = emptyList(),
selectedWorkspaceId = null,
pendingWorkspaceId = null,
showActions = false,
isSelecting = false,
),
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceSelectorCloudUnavailablePreview() {
AppTheme {
WorkspaceSelectorContent(
state = WorkspaceSelectorState.Content(
workspaces = emptyList(),
selectedWorkspaceId = null,
pendingWorkspaceId = null,
showActions = false,
isSelecting = false,
),
onEvent = {},
cloudDataUnavailable = true,
)
}
}
private val previewWorkspaces = listOf(
Workspace(
id = WorkspaceId("preview-ws-1"),
name = "Personal",
description = "Just you",
baseCurrency = CurrencyCode("EUR"),
ownerId = UserId("preview-user-1"),
createdAt = Instant.fromEpochMilliseconds(0),
),
Workspace(
id = WorkspaceId("preview-ws-2"),
name = "Family",
description = "Household budget",
baseCurrency = CurrencyCode("EUR"),
ownerId = UserId("preview-user-1"),
createdAt = Instant.fromEpochMilliseconds(0),
),
Workspace(
id = WorkspaceId("preview-ws-3"),
name = "Lisbon trip",
description = "Apr 12 – Apr 22",
baseCurrency = CurrencyCode("EUR"),
ownerId = UserId("preview-user-1"),
createdAt = Instant.fromEpochMilliseconds(0),
),
)