Coverage Summary for Class: WorkspaceInviteScreenKt (com.georgeci.moneysurfer.feature.workspace.invite)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| WorkspaceInviteScreenKt |
0%
(0/23)
|
1.3%
(1/76)
|
0%
(0/143)
|
0%
(0/1773)
|
| WorkspaceInviteScreenKt$WhenMappings |
|
| WorkspaceInviteScreenKt$WorkspaceInviteScreen$2$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/29)
|
| Total |
0%
(0/24)
|
1.3%
(1/76)
|
0%
(0/144)
|
0%
(0/1802)
|
package com.georgeci.moneysurfer.feature.workspace.invite
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.WorkspaceRole
import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
import com.georgeci.moneysurfer.domain.usecase.InviteError
import com.georgeci.moneysurfer.uikit.atom.SurferCard
import com.georgeci.moneysurfer.uikit.components.SurferButton
import com.georgeci.moneysurfer.uikit.components.SurferButtonSize
import com.georgeci.moneysurfer.uikit.components.SurferButtonStyle
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 kotlinx.coroutines.launch
import moneysurfer.feature.workspace.generated.resources.Res
import moneysurfer.feature.workspace.generated.resources.workspace_invite_close_content_description
import moneysurfer.feature.workspace.generated.resources.workspace_invite_email_helper
import moneysurfer.feature.workspace.generated.resources.workspace_invite_email_label
import moneysurfer.feature.workspace.generated.resources.workspace_invite_error_already_invited
import moneysurfer.feature.workspace.generated.resources.workspace_invite_error_already_member
import moneysurfer.feature.workspace.generated.resources.workspace_invite_error_invalid
import moneysurfer.feature.workspace.generated.resources.workspace_invite_error_not_owner
import moneysurfer.feature.workspace.generated.resources.workspace_invite_error_unknown
import moneysurfer.feature.workspace.generated.resources.workspace_invite_error_user_not_found
import moneysurfer.feature.workspace.generated.resources.workspace_invite_role_editor_hint
import moneysurfer.feature.workspace.generated.resources.workspace_invite_role_editor_title
import moneysurfer.feature.workspace.generated.resources.workspace_invite_role_label
import moneysurfer.feature.workspace.generated.resources.workspace_invite_role_viewer_hint
import moneysurfer.feature.workspace.generated.resources.workspace_invite_role_viewer_title
import moneysurfer.feature.workspace.generated.resources.workspace_invite_send
import moneysurfer.feature.workspace.generated.resources.workspace_invite_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/** Stable selectors for the workspace invite screen — see docs/testing/testing-strategy.md. */
object WorkspaceInviteTestTags {
const val Root = "workspaceInvite:root"
const val Email = "workspaceInvite:email"
const val Send = "workspaceInvite:send"
}
@Composable
fun WorkspaceInviteScreen(
workspaceId: WorkspaceId,
onNavigateBack: () -> Unit,
viewModel: WorkspaceInviteViewModel = koinViewModel(
key = workspaceId.value,
) { parametersOf(workspaceId) },
) {
val state by viewModel.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val unknownErrorText = stringResource(Res.string.workspace_invite_error_unknown)
val notOwnerText = stringResource(Res.string.workspace_invite_error_not_owner)
viewModel.HandleSideEffect { effect ->
when (effect) {
WorkspaceInviteEffect.Dismiss -> onNavigateBack()
is WorkspaceInviteEffect.Sent -> onNavigateBack()
is WorkspaceInviteEffect.ShowError -> {
val message = when (effect.error) {
InviteError.NotOwner -> notOwnerText
else -> unknownErrorText
}
scope.launch { snackbarHostState.showSnackbar(message) }
}
}
}
InviteContent(
state = state,
snackbarHostState = snackbarHostState,
onEvent = viewModel::onEvent,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InviteContent(
state: WorkspaceInviteState,
snackbarHostState: SnackbarHostState,
onEvent: (WorkspaceInviteEvent) -> Unit,
) {
Scaffold(
modifier = Modifier
.surferSafeInsets()
.testTag(WorkspaceInviteTestTags.Root)
.surferTestTagAsId(),
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.workspace_invite_title)) },
navigationIcon = {
IconButton(onClick = { onEvent(WorkspaceInviteEvent.OnDismiss) }) {
Icon(
imageVector = SurferIcons.Close,
contentDescription = stringResource(
Res.string.workspace_invite_close_content_description,
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = AppTheme.materialColors.surface,
),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding())
.verticalScroll(rememberScrollState())
.padding(horizontal = AppTheme.spacing.large),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.large),
) {
Spacer(Modifier.height(AppTheme.spacing.small))
EmailField(
value = state.email,
error = state.emailError,
enabled = !state.isSubmitting,
onChange = { onEvent(WorkspaceInviteEvent.OnEmailChanged(it)) },
)
RoleSection(
selected = state.role,
onSelect = { onEvent(WorkspaceInviteEvent.OnRoleSelected(it)) },
)
Spacer(Modifier.height(AppTheme.spacing.medium))
SurferButton(
text = stringResource(Res.string.workspace_invite_send),
style = SurferButtonStyle.Filled,
size = SurferButtonSize.Biggest,
enabled = state.email.isNotBlank() && !state.isSubmitting,
onClick = { onEvent(WorkspaceInviteEvent.OnSubmit) },
modifier = Modifier
.fillMaxWidth()
.testTag(WorkspaceInviteTestTags.Send),
)
Spacer(Modifier.height(padding.calculateBottomPadding() + AppTheme.spacing.large))
}
}
}
@Composable
private fun EmailField(
value: String,
error: InviteFieldError?,
enabled: Boolean,
onChange: (String) -> Unit,
) {
val errorText = when (error) {
InviteFieldError.Invalid -> stringResource(Res.string.workspace_invite_error_invalid)
InviteFieldError.AlreadyMember -> stringResource(Res.string.workspace_invite_error_already_member)
InviteFieldError.AlreadyInvited -> stringResource(Res.string.workspace_invite_error_already_invited)
InviteFieldError.UserNotFound -> stringResource(Res.string.workspace_invite_error_user_not_found)
null -> null
}
Column {
OutlinedTextField(
value = value,
onValueChange = onChange,
modifier = Modifier
.fillMaxWidth()
.testTag(WorkspaceInviteTestTags.Email),
enabled = enabled,
isError = error != null,
singleLine = true,
label = { Text(stringResource(Res.string.workspace_invite_email_label)) },
supportingText = {
Text(
text = errorText ?: stringResource(Res.string.workspace_invite_email_helper),
color = if (error != null) {
AppTheme.materialColors.error
} else {
AppTheme.materialColors.onSurfaceVariant
},
)
},
)
}
}
@Composable
private fun RoleSection(
selected: WorkspaceRole,
onSelect: (WorkspaceRole) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.small)) {
Text(
text = stringResource(Res.string.workspace_invite_role_label),
style = AppTheme.typography.labelLarge,
color = AppTheme.materialColors.onSurfaceVariant,
)
RoleCard(
title = stringResource(Res.string.workspace_invite_role_editor_title),
hint = stringResource(Res.string.workspace_invite_role_editor_hint),
selected = selected == WorkspaceRole.EDITOR,
onClick = { onSelect(WorkspaceRole.EDITOR) },
)
RoleCard(
title = stringResource(Res.string.workspace_invite_role_viewer_title),
hint = stringResource(Res.string.workspace_invite_role_viewer_hint),
selected = selected == WorkspaceRole.VIEWER,
onClick = { onSelect(WorkspaceRole.VIEWER) },
)
}
}
@Composable
private fun RoleCard(
title: String,
hint: String,
selected: Boolean,
onClick: () -> Unit,
) {
SurferCard(
modifier = Modifier.fillMaxWidth(),
selected = selected,
onClick = onClick,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(Modifier.weight(1f)) {
Text(text = title, style = AppTheme.typography.titleSmall)
Text(
text = hint,
style = AppTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.7f),
modifier = Modifier.padding(top = 2.dp),
)
}
RadioButton(selected = selected, onClick = onClick)
}
}
}
// ── Previews ─────────────────────────────────────────────────────────────────
private fun previewInviteState(
email: String = "",
role: WorkspaceRole = WorkspaceRole.EDITOR,
isSubmitting: Boolean = false,
emailError: InviteFieldError? = null,
): WorkspaceInviteState = WorkspaceInviteState(
workspaceId = WorkspaceId("preview-ws-1"),
email = email,
role = role,
isSubmitting = isSubmitting,
emailError = emailError,
)
@Preview
@Composable
private fun WorkspaceInviteScreenPreview_Empty() {
AppTheme {
InviteContent(
state = previewInviteState(),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceInviteScreenPreview_Filled() {
AppTheme {
InviteContent(
state = previewInviteState(email = "kasia.nowak@gmail.com", role = WorkspaceRole.EDITOR),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceInviteScreenPreview_ViewerRole() {
AppTheme {
InviteContent(
state = previewInviteState(email = "tom@mail.com", role = WorkspaceRole.VIEWER),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceInviteScreenPreview_DuplicateError() {
AppTheme {
InviteContent(
state = previewInviteState(
email = "alex@mail.com",
emailError = InviteFieldError.AlreadyMember,
),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceInviteScreenPreview_InvalidEmail() {
AppTheme {
InviteContent(
state = previewInviteState(
email = "not-an-email",
emailError = InviteFieldError.Invalid,
),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun WorkspaceInviteScreenPreview_Submitting() {
AppTheme {
InviteContent(
state = previewInviteState(
email = "kasia.nowak@gmail.com",
isSubmitting = true,
),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}