Coverage Summary for Class: WorkspaceMembersScreenKt (com.georgeci.moneysurfer.feature.workspace.members)

Class Method, % Branch, % Line, % Instruction, %
WorkspaceMembersScreenKt 0% (0/23) 0% (0/142) 0% (0/241) 0% (0/3266)
WorkspaceMembersScreenKt$WhenMappings
Total 0% (0/23) 0% (0/142) 0% (0/241) 0% (0/3266)


 package com.georgeci.moneysurfer.feature.workspace.members
 
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 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.shape.RoundedCornerShape
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.geometry.CornerRadius
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import com.georgeci.moneysurfer.domain.model.WorkspaceRole
 import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
 import com.georgeci.moneysurfer.uikit.components.SurferButton
 import com.georgeci.moneysurfer.uikit.components.SurferButtonStyle
 import com.georgeci.moneysurfer.uikit.components.base.SurferSegmentedControl
 import com.georgeci.moneysurfer.uikit.components.workspace.SurferInvitePendingRow
 import com.georgeci.moneysurfer.uikit.components.workspace.SurferInviteRow
 import com.georgeci.moneysurfer.uikit.components.workspace.SurferMemberRow
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
 import com.georgeci.moneysurfer.uikit.preview.SurferBottomSheetPreview
 import com.georgeci.moneysurfer.uikit.semantics.SurferSemantics
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import moneysurfer.feature.workspace.generated.resources.Res
 import moneysurfer.feature.workspace.generated.resources.workspace_members_close_content_description
 import moneysurfer.feature.workspace.generated.resources.workspace_members_invite_cancel_content_description
 import moneysurfer.feature.workspace.generated.resources.workspace_members_invite_status_expired
 import moneysurfer.feature.workspace.generated.resources.workspace_members_invite_status_pending
 import moneysurfer.feature.workspace.generated.resources.workspace_members_invite_subtitle
 import moneysurfer.feature.workspace.generated.resources.workspace_members_invite_title
 import moneysurfer.feature.workspace.generated.resources.workspace_members_invites_empty
 import moneysurfer.feature.workspace.generated.resources.workspace_members_leave
 import moneysurfer.feature.workspace.generated.resources.workspace_members_leave_cancel
 import moneysurfer.feature.workspace.generated.resources.workspace_members_leave_confirm
 import moneysurfer.feature.workspace.generated.resources.workspace_members_leave_message_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_leave_title_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 moneysurfer.feature.workspace.generated.resources.workspace_members_subtitle_editor_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_subtitle_owner_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_subtitle_unknown_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_subtitle_viewer_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_tab_active_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_tab_invited_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_title_format
 import moneysurfer.feature.workspace.generated.resources.workspace_members_view_only_hint
 import org.jetbrains.compose.resources.pluralStringResource
 import org.jetbrains.compose.resources.stringResource
 
 /** Stable selectors for the workspace members view — see docs/testing/testing-strategy.md. */
 object WorkspaceMembersTestTags {
     const val Root = "workspaceMembers:root"
     const val Invite = "workspaceMembers:invite"
 }
 
 /**
  * Pure content composable for the Members view. Renders header + Active/Invited tabs +
  * member list / pending-invite list against the provided [state]/[onEvent]. Callers are
  * responsible for the surrounding container (bottom sheet, dialog, full screen, etc.).
  */
 @Composable
 fun WorkspaceMembersScreen(
     state: WorkspaceMembersState,
     onEvent: (WorkspaceMembersEvent) -> Unit,
     modifier: Modifier = Modifier,
     headerNavigationIcon: ImageVector = SurferIcons.Close,
     showHeader: Boolean = true,
 ) {
     when (state) {
         is WorkspaceMembersState.Loading ->
             Box(modifier = modifier.fillMaxWidth().height(200.dp))
         is WorkspaceMembersState.Content ->
             WorkspaceMembersContent(
                 state = state,
                 onEvent = onEvent,
                 modifier = modifier,
                 headerNavigationIcon = headerNavigationIcon,
                 showHeader = showHeader,
             )
     }
     if (state is WorkspaceMembersState.Content && state.showLeaveDialog) {
         LeaveDialog(
             workspaceName = state.workspaceName,
             busy = state.busy,
             onCancel = { onEvent(WorkspaceMembersEvent.OnLeaveCancel) },
             onConfirm = { onEvent(WorkspaceMembersEvent.OnLeaveConfirm) },
         )
     }
 }
 
 @Composable
 private fun WorkspaceMembersContent(
     state: WorkspaceMembersState.Content,
     onEvent: (WorkspaceMembersEvent) -> Unit,
     modifier: Modifier = Modifier,
     headerNavigationIcon: ImageVector = SurferIcons.Close,
     showHeader: Boolean = true,
 ) {
     Column(
         modifier = modifier
             .fillMaxWidth()
             .testTag(WorkspaceMembersTestTags.Root)
             .surferTestTagAsId(),
     ) {
         if (showHeader) {
             Header(
                 workspaceName = state.workspaceName,
                 memberCount = state.activeCount,
                 viewerRole = state.viewerRole,
                 navigationIcon = headerNavigationIcon,
                 onClose = { onEvent(WorkspaceMembersEvent.OnDismiss) },
             )
         }
 
         if (state.canManage) {
             Box(modifier = Modifier.padding(horizontal = AppTheme.spacing.default)) {
                 SurferInviteRow(
                     title = stringResource(Res.string.workspace_members_invite_title),
                     subtitle = stringResource(Res.string.workspace_members_invite_subtitle),
                     onClick = { onEvent(WorkspaceMembersEvent.OnInviteClick) },
                     modifier = Modifier.testTag(WorkspaceMembersTestTags.Invite),
                 )
             }
             Spacer(Modifier.height(AppTheme.spacing.medium))
         }
 
         SurferSegmentedControl(
             options = listOf(MembersTab.Active, MembersTab.Invited),
             selected = state.tab,
             label = { tab ->
                 when (tab) {
                     MembersTab.Active -> stringResource(
                         Res.string.workspace_members_tab_active_format,
                         state.activeCount,
                     )
                     MembersTab.Invited -> stringResource(
                         Res.string.workspace_members_tab_invited_format,
                         state.invitedCount,
                     )
                 }
             },
             onSelect = { onEvent(WorkspaceMembersEvent.OnTabSelected(it)) },
             modifier = Modifier.padding(horizontal = AppTheme.spacing.large),
         )
         Spacer(Modifier.height(AppTheme.spacing.small))
 
         when (state.tab) {
             MembersTab.Active -> ActiveList(state = state, onEvent = onEvent)
             MembersTab.Invited -> InvitedList(state = state, onEvent = onEvent)
         }
 
         if (state.canLeave) {
             Spacer(Modifier.height(AppTheme.spacing.medium))
             Box(
                 modifier = Modifier.padding(horizontal = AppTheme.spacing.large).fillMaxWidth(),
             ) {
                 SurferButton(
                     text = stringResource(Res.string.workspace_members_leave),
                     style = SurferButtonStyle.Outlined,
                     enabled = !state.busy,
                     startIcon = SurferIcons.Logout,
                     onClick = { onEvent(WorkspaceMembersEvent.OnLeaveClick) },
                     modifier = Modifier.fillMaxWidth(),
                 )
             }
         }
 
         Spacer(Modifier.height(AppTheme.spacing.large))
     }
 }
 
 @Composable
 private fun Header(
     workspaceName: String,
     memberCount: Int,
     viewerRole: WorkspaceRole?,
     navigationIcon: ImageVector,
     onClose: () -> Unit,
 ) {
     Row(
         modifier = Modifier
             .fillMaxWidth()
             .padding(start = AppTheme.spacing.large, end = AppTheme.spacing.small, bottom = 14.dp),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
     ) {
         Box(
             modifier = Modifier
                 .size(40.dp)
                 .clip(RoundedCornerShape(12.dp))
                 .background(AppTheme.materialColors.surfaceContainerHighest),
             contentAlignment = Alignment.Center,
         ) {
             Text(
                 text = workspaceName.firstOrNull()?.uppercaseChar()?.toString().orEmpty(),
                 style = AppTheme.typography.titleMedium,
                 color = AppTheme.materialColors.onSurface,
             )
         }
         Column(Modifier.weight(1f)) {
             Text(
                 text = stringResource(Res.string.workspace_members_title_format, workspaceName),
                 style = AppTheme.typography.titleLarge,
                 color = AppTheme.materialColors.onSurface,
             )
             Text(
                 text = subtitleFor(viewerRole, memberCount),
                 style = AppTheme.typography.bodySmall,
                 color = AppTheme.materialColors.onSurfaceVariant,
                 modifier = Modifier.padding(top = 2.dp),
             )
         }
         IconButton(onClick = onClose) {
             Icon(
                 imageVector = navigationIcon,
                 contentDescription = stringResource(Res.string.workspace_members_close_content_description),
                 tint = AppTheme.materialColors.onSurfaceVariant,
             )
         }
     }
 }
 
 @Composable
 private fun subtitleFor(role: WorkspaceRole?, memberCount: Int): String = when (role) {
     WorkspaceRole.OWNER ->
         pluralStringResource(Res.plurals.workspace_members_subtitle_owner_format, memberCount, memberCount)
     WorkspaceRole.EDITOR ->
         pluralStringResource(Res.plurals.workspace_members_subtitle_editor_format, memberCount, memberCount)
     WorkspaceRole.VIEWER ->
         pluralStringResource(Res.plurals.workspace_members_subtitle_viewer_format, memberCount, memberCount)
     null ->
         pluralStringResource(Res.plurals.workspace_members_subtitle_unknown_format, memberCount, memberCount)
 }
 
 @Composable
 private fun ActiveList(
     state: WorkspaceMembersState.Content,
     onEvent: (WorkspaceMembersEvent) -> Unit,
 ) {
     state.members.forEachIndexed { index, member ->
         if (index > 0) Spacer(Modifier.height(AppTheme.spacing.small))
         val clickable = state.canManage && !member.isYou && member.role != WorkspaceRole.OWNER
         val rowModifier = Modifier
             .fillMaxWidth()
             .padding(horizontal = AppTheme.spacing.large)
             .let { if (clickable) it.clickable { onEvent(WorkspaceMembersEvent.OnMemberClick(member.userId)) } else it }
         Box(modifier = rowModifier) {
             SurferMemberRow(
                 name = member.name,
                 initial = member.initial,
                 roleLabel = roleLabel(member.role),
                 isYou = member.isYou,
                 secondaryLine = member.email.takeIf { it.isNotBlank() },
             )
         }
     }
     if (!state.canManage && state.viewerRole != null) {
         ViewOnlyHint()
     }
 }
 
 @Composable
 private fun InvitedList(
     state: WorkspaceMembersState.Content,
     onEvent: (WorkspaceMembersEvent) -> Unit,
 ) {
     val pending = state.pendingInvites.filter { !it.isExpired }
     val expired = state.pendingInvites.filter { it.isExpired }
     if (pending.isEmpty() && expired.isEmpty()) {
         EmptyHint(stringResource(Res.string.workspace_members_invites_empty))
         return
     }
     val pendingLabel = stringResource(Res.string.workspace_members_invite_status_pending)
     val expiredLabel = stringResource(Res.string.workspace_members_invite_status_expired)
     val all = pending + expired
     all.forEachIndexed { index, invite ->
         if (index > 0) Spacer(Modifier.height(AppTheme.spacing.small))
         InviteRow(
             invite = invite,
             statusLabel = if (invite.isExpired) expiredLabel else pendingLabel,
             canCancel = state.canManage && !state.busy,
             onCancel = { onEvent(WorkspaceMembersEvent.OnInviteCancel(invite.id)) },
         )
     }
 }
 
 @Composable
 private fun InviteRow(
     invite: InviteUi,
     statusLabel: String,
     canCancel: Boolean,
     onCancel: () -> Unit,
 ) {
     Row(
         modifier = Modifier
             .fillMaxWidth()
             .padding(horizontal = AppTheme.spacing.large),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         Box(modifier = Modifier.weight(1f)) {
             SurferInvitePendingRow(
                 email = invite.email,
                 statusLabel = statusLabel,
                 isExpired = invite.isExpired,
                 roleLabel = roleLabel(invite.role),
             )
         }
         if (canCancel) {
             IconButton(onClick = onCancel) {
                 Icon(
                     imageVector = SurferIcons.Close,
                     contentDescription = stringResource(
                         Res.string.workspace_members_invite_cancel_content_description,
                     ),
                     tint = AppTheme.materialColors.onSurfaceVariant,
                 )
             }
         }
     }
 }
 
 @Composable
 private fun ViewOnlyHint() {
     Row(
         modifier = Modifier
             .fillMaxWidth()
             .padding(horizontal = AppTheme.spacing.large, vertical = AppTheme.spacing.small)
             .clip(RoundedCornerShape(12.dp))
             .background(AppTheme.materialColors.surfaceContainerLow)
             .padding(horizontal = 14.dp, vertical = 12.dp),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(10.dp),
     ) {
         Icon(
             imageVector = SurferIcons.People,
             contentDescription = SurferSemantics.Decorative,
             tint = AppTheme.materialColors.onSurfaceVariant,
             modifier = Modifier.size(16.dp),
         )
         Text(
             text = stringResource(Res.string.workspace_members_view_only_hint),
             style = AppTheme.typography.bodySmall,
             color = AppTheme.materialColors.onSurfaceVariant,
         )
     }
 }
 
 @Composable
 private fun EmptyHint(text: String) {
     val outline = AppTheme.materialColors.outlineVariant
     Box(
         modifier = Modifier
             .fillMaxWidth()
             .padding(horizontal = AppTheme.spacing.large, vertical = AppTheme.spacing.small)
             .clip(RoundedCornerShape(12.dp))
             .drawBehind {
                 drawRoundRect(
                     color = outline,
                     style = Stroke(width = 1.dp.toPx()),
                     cornerRadius = CornerRadius(12.dp.toPx(), 12.dp.toPx()),
                 )
             }
             .padding(horizontal = 14.dp, vertical = 14.dp),
         contentAlignment = Alignment.Center,
     ) {
         Text(
             text = text,
             style = AppTheme.typography.bodySmall,
             color = AppTheme.materialColors.onSurfaceVariant,
         )
     }
 }
 
 @Composable
 private fun LeaveDialog(
     workspaceName: String,
     busy: Boolean,
     onCancel: () -> Unit,
     onConfirm: () -> Unit,
 ) {
     AlertDialog(
         onDismissRequest = { if (!busy) onCancel() },
         confirmButton = {
             TextButton(onClick = onConfirm, enabled = !busy) {
                 Text(
                     text = stringResource(Res.string.workspace_members_leave_confirm),
                     color = AppTheme.materialColors.error,
                 )
             }
         },
         dismissButton = {
             TextButton(onClick = onCancel, enabled = !busy) {
                 Text(stringResource(Res.string.workspace_members_leave_cancel))
             }
         },
         title = { Text(stringResource(Res.string.workspace_members_leave_title_format, workspaceName)) },
         text = { Text(stringResource(Res.string.workspace_members_leave_message_format, workspaceName)) },
     )
 }
 
 @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)
 }
 
 // ── Previews ─────────────────────────────────────────────────────────────────
 // Each variant exercises a different role / tab / dialog combination so changes
 // to the layout get reviewed in isolation rather than only via the running app.
 
 @Preview
 @Composable
 private fun WorkspaceMembersScreenPreviewOwnerActive() {
     SurferBottomSheetPreview {
         WorkspaceMembersScreen(state = previewState(WorkspaceRole.OWNER), onEvent = {})
     }
 }
 
 @Preview
 @Composable
 private fun WorkspaceMembersScreenPreviewOwnerInvited() {
     SurferBottomSheetPreview {
         WorkspaceMembersScreen(
             state = previewState(WorkspaceRole.OWNER, tab = MembersTab.Invited),
             onEvent = {},
         )
     }
 }
 
 @Preview
 @Composable
 private fun WorkspaceMembersScreenPreviewOwnerInvitedEmpty() {
     SurferBottomSheetPreview {
         WorkspaceMembersScreen(
             state = previewState(WorkspaceRole.OWNER, tab = MembersTab.Invited, invites = emptyList()),
             onEvent = {},
         )
     }
 }
 
 @Preview
 @Composable
 private fun WorkspaceMembersScreenPreviewEditorActive() {
     SurferBottomSheetPreview {
         WorkspaceMembersScreen(state = previewState(WorkspaceRole.EDITOR), onEvent = {})
     }
 }
 
 @Preview
 @Composable
 private fun WorkspaceMembersScreenPreviewViewerActive() {
     SurferBottomSheetPreview {
         WorkspaceMembersScreen(state = previewState(WorkspaceRole.VIEWER), onEvent = {})
     }
 }
 
 @Preview
 @Composable
 private fun WorkspaceMembersScreenPreviewLeaveDialog() {
     SurferBottomSheetPreview {
         WorkspaceMembersScreen(
             state = previewState(WorkspaceRole.EDITOR, showLeaveDialog = true),
             onEvent = {},
         )
     }
 }
 
 @Preview
 @Composable
 private fun WorkspaceMembersScreenPreviewLoading() {
     SurferBottomSheetPreview {
         WorkspaceMembersScreen(
             state = WorkspaceMembersState.Loading(WorkspaceId("preview-ws-1")),
             onEvent = {},
         )
     }
 }