Coverage Summary for Class: TransactionFilterDateSectionKt (com.georgeci.moneysurfer.feature.transaction.filters)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| TransactionFilterDateSectionKt |
50%
(9/18)
|
43.5%
(27/62)
|
76.1%
(86/113)
|
73.8%
(1144/1550)
|
| TransactionFilterDateSectionKt$WhenMappings |
|
| Total |
50%
(9/18)
|
43.5%
(27/62)
|
76.1%
(86/113)
|
73.8%
(1144/1550)
|
package com.georgeci.moneysurfer.feature.transaction.filters
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.unit.dp
import com.georgeci.moneysurfer.feature.transaction.filter.TransactionDatePreset
import com.georgeci.moneysurfer.feature.transaction.filter.TransactionDateRange
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.semantics.SurferSemantics
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import moneysurfer.feature.transaction.generated.resources.Res
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_any
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_custom
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_follows_period
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_from
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_last_month
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_picker_clear
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_picker_confirm
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_picker_dismiss
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_this_month
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_this_week
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_this_year
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_to
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_today
import moneysurfer.feature.transaction.generated.resources.transaction_filters_date_yesterday
import moneysurfer.feature.transaction.generated.resources.transaction_filters_section_date
import moneysurfer.feature.transaction.generated.resources.transactions_list_date_full
import moneysurfer.feature.transaction.generated.resources.transactions_list_months_genitive
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.stringResource
import kotlin.time.Instant
/**
* Preset chips plus explicit From/To fields.
*
* The From/To fields are only shown for a custom range: while a preset is picked they would be
* read-only echoes of it, and while the range follows the period pager there is no range to show
* — the caption says where the dates are coming from instead.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun DateRangeSection(
dateRange: TransactionDateRange,
onEvent: (TransactionFiltersEvent) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
FilterSectionHeader(stringResource(Res.string.transaction_filters_section_date))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TransactionDatePreset.entries.forEach { preset ->
FilterChip(
selected = (dateRange as? TransactionDateRange.Preset)?.preset == preset,
onClick = { onEvent(TransactionFiltersEvent.OnDatePresetClick(preset)) },
label = { Text(preset.label()) },
)
}
FilterChip(
selected = dateRange is TransactionDateRange.Custom,
onClick = { onEvent(TransactionFiltersEvent.OnCustomDateClick) },
label = { Text(stringResource(Res.string.transaction_filters_date_custom)) },
)
}
when (dateRange) {
TransactionDateRange.FollowPeriod -> Text(
text = stringResource(Res.string.transaction_filters_date_follows_period),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
is TransactionDateRange.Custom -> CustomRangeFields(range = dateRange, onEvent = onEvent)
is TransactionDateRange.Preset -> Unit
}
}
}
@Composable
private fun CustomRangeFields(
range: TransactionDateRange.Custom,
onEvent: (TransactionFiltersEvent) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
DateField(
label = stringResource(Res.string.transaction_filters_date_from),
date = range.from,
onPicked = { onEvent(TransactionFiltersEvent.OnCustomFromPicked(it)) },
modifier = Modifier.weight(1f),
)
DateField(
label = stringResource(Res.string.transaction_filters_date_to),
date = range.to,
onPicked = { onEvent(TransactionFiltersEvent.OnCustomToPicked(it)) },
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun DateField(
label: String,
date: LocalDate?,
onPicked: (LocalDate?) -> Unit,
modifier: Modifier = Modifier,
) {
var picking by remember { mutableStateOf(false) }
val shape = AppTheme.shapes.extraSmall
Row(
modifier = modifier
.heightIn(min = 56.dp)
.clip(shape)
.border(1.dp, AppTheme.materialColors.outline, shape)
.clickable { picking = true }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = SurferIcons.Event,
// decorative — the field label carries the meaning
contentDescription = SurferSemantics.Decorative,
tint = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
Text(
text = date?.let { formatFilterDate(it) }
?: stringResource(Res.string.transaction_filters_date_any),
style = AppTheme.typography.bodyLarge,
color = AppTheme.materialColors.onSurface,
maxLines = 1,
)
}
}
if (picking) {
FilterDatePickerDialog(
initial = date,
onDismiss = { picking = false },
onPicked = {
picking = false
onPicked(it)
},
)
}
}
/**
* Unlike the transaction-entry picker this one allows future dates — a filter over planned
* transactions is exactly the case where "to the end of next month" is a sensible bound — and it
* can clear the bound, which is what an open-ended range means.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterDatePickerDialog(
initial: LocalDate?,
onDismiss: () -> Unit,
onPicked: (LocalDate?) -> Unit,
) {
// The picker speaks UTC millis, so the conversion has to be UTC on both sides or a date
// picked east of Greenwich comes back a day early.
val state = rememberDatePickerState(
initialSelectedDateMillis = initial?.atStartOfDayIn(TimeZone.UTC)?.toEpochMilliseconds(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
onPicked(
state.selectedDateMillis?.let {
Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date
},
)
},
) { Text(stringResource(Res.string.transaction_filters_date_picker_confirm)) }
},
dismissButton = {
Row {
TextButton(onClick = { onPicked(null) }) {
Text(stringResource(Res.string.transaction_filters_date_picker_clear))
}
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.transaction_filters_date_picker_dismiss))
}
}
},
) {
DatePicker(state = state)
}
}
/** Genitive month, as everywhere else a concrete date is spelled out on these screens. */
@Composable
private fun formatFilterDate(date: LocalDate): String = stringResource(
Res.string.transactions_list_date_full,
date.day,
stringArrayResource(Res.array.transactions_list_months_genitive)[date.month.number - 1],
date.year,
)
@Composable
private fun TransactionDatePreset.label(): String = stringResource(
when (this) {
TransactionDatePreset.Today -> Res.string.transaction_filters_date_today
TransactionDatePreset.Yesterday -> Res.string.transaction_filters_date_yesterday
TransactionDatePreset.ThisWeek -> Res.string.transaction_filters_date_this_week
TransactionDatePreset.ThisMonth -> Res.string.transaction_filters_date_this_month
TransactionDatePreset.LastMonth -> Res.string.transaction_filters_date_last_month
TransactionDatePreset.ThisYear -> Res.string.transaction_filters_date_this_year
},
)