Coverage Summary for Class: GoalDetailsScreenKt (com.georgeci.moneysurfer.feature.goal.details)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| GoalDetailsScreenKt |
0%
(0/30)
|
0%
(0/118)
|
0%
(0/163)
|
0%
(0/2563)
|
| GoalDetailsScreenKt$GoalDetailsBody$lambda$0$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| GoalDetailsScreenKt$GoalDetailsBody$lambda$0$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| GoalDetailsScreenKt$GoalDetailsBody$lambda$0$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| GoalDetailsScreenKt$GoalDetailsBody$lambda$0$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| Total |
0%
(0/34)
|
0%
(0/118)
|
0%
(0/163)
|
0%
(0/2563)
|
package com.georgeci.moneysurfer.feature.goal.details
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.GoalStatus
import com.georgeci.moneysurfer.domain.primitives.GoalContributionId
import com.georgeci.moneysurfer.domain.primitives.GoalId
import com.georgeci.moneysurfer.feature.goal.formatGoalDate
import com.georgeci.moneysurfer.feature.goal.labelRes
import com.georgeci.moneysurfer.feature.goal.toUi
import com.georgeci.moneysurfer.navigation.GoalContributionMode
import com.georgeci.moneysurfer.uikit.components.base.SurferPaneScaffold
import com.georgeci.moneysurfer.uikit.components.goal.SurferGoalContributionRow
import com.georgeci.moneysurfer.uikit.components.goal.SurferGoalIcon
import com.georgeci.moneysurfer.uikit.components.goal.SurferGoalProgressRing
import com.georgeci.moneysurfer.uikit.components.goal.SurferGoalStatusPill
import com.georgeci.moneysurfer.uikit.components.goal.goalAccentColor
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
import com.georgeci.moneysurfer.uikit.preview.SurferComponentPreview
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.utils.AsyncState
import com.georgeci.moneysurfer.utils.HandleSideEffect
import kotlinx.datetime.LocalDate
import moneysurfer.feature.goal.generated.resources.Res
import moneysurfer.feature.goal.generated.resources.goal_details_actions
import moneysurfer.feature.goal.generated.resources.goal_details_add
import moneysurfer.feature.goal.generated.resources.goal_details_archive
import moneysurfer.feature.goal.generated.resources.goal_details_delete
import moneysurfer.feature.goal.generated.resources.goal_details_delete_cancel
import moneysurfer.feature.goal.generated.resources.goal_details_delete_confirm
import moneysurfer.feature.goal.generated.resources.goal_details_delete_message
import moneysurfer.feature.goal.generated.resources.goal_details_delete_title
import moneysurfer.feature.goal.generated.resources.goal_details_edit
import moneysurfer.feature.goal.generated.resources.goal_details_history
import moneysurfer.feature.goal.generated.resources.goal_details_history_empty
import moneysurfer.feature.goal.generated.resources.goal_details_linked_account
import moneysurfer.feature.goal.generated.resources.goal_details_linked_account_note
import moneysurfer.feature.goal.generated.resources.goal_details_of_target
import moneysurfer.feature.goal.generated.resources.goal_details_pause
import moneysurfer.feature.goal.generated.resources.goal_details_reached
import moneysurfer.feature.goal.generated.resources.goal_details_remaining
import moneysurfer.feature.goal.generated.resources.goal_details_resume
import moneysurfer.feature.goal.generated.resources.goal_details_unarchive
import moneysurfer.feature.goal.generated.resources.goal_details_withdraw
import moneysurfer.feature.goal.generated.resources.goals_card_deadline
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/** Stable selectors for the Goal details screen — see docs/testing/testing-strategy.md. */
object GoalDetailsTestTags {
const val Root = "goalDetails:root"
const val AddMoneyButton = "goalDetails:addMoney"
const val HistoryHeader = "goalDetails:history"
}
@Composable
fun GoalDetailsScreen(
goalId: GoalId,
onNavigateBack: () -> Unit,
onNavigateToEdit: (GoalId) -> Unit,
onNavigateToContribution: (GoalId, GoalContributionMode) -> Unit,
viewModel: GoalDetailsViewModel = koinViewModel(key = goalId.value) { parametersOf(goalId) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
GoalDetailsEffect.NavigateBack -> onNavigateBack()
is GoalDetailsEffect.NavigateToEdit -> onNavigateToEdit(effect.goalId)
is GoalDetailsEffect.NavigateToContribution ->
onNavigateToContribution(effect.goalId, effect.mode)
}
}
GoalDetailsScaffold(
content = (state as? AsyncState.Content)?.value,
onEvent = viewModel::onEvent,
)
}
@Composable
private fun GoalDetailsScaffold(
content: GoalDetailsContent?,
onEvent: (GoalDetailsEvent) -> Unit,
) {
SurferPaneScaffold(
title = content?.title.orEmpty(),
modifier = Modifier
.testTag(GoalDetailsTestTags.Root)
.surferTestTagAsId(),
onBack = { onEvent(GoalDetailsEvent.OnBackClick) },
actions = {
IconButton(onClick = { onEvent(GoalDetailsEvent.OnActionsClick) }) {
Icon(
imageVector = SurferIcons.MoreVert,
contentDescription = stringResource(Res.string.goal_details_actions),
)
}
},
) { padding ->
if (content == null) {
Box(modifier = Modifier.fillMaxSize().padding(padding))
return@SurferPaneScaffold
}
GoalDetailsBody(content = content, padding = padding, onEvent = onEvent)
if (content.showActions) {
GoalActionsSheet(content = content, onEvent = onEvent)
}
if (content.showDeleteConfirmation) {
DeleteGoalDialog(title = content.title, onEvent = onEvent)
}
}
}
@Composable
private fun GoalDetailsBody(
content: GoalDetailsContent,
padding: PaddingValues,
onEvent: (GoalDetailsEvent) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().surferContentContainer().padding(padding),
contentPadding = PaddingValues(
horizontal = AppTheme.spacing.default,
vertical = AppTheme.spacing.small,
),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
item { GoalHero(content = content) }
item { GoalActionsRow(content = content, onEvent = onEvent) }
if (content.accountName != null) {
item { LinkedAccountNote(accountName = content.accountName) }
}
item {
Text(
text = stringResource(Res.string.goal_details_history),
style = AppTheme.typography.titleMedium,
color = AppTheme.materialColors.onSurface,
modifier = Modifier.testTag(GoalDetailsTestTags.HistoryHeader),
)
}
if (content.contributions.isEmpty()) {
item {
Text(
text = stringResource(Res.string.goal_details_history_empty),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
}
items(content.contributions, key = { it.id.value }) { contribution ->
SurferGoalContributionRow(
amountLabel = contribution.amountLabel,
dateLabel = formatGoalDate(contribution.occurredOn),
isWithdrawal = contribution.isWithdrawal,
note = contribution.note,
)
}
}
}
@Composable
private fun GoalHero(content: GoalDetailsContent) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = AppTheme.spacing.medium),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
SurferGoalProgressRing(
progress = content.progress,
percentLabel = content.percentLabel,
savedLabel = content.savedLabel,
targetLabel = stringResource(Res.string.goal_details_of_target, content.targetLabel),
color = goalAccentColor(content.hue),
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
) {
SurferGoalIcon(emoji = content.emoji, hue = content.hue)
SurferGoalStatusPill(
status = content.status.toUi(),
label = stringResource(content.status.labelRes()),
)
}
Text(
text = if (content.isReached) {
stringResource(Res.string.goal_details_reached)
} else {
stringResource(Res.string.goal_details_remaining, content.remainingLabel)
},
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (content.targetDate != null) {
Text(
text = stringResource(Res.string.goals_card_deadline, formatGoalDate(content.targetDate)),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
}
}
@Composable
private fun GoalActionsRow(
content: GoalDetailsContent,
onEvent: (GoalDetailsEvent) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
) {
Button(
onClick = { onEvent(GoalDetailsEvent.OnAddMoneyClick) },
enabled = content.status == GoalStatus.ACTIVE || content.status == GoalStatus.COMPLETED,
modifier = Modifier
.weight(1f)
.testTag(GoalDetailsTestTags.AddMoneyButton),
) {
Text(stringResource(Res.string.goal_details_add))
}
OutlinedButton(
onClick = { onEvent(GoalDetailsEvent.OnWithdrawClick) },
modifier = Modifier.weight(1f),
) {
Text(stringResource(Res.string.goal_details_withdraw))
}
}
}
@Composable
private fun LinkedAccountNote(accountName: String) {
Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.xxSmall)) {
Text(
text = stringResource(Res.string.goal_details_linked_account, accountName),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurface,
)
Text(
text = stringResource(Res.string.goal_details_linked_account_note),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GoalActionsSheet(
content: GoalDetailsContent,
onEvent: (GoalDetailsEvent) -> Unit,
) {
ModalBottomSheet(
onDismissRequest = { onEvent(GoalDetailsEvent.OnActionsDismiss) },
containerColor = AppTheme.materialColors.surfaceContainerLow,
) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = AppTheme.spacing.large)) {
SheetAction(
label = stringResource(Res.string.goal_details_edit),
onClick = { onEvent(GoalDetailsEvent.OnEditClick) },
)
SheetAction(
label = if (content.status == GoalStatus.PAUSED) {
stringResource(Res.string.goal_details_resume)
} else {
stringResource(Res.string.goal_details_pause)
},
onClick = { onEvent(GoalDetailsEvent.OnPauseResumeClick) },
)
SheetAction(
label = if (content.status == GoalStatus.ARCHIVED) {
stringResource(Res.string.goal_details_unarchive)
} else {
stringResource(Res.string.goal_details_archive)
},
onClick = { onEvent(GoalDetailsEvent.OnArchiveClick) },
)
SheetAction(
label = stringResource(Res.string.goal_details_delete),
onClick = { onEvent(GoalDetailsEvent.OnDeleteClick) },
destructive = true,
)
}
}
}
@Composable
private fun SheetAction(
label: String,
onClick: () -> Unit,
destructive: Boolean = false,
) {
TextButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = label,
style = AppTheme.typography.bodyLarge,
color = if (destructive) AppTheme.materialColors.error else AppTheme.materialColors.onSurface,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start,
)
}
}
@Composable
private fun DeleteGoalDialog(
title: String,
onEvent: (GoalDetailsEvent) -> Unit,
) {
AlertDialog(
onDismissRequest = { onEvent(GoalDetailsEvent.OnDeleteCancel) },
title = { Text(stringResource(Res.string.goal_details_delete_title)) },
text = { Text(stringResource(Res.string.goal_details_delete_message, title)) },
confirmButton = {
TextButton(onClick = { onEvent(GoalDetailsEvent.OnDeleteConfirm) }) {
Text(
text = stringResource(Res.string.goal_details_delete_confirm),
color = AppTheme.materialColors.error,
)
}
},
dismissButton = {
TextButton(onClick = { onEvent(GoalDetailsEvent.OnDeleteCancel) }) {
Text(stringResource(Res.string.goal_details_delete_cancel))
}
},
)
}
@Preview
@Composable
private fun GoalDetailsScreenPreview() {
SurferComponentPreview {
GoalDetailsScaffold(
content = GoalDetailsContent(
title = "Emergency fund",
emoji = "☂️",
hue = 210,
status = GoalStatus.ACTIVE,
targetDate = LocalDate(2026, 8, 1),
accountName = "Savings",
progress = 0.74f,
percentLabel = "74%",
savedLabel = "€8,915",
targetLabel = "€12,000",
remainingLabel = "€3,085",
isReached = false,
contributions = listOf(
GoalContributionUi(
id = GoalContributionId("c-1"),
amountLabel = "+€250",
occurredOn = LocalDate(2026, 7, 12),
note = "Salary top-up",
isWithdrawal = false,
),
),
),
onEvent = {},
)
}
}