Coverage Summary for Class: CategoryDetailsScreenKt (com.georgeci.moneysurfer.feature.category.details)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CategoryDetailsScreenKt |
68.4%
(13/19)
|
15.9%
(17/107)
|
67.3%
(113/168)
|
22.2%
(281/1268)
|
| CategoryDetailsScreenKt$CategoryDetailsContent$4$1$1$5$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| CategoryDetailsScreenKt$CategoryDetailsContent$4$1$1$7$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| CategoryDetailsScreenKt$CategoryDetailsContent$4$1$1$7$2 |
100%
(1/1)
|
100%
(2/2)
|
100%
(11/11)
|
100%
(128/128)
|
| CategoryDetailsScreenKt$CategoryDetailsContent$4$1$1$7$2$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$5 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$6 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$7 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsContent$lambda$4$0$0$$inlined$items$default$8 |
0%
(0/1)
|
|
| CategoryDetailsScreenKt$CategoryDetailsScreen$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| CategoryDetailsScreenKt$CategoryDetailsScreen$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| CategoryDetailsScreenKt$CategoryDetailsScreen$4$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| CategoryDetailsScreenKt$WhenMappings |
|
| Total |
41.2%
(14/34)
|
17.4%
(19/109)
|
67%
(124/185)
|
28.6%
(409/1429)
|
package com.georgeci.moneysurfer.feature.category.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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CategoryType
import com.georgeci.moneysurfer.domain.primitives.TransactionId
import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
import com.georgeci.moneysurfer.uikit.components.SurferDetailPlaceholder
import com.georgeci.moneysurfer.uikit.components.SurferSkeletonRow
import com.georgeci.moneysurfer.uikit.components.account.SurferAccountStatCard
import com.georgeci.moneysurfer.uikit.components.account.SurferStatValueFormat
import com.georgeci.moneysurfer.uikit.components.base.SurferPaneAction
import com.georgeci.moneysurfer.uikit.components.base.SurferPaneScaffold
import com.georgeci.moneysurfer.uikit.components.base.SurferSectionHeader
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbarAction
import com.georgeci.moneysurfer.uikit.components.category.SurferCategoryBreakdownRow
import com.georgeci.moneysurfer.uikit.components.category.SurferCategoryHeroCard
import com.georgeci.moneysurfer.uikit.components.category.SurferCategoryTrendBar
import com.georgeci.moneysurfer.uikit.components.category.SurferCategoryTrendCard
import com.georgeci.moneysurfer.uikit.components.transaction.SurferSwipeToDeleteTransaction
import com.georgeci.moneysurfer.uikit.components.transaction.SurferTransactionLine
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.utils.HandleSideEffect
import kotlinx.datetime.Month
import kotlinx.datetime.YearMonth
import kotlinx.datetime.number
import moneysurfer.feature.category.generated.resources.Res
import moneysurfer.feature.category.generated.resources.category_details_edit_content_description
import moneysurfer.feature.category.generated.resources.category_details_hero_label_expense
import moneysurfer.feature.category.generated.resources.category_details_hero_label_income
import moneysurfer.feature.category.generated.resources.category_details_hero_label_transfer
import moneysurfer.feature.category.generated.resources.category_details_log_expense
import moneysurfer.feature.category.generated.resources.category_details_log_income
import moneysurfer.feature.category.generated.resources.category_details_missing
import moneysurfer.feature.category.generated.resources.category_details_recent
import moneysurfer.feature.category.generated.resources.category_details_recent_empty
import moneysurfer.feature.category.generated.resources.category_details_share_percent
import moneysurfer.feature.category.generated.resources.category_details_stat_average
import moneysurfer.feature.category.generated.resources.category_details_stat_per_transaction
import moneysurfer.feature.category.generated.resources.category_details_stat_transactions
import moneysurfer.feature.category.generated.resources.category_details_subcategories
import moneysurfer.feature.category.generated.resources.category_details_subcategories_empty
import moneysurfer.feature.category.generated.resources.category_details_title
import moneysurfer.feature.category.generated.resources.category_details_transaction_untitled
import moneysurfer.feature.category.generated.resources.category_details_trend_title
import moneysurfer.feature.category.generated.resources.month_short
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
@Composable
fun CategoryDetailsScreen(
categoryId: CategoryId,
onNavigateBack: () -> Unit,
onNavigateToCategoryEdit: (CategoryId) -> Unit = {},
onNavigateToCategoryDetails: (CategoryId) -> Unit = {},
onNavigateToTransactionCreation: () -> Unit = {},
onNavigateToTransactionDetails: (TransactionId) -> Unit = {},
viewModel: CategoryDetailsViewModel =
koinViewModel(key = categoryId.value) { parametersOf(categoryId) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
CategoryDetailsEffect.NavigateBack -> onNavigateBack()
CategoryDetailsEffect.NavigateToTransactionCreation -> onNavigateToTransactionCreation()
is CategoryDetailsEffect.NavigateToCategoryEdit -> onNavigateToCategoryEdit(effect.categoryId)
is CategoryDetailsEffect.NavigateToCategoryDetails ->
onNavigateToCategoryDetails(effect.categoryId)
is CategoryDetailsEffect.NavigateToTransactionDetails ->
onNavigateToTransactionDetails(effect.transactionId)
}
}
when (val current = state) {
is CategoryDetailsState.Loading -> CategoryDetailsPlaceholder(
message = null,
onEvent = viewModel::onEvent,
)
is CategoryDetailsState.Missing -> CategoryDetailsPlaceholder(
message = stringResource(Res.string.category_details_missing),
onEvent = viewModel::onEvent,
)
is CategoryDetailsState.Content -> CategoryDetailsContent(
state = current,
onEvent = viewModel::onEvent,
)
}
}
@Composable
private fun CategoryDetailsPlaceholder(
message: String?,
onEvent: (CategoryDetailsEvent) -> Unit,
) {
SurferPaneScaffold(
title = stringResource(Res.string.category_details_title),
onBack = { onEvent(CategoryDetailsEvent.OnBackClick) },
) { padding ->
// Loading passes no message and gets skeleton rows; a placeholder there would claim the
// category is gone when it is merely still arriving.
if (message == null) {
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(padding)
.padding(
horizontal = AppTheme.spacing.default,
vertical = AppTheme.spacing.medium,
),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
repeat(SKELETON_ROWS) {
SurferSkeletonRow()
}
}
} else {
Box(modifier = Modifier.fillMaxSize().surferContentContainer().padding(padding)) {
SurferDetailPlaceholder(text = message)
}
}
}
}
private const val SKELETON_ROWS = 5
/**
* The category, drawn from a state it is handed rather than one it resolves.
*
* `internal` for the Roborazzi captures in `androidHostTest`, which mount it with a state of their
* own — see docs/testing/screenshot-tests.md.
*/
@Composable
internal fun CategoryDetailsContent(
state: CategoryDetailsState.Content,
onEvent: (CategoryDetailsEvent) -> Unit,
) {
val visual = SurferCategoryPalette.visualFor(
id = state.categoryId.value,
iconKey = state.iconKey,
hue = state.hue,
systemKind = state.systemKind,
)
val currentMonthLabel = state.months.lastOrNull()?.let { monthShortLabel(it.monthOfYear) }.orEmpty()
SurferPaneScaffold(
title = stringResource(Res.string.category_details_title),
onBack = { onEvent(CategoryDetailsEvent.OnBackClick) },
actions = {
SurferToolbarAction(
icon = SurferIcons.Edit,
contentDescription = stringResource(
Res.string.category_details_edit_content_description,
),
onClick = { onEvent(CategoryDetailsEvent.OnEditClick) },
)
},
primaryAction = SurferPaneAction(
label = stringResource(
if (state.type == CategoryType.INCOME) {
Res.string.category_details_log_income
} else {
Res.string.category_details_log_expense
},
),
onClick = { onEvent(CategoryDetailsEvent.OnAddTransactionClick) },
),
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding()),
contentPadding = PaddingValues(
start = AppTheme.spacing.default,
end = AppTheme.spacing.default,
top = AppTheme.spacing.small,
bottom = padding.calculateBottomPadding() +
AppTheme.spacing.xxxLarge + AppTheme.spacing.xLarge,
),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
item(key = "hero") {
SurferCategoryHeroCard(
icon = visual.icon,
tint = visual.tint,
name = state.name,
label = stringResource(heroLabelFor(state.type), currentMonthLabel),
formattedAmount = state.formattedTotal,
)
}
item(key = "stats") {
Row(horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.small)) {
SurferAccountStatCard(
label = stringResource(Res.string.category_details_stat_average),
value = state.formattedAverage,
icon = SurferIcons.Calendar,
iconTint = visual.tint,
modifier = Modifier.weight(1f),
)
SurferAccountStatCard(
label = stringResource(Res.string.category_details_stat_transactions),
value = state.transactionCount.toString(),
icon = SurferIcons.Receipt,
iconTint = visual.tint,
modifier = Modifier.weight(1f),
// A count, not money — the default split treatment would pad "14" to "14.00".
valueFormat = SurferStatValueFormat.Plain,
)
SurferAccountStatCard(
label = stringResource(Res.string.category_details_stat_per_transaction),
value = state.formattedPerTransaction,
icon = SurferIcons.Tag,
iconTint = visual.tint,
modifier = Modifier.weight(1f),
)
}
}
item(key = "trend") {
// No trailing label — the stat tile directly above already carries the average,
// and the mockup's "Cap …" slot went away with caps moving to Budgets.
SurferCategoryTrendCard(
title = stringResource(Res.string.category_details_trend_title),
tint = visual.tint,
bars = state.months.map { month ->
SurferCategoryTrendBar(
label = monthShortLabel(month.monthOfYear),
value = month.minorValue,
formattedValue = month.formattedValue,
)
},
)
}
item(key = "subcategories-header") {
SurferSectionHeader(
title = stringResource(Res.string.category_details_subcategories),
modifier = Modifier.padding(
horizontal = AppTheme.spacing.small,
vertical = AppTheme.spacing.xSmall,
),
)
}
if (state.isLeaf) {
item(key = "subcategories-empty") {
Text(
text = stringResource(Res.string.category_details_subcategories_empty),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = AppTheme.spacing.small,
vertical = AppTheme.spacing.small,
),
)
}
} else {
items(state.subcategories, key = { "sub-${it.id.value}" }) { child ->
val childVisual = SurferCategoryPalette.visualFor(
id = child.id.value,
iconKey = child.iconKey,
hue = child.hue,
)
SurferCategoryBreakdownRow(
visual = childVisual,
name = child.name,
formattedAmount = child.formattedAmount,
sharePercentLabel = stringResource(
Res.string.category_details_share_percent,
(child.share * PercentScale).roundToInt(),
),
share = child.share,
onClick = { onEvent(CategoryDetailsEvent.OnSubcategoryClick(child.id)) },
)
}
}
item(key = "recent-header") {
SurferSectionHeader(
title = stringResource(Res.string.category_details_recent),
modifier = Modifier.padding(
horizontal = AppTheme.spacing.small,
vertical = AppTheme.spacing.xSmall,
),
)
}
if (state.transactions.isEmpty()) {
item(key = "recent-empty") {
Text(
text = stringResource(Res.string.category_details_recent_empty),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = AppTheme.spacing.small,
vertical = AppTheme.spacing.small,
),
)
}
} else {
items(state.transactions, key = { it.id.value }) { txn ->
val untitled = stringResource(Res.string.category_details_transaction_untitled)
val title = txn.title.ifBlank { untitled }
SurferSwipeToDeleteTransaction(
transactionTitle = title,
onDelete = { onEvent(CategoryDetailsEvent.OnDeleteTransaction(txn.id)) },
isTransfer = txn.isTransfer,
isSplit = txn.isSplitLeg,
) {
SurferTransactionLine(
icon = visual.icon,
title = title,
formattedAmount = txn.formattedAmount,
categoryHueSeed = txn.categoryHueSeed,
amountColor = if (txn.isExpense) {
AppTheme.materialColors.onSurface
} else {
AppTheme.semanticColors.income
},
onClick = { onEvent(CategoryDetailsEvent.OnTransactionClick(txn.id)) },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
private fun heroLabelFor(type: CategoryType) = when (type) {
CategoryType.INCOME -> Res.string.category_details_hero_label_income
CategoryType.TRANSFER -> Res.string.category_details_hero_label_transfer
CategoryType.EXPENSE -> Res.string.category_details_hero_label_expense
}
/**
* Short month name from the feature's own `month_short` array.
*
* Deliberately not shared with the identical-looking array in `feature/account`: this one labels
* a chart axis and takes the nominative ("март"), while the account list reads "archived <month>
* <year>" and needs the genitive ("марта"). Same English strings, different Russian ones.
*/
@Composable
private fun monthShortLabel(month: Month): String =
stringArrayResource(Res.array.month_short)[month.number - 1]
private const val PercentScale = 100
@Preview
@Composable
private fun CategoryDetailsContentPreview() {
AppTheme {
CategoryDetailsContent(
state = CategoryDetailsState.Content(
categoryId = CategoryId("dining"),
name = "Dining",
type = CategoryType.EXPENSE,
iconKey = "receipt",
hue = 340,
systemKind = null,
isLeaf = false,
formattedTotal = "€168.55",
formattedAverage = "€196.83",
formattedPerTransaction = "€12.04",
transactionCount = 14,
months = listOf(
CategoryTrendMonthUi(YearMonth(2025, 11), 20500, "€205.00"),
CategoryTrendMonthUi(YearMonth(2025, 12), 19200, "€192.00"),
CategoryTrendMonthUi(YearMonth(2026, 1), 24000, "€240.00"),
CategoryTrendMonthUi(YearMonth(2026, 2), 19800, "€198.00"),
CategoryTrendMonthUi(YearMonth(2026, 3), 17600, "€176.00"),
CategoryTrendMonthUi(YearMonth(2026, 4), 16855, "€168.55"),
),
subcategories = listOf(
CategorySubcategoryUi(
id = CategoryId("d-delivery"),
name = "Delivery",
iconKey = "receipt",
hue = 340,
formattedAmount = "€106.15",
share = 0.63f,
),
CategorySubcategoryUi(
id = CategoryId("d-coffee"),
name = "Coffee",
iconKey = "cash",
hue = 35,
formattedAmount = "€62.40",
share = 0.37f,
),
),
transactions = listOf(
CategoryTransactionUi(
id = TransactionId("1"),
title = "Sushi Bar",
formattedAmount = "€38.20",
isExpense = true,
categoryHueSeed = "dining",
),
),
),
onEvent = {},
)
}
}