Coverage Summary for Class: IncomingInvitesScreenKt (com.georgeci.moneysurfer.feature.workspace.incoming)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| IncomingInvitesScreenKt |
0%
(0/20)
|
0%
(0/67)
|
0%
(0/160)
|
0%
(0/1761)
|
| IncomingInvitesScreenKt$IncomingInvitesScreen$1$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/29)
|
| IncomingInvitesScreenKt$IncomingInvitesScreen$lambda$3$0$$inlined$onPauseOrDispose$1 |
0%
(0/2)
|
|
| IncomingInvitesScreenKt$ScreenContent$2$2$1$2$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| IncomingInvitesScreenKt$ScreenContent$2$2$1$2$3$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| IncomingInvitesScreenKt$ScreenContent_rAjV9yQ$lambda$1$1$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| IncomingInvitesScreenKt$ScreenContent_rAjV9yQ$lambda$1$1$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| IncomingInvitesScreenKt$ScreenContent_rAjV9yQ$lambda$1$1$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| IncomingInvitesScreenKt$ScreenContent_rAjV9yQ$lambda$1$1$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| IncomingInvitesScreenKt$WhenMappings |
|
| Total |
0%
(0/29)
|
0%
(0/67)
|
0%
(0/163)
|
0%
(0/1812)
|
package com.georgeci.moneysurfer.feature.workspace.incoming
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.WorkspaceRole
import com.georgeci.moneysurfer.domain.primitives.WorkspaceInviteId
import com.georgeci.moneysurfer.uikit.components.SurferButton
import com.georgeci.moneysurfer.uikit.components.SurferButtonStyle
import com.georgeci.moneysurfer.uikit.components.workspace.SurferIncomingInviteCard
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.semantics.SurferSemantics
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_incoming_accept
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_back_content_description
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_decline
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_empty_subtitle
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_empty_title
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_error_unknown
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_expired
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_header_subtitle
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_header_title_format
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_refresh_failed
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_retry
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_title
import moneysurfer.feature.workspace.generated.resources.workspace_incoming_workspace_unknown_format
import moneysurfer.feature.workspace.generated.resources.workspace_members_role_editor
import moneysurfer.feature.workspace.generated.resources.workspace_members_role_owner
import moneysurfer.feature.workspace.generated.resources.workspace_members_role_viewer
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun IncomingInvitesScreen(
onNavigateBack: () -> Unit,
viewModel: IncomingInvitesViewModel = koinViewModel(),
) {
val state by viewModel.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val errorText = stringResource(Res.string.workspace_incoming_error_unknown)
viewModel.HandleSideEffect { effect ->
when (effect) {
IncomingInvitesEffect.NavigateBack -> onNavigateBack()
is IncomingInvitesEffect.ShowError -> {
scope.launch { snackbarHostState.showSnackbar(errorText) }
}
}
}
// Re-pull invites whenever the screen returns to the foreground — VM only does
// a one-shot pull in init {}, so without this an invite that landed while the user
// was away wouldn't appear until they kill+reopen the app.
LifecycleResumeEffect(Unit) {
viewModel.onEvent(IncomingInvitesEvent.OnRefresh)
onPauseOrDispose { }
}
Body(
state = state,
snackbarHostState = snackbarHostState,
onEvent = viewModel::onEvent,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Body(
state: IncomingInvitesState,
snackbarHostState: SnackbarHostState,
onEvent: (IncomingInvitesEvent) -> Unit,
) {
Scaffold(
modifier = Modifier.surferSafeInsets(),
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.workspace_incoming_title)) },
navigationIcon = {
val backLabel = stringResource(Res.string.workspace_incoming_back_content_description)
IconButton(onClick = { onEvent(IncomingInvitesEvent.OnBackClick) }) {
Icon(imageVector = SurferIcons.Back, contentDescription = backLabel)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = AppTheme.materialColors.surface,
),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding()),
) {
if (state.refreshError != null) {
RefreshErrorBanner(onRetry = { onEvent(IncomingInvitesEvent.OnRefresh) })
}
ScreenContent(
state = state,
bottomPadding = padding.calculateBottomPadding(),
onEvent = onEvent,
)
}
}
}
@Composable
private fun ScreenContent(
state: IncomingInvitesState,
bottomPadding: Dp,
onEvent: (IncomingInvitesEvent) -> Unit,
) {
when {
state.loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "…",
style = AppTheme.typography.titleLarge,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
state.invites.isEmpty() -> EmptyView()
else -> Column(modifier = Modifier.fillMaxSize()) {
Header(activeCount = state.invites.count { !it.expired })
Spacer(Modifier.height(AppTheme.spacing.small))
LazyColumn(
contentPadding = PaddingValues(
start = AppTheme.spacing.large,
top = AppTheme.spacing.small,
end = AppTheme.spacing.large,
bottom = bottomPadding + AppTheme.spacing.small,
),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
items(state.invites, key = { it.id.value }) { invite ->
SurferIncomingInviteCard(
workspaceName = invite.workspaceName.ifBlank {
stringResource(
Res.string.workspace_incoming_workspace_unknown_format,
invite.workspaceIdShort,
)
},
inviterLabel = "@${invite.invitedByUserId}",
roleLabel = roleLabel(invite.role),
expired = invite.expired,
expiredLabel = stringResource(Res.string.workspace_incoming_expired),
acceptLabel = stringResource(Res.string.workspace_incoming_accept),
declineLabel = stringResource(Res.string.workspace_incoming_decline),
onAccept = { onEvent(IncomingInvitesEvent.OnAccept(invite.id)) },
onDecline = { onEvent(IncomingInvitesEvent.OnDecline(invite.id)) },
busy = state.busyId == invite.id,
)
}
}
}
}
}
@Composable
private fun RefreshErrorBanner(onRetry: () -> Unit) {
val cs = AppTheme.materialColors
Row(
modifier = Modifier
.fillMaxWidth()
.background(cs.errorContainer)
.padding(horizontal = AppTheme.spacing.large, vertical = AppTheme.spacing.small),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.workspace_incoming_refresh_failed),
style = AppTheme.typography.bodyMedium,
color = cs.onErrorContainer,
modifier = Modifier.weight(1f),
)
SurferButton(
text = stringResource(Res.string.workspace_incoming_retry),
style = SurferButtonStyle.Text,
onClick = onRetry,
)
}
}
@Composable
private fun Header(activeCount: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppTheme.spacing.large, vertical = AppTheme.spacing.small),
) {
Text(
text = pluralStringResource(
Res.plurals.workspace_incoming_header_title_format,
activeCount,
activeCount,
),
style = AppTheme.typography.headlineSmall,
color = AppTheme.materialColors.onSurface,
)
Text(
text = stringResource(Res.string.workspace_incoming_header_subtitle),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun roleLabel(role: WorkspaceRole): String = when (role) {
WorkspaceRole.OWNER -> stringResource(Res.string.workspace_members_role_owner)
WorkspaceRole.EDITOR -> stringResource(Res.string.workspace_members_role_editor)
WorkspaceRole.VIEWER -> stringResource(Res.string.workspace_members_role_viewer)
}
@Composable
private fun EmptyView(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = AppTheme.spacing.large),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = SurferIcons.Mail,
contentDescription = SurferSemantics.Decorative,
tint = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(Res.string.workspace_incoming_empty_title),
style = AppTheme.typography.titleMedium,
color = AppTheme.materialColors.onSurface,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(Res.string.workspace_incoming_empty_subtitle),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp),
)
}
}
// ── Previews ─────────────────────────────────────────────────────────────────
private val PreviewIncoming = listOf(
IncomingInviteUi(
id = WorkspaceInviteId("i-1"),
workspaceName = "Lisbon trip '26",
workspaceIdShort = "ws-001",
role = WorkspaceRole.EDITOR,
invitedByUserId = "kasiam",
expired = false,
expiresAt = kotlin.time.Instant.fromEpochMilliseconds(0),
),
IncomingInviteUi(
id = WorkspaceInviteId("i-2"),
workspaceName = "Office lunch club",
workspaceIdShort = "ws-002",
role = WorkspaceRole.VIEWER,
invitedByUserId = "marekw",
expired = false,
expiresAt = kotlin.time.Instant.fromEpochMilliseconds(0),
),
IncomingInviteUi(
id = WorkspaceInviteId("i-3"),
workspaceName = "",
workspaceIdShort = "ws-003",
role = WorkspaceRole.VIEWER,
invitedByUserId = "lenab",
expired = true,
expiresAt = kotlin.time.Instant.fromEpochMilliseconds(0),
),
)
@Preview
@Composable
private fun IncomingInvitesScreenPreview_WithInvites() {
AppTheme {
Body(
state = IncomingInvitesState(loading = false, invites = PreviewIncoming),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun IncomingInvitesScreenPreview_AcceptingBusy() {
AppTheme {
Body(
state = IncomingInvitesState(
loading = false,
invites = PreviewIncoming,
busyId = WorkspaceInviteId("i-1"),
),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun IncomingInvitesScreenPreview_Empty() {
AppTheme {
Body(
state = IncomingInvitesState(loading = false, invites = emptyList()),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}
@Preview
@Composable
private fun IncomingInvitesScreenPreview_Loading() {
AppTheme {
Body(
state = IncomingInvitesState(loading = true),
snackbarHostState = remember { SnackbarHostState() },
onEvent = {},
)
}
}