Coverage Summary for Class: WorkspaceCreationScreenKt (com.georgeci.moneysurfer.feature.workspace.creation)

Class Method, % Branch, % Line, % Instruction, %
WorkspaceCreationScreenKt 0% (0/36) 0% (0/123) 0% (0/217) 0% (0/2745)
WorkspaceCreationScreenKt$WhenMappings
WorkspaceCreationScreenKt$WorkspaceCreationScreen$1$1 0% (0/1) 0% (0/1) 0% (0/11)
Total 0% (0/37) 0% (0/123) 0% (0/218) 0% (0/2756)


 package com.georgeci.moneysurfer.feature.workspace.creation
 
 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.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.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.OutlinedCard
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBar
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 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.uikit.components.SurferCurrencyBottomSheet
 import com.georgeci.moneysurfer.uikit.components.SurferCurrencyOption
 import com.georgeci.moneysurfer.uikit.components.SurferCurrencyPickerField
 import com.georgeci.moneysurfer.uikit.components.SurferFullScreenLoader
 import com.georgeci.moneysurfer.uikit.components.base.SurferFormSection
 import com.georgeci.moneysurfer.uikit.components.base.SurferToolbarButtonAction
 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.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.first_run_currency_search_placeholder
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_action_create
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_action_update
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_close_content_description
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_error_remote_sync
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_error_unknown
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_invite_subtitle
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_invite_title
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_name_label
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_preview_fallback_title
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_preview_subtitle_format
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_preview_subtitle_offline_format
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_section_currency
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_section_members
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_title_create
 import moneysurfer.feature.workspace.generated.resources.workspace_creation_title_edit
 import moneysurfer.feature.workspace.generated.resources.workspace_members_role_editor_label
 import moneysurfer.feature.workspace.generated.resources.workspace_members_role_owner_label
 import moneysurfer.feature.workspace.generated.resources.workspace_members_role_viewer_label
 import org.jetbrains.compose.resources.pluralStringResource
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 
 /** Stable selectors for the workspace creation screen — see docs/testing/testing-strategy.md. */
 object WorkspaceCreationTestTags {
     const val Root = "workspaceCreation:root"
     const val Name = "workspaceCreation:name"
     const val Save = "workspaceCreation:save"
 }
 
 @Composable
 fun WorkspaceCreationScreen(
     workspaceId: WorkspaceId?,
     onNavigateBack: () -> Unit,
     viewModel: WorkspaceCreationViewModel = koinViewModel(),
 ) {
     LaunchedEffect(workspaceId) { viewModel.init(workspaceId) }
     val state by viewModel.collectAsStateWithLifecycle()
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             WorkspaceCreationEffect.NavigateBack -> onNavigateBack()
         }
     }
 
     when (val current = state) {
         WorkspaceCreationState.Loading -> WorkspaceCreationLoading(onEvent = viewModel::onEvent)
         is WorkspaceCreationState.Content -> WorkspaceCreationContent(
             state = current,
             onEvent = viewModel::onEvent,
         )
     }
 }
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 private fun WorkspaceCreationLoading(onEvent: (WorkspaceCreationEvent) -> Unit) {
     Box(modifier = Modifier.fillMaxSize()) {
         Scaffold(
             modifier = Modifier.surferSafeInsets(),
             topBar = {
                 TopAppBar(
                     title = { Text(stringResource(Res.string.workspace_creation_title_create)) },
                     navigationIcon = {
                         IconButton(onClick = { onEvent(WorkspaceCreationEvent.OnBackClick) }) {
                             Icon(
                                 imageVector = SurferIcons.Close,
                                 contentDescription = stringResource(
                                     Res.string.workspace_creation_close_content_description,
                                 ),
                             )
                         }
                     },
                 )
             },
         ) { padding ->
             Box(modifier = Modifier.fillMaxSize().padding(padding))
         }
 
         SurferFullScreenLoader(modifier = Modifier.fillMaxSize())
     }
 }
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 private fun WorkspaceCreationContent(
     state: WorkspaceCreationState.Content,
     onEvent: (WorkspaceCreationEvent) -> Unit,
 ) {
     val titleRes = if (state.isEditing) {
         Res.string.workspace_creation_title_edit
     } else {
         Res.string.workspace_creation_title_create
     }
     val actionRes = if (state.isEditing) {
         Res.string.workspace_creation_action_update
     } else {
         Res.string.workspace_creation_action_create
     }
     Box(modifier = Modifier.fillMaxSize()) {
         Scaffold(
             modifier = Modifier
                 .surferSafeInsets()
                 .testTag(WorkspaceCreationTestTags.Root)
                 .surferTestTagAsId(),
             topBar = {
                 TopAppBar(
                     title = {
                         Text(
                             text = stringResource(titleRes),
                             style = AppTheme.typography.titleLarge,
                         )
                     },
                     navigationIcon = {
                         IconButton(onClick = { onEvent(WorkspaceCreationEvent.OnBackClick) }) {
                             Icon(
                                 imageVector = SurferIcons.Close,
                                 contentDescription = stringResource(
                                     Res.string.workspace_creation_close_content_description,
                                 ),
                             )
                         }
                     },
                     actions = {
                         // The same pill every other create/edit toolbar uses ("Save", "Update") —
                         // a bare text action here made the two flows look like different apps.
                         SurferToolbarButtonAction(
                             icon = SurferIcons.Check,
                             text = stringResource(actionRes),
                             onClick = { onEvent(WorkspaceCreationEvent.OnSaveClick) },
                             enabled = state.name.isNotBlank() && !state.isSaving,
                             modifier = Modifier.testTag(WorkspaceCreationTestTags.Save),
                         )
                     },
                     colors = TopAppBarDefaults.topAppBarColors(),
                 )
             },
         ) { padding ->
             Column(
                 modifier = Modifier
                     .fillMaxSize()
                     .surferContentContainer()
                     .padding(top = padding.calculateTopPadding())
                     .verticalScroll(rememberScrollState())
                     .padding(horizontal = 24.dp)
                     .padding(top = 8.dp),
                 verticalArrangement = Arrangement.spacedBy(24.dp),
             ) {
                 state.error?.let { ErrorBanner(it) }
 
                 PreviewCard(
                     name = state.name,
                     // Null while creating: there is no membership to count yet, and "2 members"
                     // under a workspace that does not exist reads as somebody else's data.
                     memberCount = state.members.size.takeIf { state.isEditing },
                     currencyCode = state.currency,
                     isOffline = state.isOffline,
                 )
 
                 OutlinedTextField(
                     value = state.name,
                     onValueChange = { onEvent(WorkspaceCreationEvent.OnNameChanged(it)) },
                     label = { Text(stringResource(Res.string.workspace_creation_name_label)) },
                     singleLine = true,
                     modifier = Modifier
                         .fillMaxWidth()
                         .testTag(WorkspaceCreationTestTags.Name),
                 )
 
                 if (state.currencies.isNotEmpty() && !(state.isEditing && state.isOffline)) {
                     CurrencyPicker(
                         currencies = state.currencies,
                         selected = state.currency,
                         onSelect = { onEvent(WorkspaceCreationEvent.OnCurrencyChanged(it)) },
                     )
                 }
 
                 if (state.showMembersSection) {
                     MembersSection(
                         members = state.members,
                         showInviteRow = true,
                         onInviteClick = { onEvent(WorkspaceCreationEvent.OnInviteMemberClick) },
                     )
                 }
 
                 Spacer(Modifier.height(padding.calculateBottomPadding() + 24.dp))
             }
         }
 
         if (state.isSaving) {
             SurferFullScreenLoader(modifier = Modifier.fillMaxSize())
         }
     }
 }
 
 /**
  * A field that opens the currency bottom sheet, mirroring account creation. The supported list
  * is already eight entries and still growing, which a segmented control cannot hold on a phone
  * (see issue #277).
  */
 @Composable
 private fun CurrencyPicker(
     currencies: List<Currency>,
     selected: String,
     onSelect: (String) -> Unit,
 ) {
     val resolved = currencies.firstOrNull { it.code.value == selected } ?: currencies.first()
     var sheetOpen by rememberSaveable { mutableStateOf(false) }
 
     SurferFormSection(label = stringResource(Res.string.workspace_creation_section_currency)) {
         SurferCurrencyPickerField(
             symbol = resolved.symbol,
             code = resolved.code.value,
             name = resolved.displayName,
             onClick = { sheetOpen = true },
             expanded = sheetOpen,
         )
     }
 
     if (sheetOpen) {
         SurferCurrencyBottomSheet(
             title = stringResource(Res.string.workspace_creation_section_currency),
             searchPlaceholder = stringResource(Res.string.first_run_currency_search_placeholder),
             currencies = currencies.map {
                 SurferCurrencyOption(code = it.code.value, symbol = it.symbol, name = it.displayName)
             },
             selectedCode = resolved.code.value,
             onSelect = {
                 onSelect(it.code)
                 sheetOpen = false
             },
             onDismiss = { sheetOpen = false },
         )
     }
 }
 
 @Composable
 private fun PreviewCard(
     name: String,
     memberCount: Int?,
     currencyCode: String,
     isOffline: Boolean,
 ) {
     val initials = name.toInitials().ifBlank { "W" }
     Card(
         shape = RoundedCornerShape(16.dp),
         colors = CardDefaults.cardColors(
             containerColor = AppTheme.materialColors.primaryContainer,
             contentColor = AppTheme.materialColors.onPrimaryContainer,
         ),
         modifier = Modifier.fillMaxWidth(),
     ) {
         Row(
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(20.dp),
             verticalAlignment = Alignment.CenterVertically,
             horizontalArrangement = Arrangement.spacedBy(16.dp),
         ) {
             Box(
                 modifier = Modifier
                     .size(64.dp)
                     .clip(RoundedCornerShape(18.dp))
                     .background(AppTheme.materialColors.primary),
                 contentAlignment = Alignment.Center,
             ) {
                 Text(
                     text = initials,
                     style = AppTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
                     color = AppTheme.materialColors.onPrimary,
                 )
             }
             Column(modifier = Modifier.weight(1f)) {
                 Text(
                     text = name.ifBlank { stringResource(Res.string.workspace_creation_preview_fallback_title) },
                     style = AppTheme.typography.titleLarge,
                 )
                 Text(
                     text = if (isOffline || memberCount == null) {
                         stringResource(
                             Res.string.workspace_creation_preview_subtitle_offline_format,
                             currencyCode.ifBlank { "—" },
                         )
                     } else {
                         pluralStringResource(
                             Res.plurals.workspace_creation_preview_subtitle_format,
                             memberCount,
                             memberCount,
                             currencyCode.ifBlank { "—" },
                         )
                     },
                     style = AppTheme.typography.bodySmall,
                     modifier = Modifier.padding(top = 2.dp),
                 )
             }
         }
     }
 }
 
 @Composable
 private fun ErrorBanner(error: WorkspaceCreationError) {
     val text = when (error) {
         WorkspaceCreationError.RemoteSyncFailed -> stringResource(Res.string.workspace_creation_error_remote_sync)
         WorkspaceCreationError.Unknown -> stringResource(Res.string.workspace_creation_error_unknown)
     }
     Card(
         modifier = Modifier.fillMaxWidth(),
         colors = CardDefaults.cardColors(containerColor = AppTheme.materialColors.errorContainer),
     ) {
         Text(
             text = text,
             color = AppTheme.materialColors.onErrorContainer,
             style = AppTheme.typography.bodyMedium,
             modifier = Modifier.padding(16.dp),
         )
     }
 }
 
 @Composable
 private fun MembersSection(
     members: List<WorkspaceMemberUi>,
     showInviteRow: Boolean,
     onInviteClick: () -> Unit,
 ) {
     SurferFormSection(label = stringResource(Res.string.workspace_creation_section_members)) {
         Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
             if (showInviteRow) {
                 SurferInviteRow(
                     title = stringResource(Res.string.workspace_creation_invite_title),
                     subtitle = stringResource(Res.string.workspace_creation_invite_subtitle),
                     onClick = onInviteClick,
                 )
             }
             if (members.isNotEmpty()) {
                 OutlinedCard(
                     shape = RoundedCornerShape(16.dp),
                     modifier = Modifier.fillMaxWidth(),
                 ) {
                     members.forEach { member ->
                         SurferMemberRow(
                             name = member.name,
                             initial = member.initial,
                             roleLabel = roleLabel(member.role),
                         )
                     }
                 }
             }
         }
     }
 }
 
 @Composable
 private fun roleLabel(role: WorkspaceRole): String = when (role) {
     WorkspaceRole.OWNER -> stringResource(Res.string.workspace_members_role_owner_label)
     WorkspaceRole.EDITOR -> stringResource(Res.string.workspace_members_role_editor_label)
     WorkspaceRole.VIEWER -> stringResource(Res.string.workspace_members_role_viewer_label)
 }
 
 private fun String.toInitials(): String =
     trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
         .take(2)
         .joinToString("") { it.first().uppercaseChar().toString() }
 
 @Preview
 @Composable
 private fun WorkspaceCreationScreenPreview() {
     AppTheme {
         WorkspaceCreationContent(
             state = WorkspaceCreationState.Content(
                 workspaceId = null,
                 name = "Lisbon trip",
                 description = "",
                 currency = "EUR",
                 currencies = previewCurrencies,
                 members = emptyList(),
                 isEditing = false,
             ),
             onEvent = {},
         )
     }
 }
 
 @Preview
 @Composable
 private fun WorkspaceEditScreenPreview() {
     AppTheme {
         WorkspaceCreationContent(
             state = WorkspaceCreationState.Content(
                 workspaceId = WorkspaceId("preview-ws-1"),
                 name = "Personal",
                 description = "Everyday spending and savings",
                 currency = "USD",
                 currencies = previewCurrencies,
                 members = emptyList(),
                 isEditing = true,
             ),
             onEvent = {},
         )
     }
 }
 
 private val previewCurrencies = listOf(
     Currency(CurrencyCode("EUR"), "€", "Euro"),
     Currency(CurrencyCode("USD"), "$", "US Dollar"),
     Currency(CurrencyCode("GBP"), "£", "British Pound"),
     Currency(CurrencyCode("PLN"), "zł", "Polish Zloty"),
 )