From 7f925e46ad88b96128a298b7922192cd3dcc02d5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 26 Jul 2026 14:51:36 +0000 Subject: [PATCH 01/10] Added due date picker to create task. Co-authored-by: MessiasLima --- .../composeResources/values/strings.xml | 5 + .../component/datepicker/FoliaryDatePicker.kt | 160 ++++++++++++++++++ .../feature/createtask/CreateTaskScreen.kt | 13 ++ .../feature/createtask/CreateTaskViewModel.kt | 10 +- .../datepicker/FoliaryDatePickerTest.kt | 121 +++++++++++++ .../createtask/CreateTaskScreenTest.kt | 55 ++++++ .../createtask/CreateTaskViewModelTest.kt | 53 ++++++ 7 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml index 04cc92b8..5b077d9b 100644 --- a/foliary/src/commonMain/composeResources/values/strings.xml +++ b/foliary/src/commonMain/composeResources/values/strings.xml @@ -59,6 +59,11 @@ Add description... Save Title is required + Due date + Add due date + Clear due date + Confirm + Cancel diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt new file mode 100644 index 00000000..ae7c01e2 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt @@ -0,0 +1,160 @@ +package dev.appoutlet.foliary.core.ui.component.datepicker + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DatePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.X +import dev.appoutlet.foliary.core.ui.component.card.FoliaryCard +import foliary.foliary.generated.resources.Res +import foliary.foliary.generated.resources.create_task_due_date_clear_a11y +import foliary.foliary.generated.resources.date_picker_cancel +import foliary.foliary.generated.resources.date_picker_confirm +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant + +private const val PlaceholderAlpha = 0.6f + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FoliaryDatePicker( + label: String, + placeholder: String, + selectedDate: Instant?, + onDateSelected: (Instant?) -> Unit, + modifier: Modifier = Modifier, +) { + var showBottomSheet by rememberSaveable { mutableStateOf(false) } + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = selectedDate?.toPickerMillis() + ) + + Box(modifier = modifier) { + FoliaryCard( + modifier = Modifier + .fillMaxWidth() + .testTag("FoliaryDatePicker:Card") + .clickable { showBottomSheet = true } + ) { + Column( + modifier = Modifier.padding(all = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = selectedDate?.let { formatDate(it) } ?: placeholder, + modifier = Modifier + .align(Alignment.CenterStart) + .testTag("FoliaryDatePicker:Value"), + style = MaterialTheme.typography.bodyMedium, + color = if (selectedDate != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onBackground.copy(alpha = PlaceholderAlpha) + } + ) + + if (selectedDate != null) { + IconButton( + onClick = { onDateSelected(null) }, + modifier = Modifier + .align(Alignment.CenterEnd) + .testTag("FoliaryDatePicker:ClearButton") + ) { + Icon( + imageVector = Lucide.X, + contentDescription = stringResource(Res.string.create_task_due_date_clear_a11y), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false } + ) { + DatePicker(state = datePickerState) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + TextButton( + onClick = { showBottomSheet = false } + ) { + Text(text = stringResource(Res.string.date_picker_cancel)) + } + + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + onDateSelected(millis.toEndOfDayInstant()) + } + showBottomSheet = false + }, + enabled = datePickerState.selectedDateMillis != null + ) { + Text(text = stringResource(Res.string.date_picker_confirm)) + } + } + } + } +} + +private fun formatDate(instant: Instant): String = instant.toLocalDate().toString() + +private fun Instant.toLocalDate(): LocalDate = toLocalDateTime(TimeZone.currentSystemDefault()).date + +private fun Instant.toPickerMillis(): Long = toLocalDate() + .atTime(LocalTime(0, 0)) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + +private fun Long.toEndOfDayInstant(): Instant { + val localDate = Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()).date + return localDate.toEndOfDayInstant() +} + +private fun LocalDate.toEndOfDayInstant(): Instant = + atTime(LocalTime(23, 59, 59, 999_999_999)) + .toInstant(TimeZone.currentSystemDefault()) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt index 49413217..c4810663 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt @@ -29,12 +29,15 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import dev.appoutlet.foliary.core.ui.component.button.FoliaryBackIconButton import dev.appoutlet.foliary.core.ui.component.button.FoliaryPrimaryButton +import dev.appoutlet.foliary.core.ui.component.datepicker.FoliaryDatePicker import dev.appoutlet.foliary.core.ui.component.modifier.widthInCompact import dev.appoutlet.foliary.core.ui.component.textfield.FoliaryTextField import dev.appoutlet.foliary.feature.main.getWindowDecorationPadding import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.create_task_description_label import foliary.foliary.generated.resources.create_task_description_placeholder +import foliary.foliary.generated.resources.create_task_due_date_label +import foliary.foliary.generated.resources.create_task_due_date_placeholder import foliary.foliary.generated.resources.create_task_save import foliary.foliary.generated.resources.create_task_title import foliary.foliary.generated.resources.create_task_title_label @@ -63,6 +66,16 @@ fun CreateTaskScreen(viewData: CreateTaskViewData, onEvent: (CreateTaskEvent) -> Spacer(Modifier.size(16.dp)) TitleField(viewData.title, onEvent) DescriptionField(viewData.description, onEvent) + FoliaryDatePicker( + modifier = Modifier.widthInCompact() + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag("CreateTaskScreen:DueDatePicker"), + label = stringResource(Res.string.create_task_due_date_label), + placeholder = stringResource(Res.string.create_task_due_date_placeholder), + selectedDate = viewData.dueDate, + onDateSelected = { onEvent(CreateTaskEvent.DueDateChanged(it)) }, + ) Spacer(Modifier.size(64.dp)) } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt index aca629f4..5ff5c526 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt @@ -7,6 +7,7 @@ import dev.appoutlet.foliary.core.provider.uuid.UuidProvider import dev.appoutlet.foliary.data.task.TaskRepository import dev.appoutlet.foliary.data.task.database.entity.Task import org.koin.core.annotation.KoinViewModel +import kotlin.time.Instant import kotlin.uuid.Uuid @KoinViewModel @@ -21,11 +22,16 @@ class CreateTaskViewModel( when (event) { is CreateTaskEvent.TitleChanged -> onTitleChange(event.title) is CreateTaskEvent.DescriptionChanged -> onDescriptionChange(event.description) + is CreateTaskEvent.DueDateChanged -> onDueDateChange(event.dueDate) CreateTaskEvent.SaveClicked -> onSaveClick() CreateTaskEvent.BackClicked -> onBackClick() } } + private fun onDueDateChange(dueDate: Instant?) = intent { + reduce { state.copy(dueDate = dueDate) } + } + private fun onTitleChange(title: String) = intent { reduce { state.copy( @@ -47,7 +53,7 @@ class CreateTaskViewModel( title = state.title.trim(), description = state.description?.trim(), creationDate = timeProvider.now(), - dueDate = null, + dueDate = state.dueDate, completionDate = null, priority = null, url = null, @@ -68,12 +74,14 @@ data class CreateTaskViewData( val id: String? = null, val title: String = "", val description: String? = null, + val dueDate: Instant? = null, val saveButtonEnabled: Boolean = false, ) sealed interface CreateTaskEvent { data class TitleChanged(val title: String) : CreateTaskEvent data class DescriptionChanged(val description: String?) : CreateTaskEvent + data class DueDateChanged(val dueDate: Instant?) : CreateTaskEvent data object SaveClicked : CreateTaskEvent data object BackClicked : CreateTaskEvent } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt new file mode 100644 index 00000000..36d7b377 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt @@ -0,0 +1,121 @@ +package dev.appoutlet.foliary.core.ui.component.datepicker + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import foliary.foliary.generated.resources.Res +import foliary.foliary.generated.resources.create_task_due_date_clear_a11y +import foliary.foliary.generated.resources.create_task_due_date_placeholder +import foliary.foliary.generated.resources.date_picker_confirm +import io.kotest.matchers.shouldBe +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.getString +import kotlin.test.Test +import kotlin.time.Instant + +@OptIn(ExperimentalTestApi::class) +class FoliaryDatePickerTest { + + @Test + fun `should show placeholder when selectedDate is null`() = runComposeUiTest { + val placeholder = getString(Res.string.create_task_due_date_placeholder) + + setContent { + FoliaryDatePicker( + label = "Due date", + placeholder = placeholder, + selectedDate = null, + onDateSelected = {} + ) + } + + onNodeWithText(placeholder).assertIsDisplayed() + onNodeWithTag("FoliaryDatePicker:Value", useUnmergedTree = true).assertIsDisplayed() + onNodeWithTag("FoliaryDatePicker:ClearButton", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `should show selected date when selectedDate is not null`() = runComposeUiTest { + val placeholder = getString(Res.string.create_task_due_date_placeholder) + val selectedDate = Instant.parse("2026-07-21T12:00:00Z") + val expectedDateText = selectedDate.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + + setContent { + FoliaryDatePicker( + label = "Due date", + placeholder = placeholder, + selectedDate = selectedDate, + onDateSelected = {} + ) + } + + onNodeWithText(expectedDateText).assertIsDisplayed() + onNodeWithTag("FoliaryDatePicker:Value", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `should show clear button only when date is selected`() = runComposeUiTest { + val placeholder = getString(Res.string.create_task_due_date_placeholder) + val clearDescription = getString(Res.string.create_task_due_date_clear_a11y) + + setContent { + FoliaryDatePicker( + label = "Due date", + placeholder = placeholder, + selectedDate = null, + onDateSelected = {} + ) + } + + onNodeWithContentDescription(clearDescription).assertDoesNotExist() + } + + @Test + fun `clear button should call onDateSelected with null`() = runComposeUiTest { + val placeholder = getString(Res.string.create_task_due_date_placeholder) + val clearDescription = getString(Res.string.create_task_due_date_clear_a11y) + val selectedDate = Instant.parse("2026-07-21T12:00:00Z") + var cleared = false + + setContent { + FoliaryDatePicker( + label = "Due date", + placeholder = placeholder, + selectedDate = selectedDate, + onDateSelected = { cleared = it == null } + ) + } + + onNodeWithContentDescription(clearDescription) + .assertIsDisplayed() + .performClick() + + cleared shouldBe true + } + + @Test + fun `should open date picker bottom sheet when card is clicked`() = runComposeUiTest { + val placeholder = getString(Res.string.create_task_due_date_placeholder) + val confirmText = getString(Res.string.date_picker_confirm) + + setContent { + FoliaryDatePicker( + label = "Due date", + placeholder = placeholder, + selectedDate = null, + onDateSelected = {} + ) + } + + onNodeWithTag("FoliaryDatePicker:Card", useUnmergedTree = true).performClick() + + waitForIdle() + + onNodeWithText(confirmText).assertExists() + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt index f26de7ac..05be7f3b 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt @@ -15,13 +15,19 @@ import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.back_icon_button_a11y import foliary.foliary.generated.resources.create_task_description_label import foliary.foliary.generated.resources.create_task_description_placeholder +import foliary.foliary.generated.resources.create_task_due_date_clear_a11y +import foliary.foliary.generated.resources.create_task_due_date_label +import foliary.foliary.generated.resources.create_task_due_date_placeholder import foliary.foliary.generated.resources.create_task_save import foliary.foliary.generated.resources.create_task_title import foliary.foliary.generated.resources.create_task_title_label import foliary.foliary.generated.resources.create_task_title_placeholder import io.kotest.matchers.shouldBe +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import kotlin.test.Test +import kotlin.time.Instant @OptIn(ExperimentalTestApi::class) class CreateTaskScreenTest { @@ -81,4 +87,53 @@ class CreateTaskScreenTest { onNodeWithTag("CreateTaskScreen:DescriptionField", useUnmergedTree = true) .assertTextEquals(description) } + + @Test + fun `should show due date placeholder when no date is selected`() = runComposeUiTest { + setContent { + CreateTaskScreen(viewData = CreateTaskViewData(), onEvent = {}) + } + + onNodeWithText(getString(Res.string.create_task_due_date_label)).assertIsDisplayed() + onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed() + onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) + .assertDoesNotExist() + } + + @Test + fun `should show selected due date and clear button`() = runComposeUiTest { + val dueDate = Instant.parse("2026-07-21T12:00:00Z") + val expectedDateText = dueDate.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + + setContent { + CreateTaskScreen( + viewData = CreateTaskViewData(dueDate = dueDate), + onEvent = {} + ) + } + + onNodeWithText(getString(Res.string.create_task_due_date_label)).assertIsDisplayed() + onNodeWithText(expectedDateText).assertIsDisplayed() + onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) + .assertIsDisplayed() + } + + @Test + fun `should emit DueDateChanged null when clear button is clicked`() = runComposeUiTest { + val dueDate = Instant.parse("2026-07-21T12:00:00Z") + val events = mutableListOf() + + setContent { + CreateTaskScreen( + viewData = CreateTaskViewData(dueDate = dueDate), + onEvent = events::add, + ) + } + + onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) + .assertIsDisplayed() + .performClick() + + events.contains(CreateTaskEvent.DueDateChanged(null)) shouldBe true + } } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt index a69d752c..338a0888 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt @@ -65,6 +65,32 @@ class CreateTaskViewModelTest : expectSideEffect(CreateTaskAction.NavigateBack) } + @Test + fun `default state should have dueDate unset`() = test { + currentState.dueDate shouldBe null + } + + @Test + fun `DueDateChanged - should update dueDate`() = test { + val dueDate = Instant.parse("2026-07-21T12:00:00Z") + + viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDate)) + + awaitState().dueDate shouldBe dueDate + } + + @Test + fun `DueDateChanged - null should clear dueDate`() = test { + val dueDate = Instant.parse("2026-07-21T12:00:00Z") + + viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDate)) + expectState { copy(dueDate = dueDate) } + + viewModel.onEvent(CreateTaskEvent.DueDateChanged(null)) + + awaitState().dueDate shouldBe null + } + @Test fun `SaveClicked - save task on task creation flow`() = test { val id = Uuid.random() @@ -95,9 +121,36 @@ class CreateTaskViewModelTest : task.title shouldBe title task.description shouldBe description task.creationDate shouldBe creationDate + task.dueDate shouldBe null } // Navigate back after successful saving awaitSideEffect() shouldBe CreateTaskAction.NavigateBack } + + @Test + fun `SaveClicked - should persist selected dueDate`() = test { + val id = Uuid.random() + val title = "Task title" + val dueDate = Instant.parse("2026-07-21T12:00:00Z") + val taskCapture = Capture.slot() + val creationDate = Instant.parse("2026-07-21T12:00:00Z") + + every(mockUuidProvider::random) returns id + every(mockTimeProvider::now) returns creationDate + everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit + + viewModel.onEvent(CreateTaskEvent.TitleChanged(title)) + expectState { copy(title = title, saveButtonEnabled = true) } + + viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDate)) + expectState { copy(title = title, dueDate = dueDate, saveButtonEnabled = true) } + + viewModel.onEvent(CreateTaskEvent.SaveClicked) + awaitState().saveButtonEnabled shouldBe false + + taskCapture.get().dueDate shouldBe dueDate + + awaitSideEffect() shouldBe CreateTaskAction.NavigateBack + } } From faa36767d4555d8173461e694c4d398215e68989 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 26 Jul 2026 15:27:22 +0000 Subject: [PATCH 02/10] Fixed detekt issues; tests pass. Co-authored-by: MessiasLima --- .../component/datepicker/FoliaryDatePicker.kt | 177 +++++++++++------- 1 file changed, 111 insertions(+), 66 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt index ae7c01e2..dbac1f7d 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -23,6 +24,7 @@ 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.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Lucide @@ -41,6 +43,10 @@ import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource import kotlin.time.Instant +private const val EndOfDayHour = 23 +private const val EndOfDayMinute = 59 +private const val EndOfDaySecond = 59 +private const val EndOfDayNanosecond = 999_999_999 private const val PlaceholderAlpha = 0.6f @OptIn(ExperimentalMaterial3Api::class) @@ -58,84 +64,123 @@ fun FoliaryDatePicker( ) Box(modifier = modifier) { - FoliaryCard( - modifier = Modifier - .fillMaxWidth() - .testTag("FoliaryDatePicker:Card") - .clickable { showBottomSheet = true } + DatePickerCard( + label = label, + placeholder = placeholder, + selectedDate = selectedDate, + onClick = { showBottomSheet = true }, + onClear = { onDateSelected(null) } + ) + } + + if (showBottomSheet) { + DatePickerBottomSheet( + datePickerState = datePickerState, + onDismiss = { showBottomSheet = false }, + onConfirm = { onDateSelected(it.toEndOfDayInstant()) } + ) + } +} + +@Composable +private fun DatePickerCard( + label: String, + placeholder: String, + selectedDate: Instant?, + onClick: () -> Unit, + onClear: () -> Unit, +) { + FoliaryCard( + modifier = Modifier + .fillMaxWidth() + .testTag("FoliaryDatePicker:Card") + .clickable(onClick = onClick) + ) { + Column( + modifier = Modifier.padding(all = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Column( - modifier = Modifier.padding(all = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Box(modifier = Modifier.fillMaxWidth()) { Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground + text = selectedDate?.let { formatDate(it) } ?: placeholder, + modifier = Modifier + .align(Alignment.CenterStart) + .testTag("FoliaryDatePicker:Value"), + style = MaterialTheme.typography.bodyMedium, + color = datePickerValueColor(selectedDate) ) - Box(modifier = Modifier.fillMaxWidth()) { - Text( - text = selectedDate?.let { formatDate(it) } ?: placeholder, - modifier = Modifier - .align(Alignment.CenterStart) - .testTag("FoliaryDatePicker:Value"), - style = MaterialTheme.typography.bodyMedium, - color = if (selectedDate != null) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onBackground.copy(alpha = PlaceholderAlpha) - } + if (selectedDate != null) { + DateClearButton( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = onClear ) - - if (selectedDate != null) { - IconButton( - onClick = { onDateSelected(null) }, - modifier = Modifier - .align(Alignment.CenterEnd) - .testTag("FoliaryDatePicker:ClearButton") - ) { - Icon( - imageVector = Lucide.X, - contentDescription = stringResource(Res.string.create_task_due_date_clear_a11y), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } } } } } +} - if (showBottomSheet) { - ModalBottomSheet( - onDismissRequest = { showBottomSheet = false } +@Composable +private fun datePickerValueColor(selectedDate: Instant?): Color = if (selectedDate != null) { + MaterialTheme.colorScheme.onSurface +} else { + MaterialTheme.colorScheme.onBackground.copy(alpha = PlaceholderAlpha) +} + +@Composable +private fun DateClearButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + onClick = onClick, + modifier = modifier.testTag("FoliaryDatePicker:ClearButton") + ) { + Icon( + imageVector = Lucide.X, + contentDescription = stringResource(Res.string.create_task_due_date_clear_a11y), + tint = MaterialTheme.colorScheme.onSurface + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerBottomSheet( + datePickerState: DatePickerState, + onDismiss: () -> Unit, + onConfirm: (Long) -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss + ) { + DatePicker(state = datePickerState) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) ) { - DatePicker(state = datePickerState) + TextButton(onClick = onDismiss) { + Text(text = stringResource(Res.string.date_picker_cancel)) + } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let(onConfirm) + onDismiss() + }, + enabled = datePickerState.selectedDateMillis != null ) { - TextButton( - onClick = { showBottomSheet = false } - ) { - Text(text = stringResource(Res.string.date_picker_cancel)) - } - - TextButton( - onClick = { - datePickerState.selectedDateMillis?.let { millis -> - onDateSelected(millis.toEndOfDayInstant()) - } - showBottomSheet = false - }, - enabled = datePickerState.selectedDateMillis != null - ) { - Text(text = stringResource(Res.string.date_picker_confirm)) - } + Text(text = stringResource(Res.string.date_picker_confirm)) } } } @@ -156,5 +201,5 @@ private fun Long.toEndOfDayInstant(): Instant { } private fun LocalDate.toEndOfDayInstant(): Instant = - atTime(LocalTime(23, 59, 59, 999_999_999)) + atTime(LocalTime(EndOfDayHour, EndOfDayMinute, EndOfDaySecond, EndOfDayNanosecond)) .toInstant(TimeZone.currentSystemDefault()) From 6271d028f7fefc7071f4677378345cc57b512a78 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 16:40:30 +0100 Subject: [PATCH 03/10] reorg the strings suffix --- desktop/src/main/kotlin/SystemTray.kt | 8 ++++---- .../commonMain/composeResources/values-af/strings.xml | 4 ++-- .../commonMain/composeResources/values-ar/strings.xml | 4 ++-- .../commonMain/composeResources/values-az/strings.xml | 4 ++-- .../commonMain/composeResources/values-bar/strings.xml | 4 ++-- .../commonMain/composeResources/values-be/strings.xml | 4 ++-- .../commonMain/composeResources/values-bg/strings.xml | 4 ++-- .../commonMain/composeResources/values-bn/strings.xml | 4 ++-- .../commonMain/composeResources/values-ca/strings.xml | 4 ++-- .../commonMain/composeResources/values-cs/strings.xml | 4 ++-- .../commonMain/composeResources/values-cy/strings.xml | 4 ++-- .../commonMain/composeResources/values-da/strings.xml | 4 ++-- .../commonMain/composeResources/values-de/strings.xml | 4 ++-- .../commonMain/composeResources/values-el/strings.xml | 4 ++-- .../commonMain/composeResources/values-en/strings.xml | 6 +++--- .../commonMain/composeResources/values-es/strings.xml | 4 ++-- .../commonMain/composeResources/values-et/strings.xml | 4 ++-- .../commonMain/composeResources/values-eu/strings.xml | 4 ++-- .../commonMain/composeResources/values-fa/strings.xml | 4 ++-- .../commonMain/composeResources/values-fi/strings.xml | 4 ++-- .../commonMain/composeResources/values-fil/strings.xml | 4 ++-- .../commonMain/composeResources/values-fr/strings.xml | 4 ++-- .../commonMain/composeResources/values-ga/strings.xml | 4 ++-- .../commonMain/composeResources/values-gl/strings.xml | 4 ++-- .../commonMain/composeResources/values-he/strings.xml | 4 ++-- .../commonMain/composeResources/values-hi/strings.xml | 4 ++-- .../commonMain/composeResources/values-hr/strings.xml | 4 ++-- .../commonMain/composeResources/values-hu/strings.xml | 4 ++-- .../commonMain/composeResources/values-id/strings.xml | 4 ++-- .../commonMain/composeResources/values-it/strings.xml | 4 ++-- .../commonMain/composeResources/values-ja/strings.xml | 4 ++-- .../commonMain/composeResources/values-kk/strings.xml | 4 ++-- .../commonMain/composeResources/values-km/strings.xml | 4 ++-- .../commonMain/composeResources/values-ko/strings.xml | 4 ++-- .../commonMain/composeResources/values-lt/strings.xml | 4 ++-- .../commonMain/composeResources/values-lv/strings.xml | 4 ++-- .../commonMain/composeResources/values-ms/strings.xml | 4 ++-- .../commonMain/composeResources/values-mt/strings.xml | 4 ++-- .../commonMain/composeResources/values-nap/strings.xml | 4 ++-- .../commonMain/composeResources/values-nl/strings.xml | 4 ++-- .../commonMain/composeResources/values-no/strings.xml | 4 ++-- .../commonMain/composeResources/values-pa/strings.xml | 4 ++-- .../commonMain/composeResources/values-pl/strings.xml | 4 ++-- .../src/commonMain/composeResources/values/strings.xml | 10 +++++----- .../core/ui/component/datepicker/FoliaryDatePicker.kt | 8 ++++---- .../ui/component/datepicker/FoliaryDatePickerTest.kt | 4 ++-- i18n.lock | 4 ++-- 47 files changed, 102 insertions(+), 102 deletions(-) diff --git a/desktop/src/main/kotlin/SystemTray.kt b/desktop/src/main/kotlin/SystemTray.kt index 061e7a7f..85db9b78 100644 --- a/desktop/src/main/kotlin/SystemTray.kt +++ b/desktop/src/main/kotlin/SystemTray.kt @@ -4,8 +4,8 @@ import com.kdroid.composetray.tray.api.Tray import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.app_name import foliary.foliary.generated.resources.ic_foliary_tray -import foliary.foliary.generated.resources.open_app -import foliary.foliary.generated.resources.quit +import foliary.foliary.generated.resources.system_tray_open_app +import foliary.foliary.generated.resources.system_tray_quit import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import java.awt.Window @@ -13,8 +13,8 @@ import java.awt.Window @Composable fun ApplicationScope.FoliaryTray(window: () -> Window) { val appName = stringResource(Res.string.app_name) - val openApp = stringResource(Res.string.open_app) - val quit = stringResource(Res.string.quit) + val openApp = stringResource(Res.string.system_tray_open_app) + val quit = stringResource(Res.string.system_tray_quit) Tray( icon = painterResource(Res.drawable.ic_foliary_tray), diff --git a/foliary/src/commonMain/composeResources/values-af/strings.xml b/foliary/src/commonMain/composeResources/values-af/strings.xml index 942e246f..4d70ede2 100644 --- a/foliary/src/commonMain/composeResources/values-af/strings.xml +++ b/foliary/src/commonMain/composeResources/values-af/strings.xml @@ -28,6 +28,6 @@ Teken uit Profiel Foliary - Maak Foliary oop - Verlaat + Maak Foliary oop + Verlaat diff --git a/foliary/src/commonMain/composeResources/values-ar/strings.xml b/foliary/src/commonMain/composeResources/values-ar/strings.xml index 8ee9b80f..2ccf5837 100644 --- a/foliary/src/commonMain/composeResources/values-ar/strings.xml +++ b/foliary/src/commonMain/composeResources/values-ar/strings.xml @@ -28,6 +28,6 @@ تسجيل الخروج الملف الشخصي فولياري - افتح فولياري - إنهاء + افتح فولياري + إنهاء diff --git a/foliary/src/commonMain/composeResources/values-az/strings.xml b/foliary/src/commonMain/composeResources/values-az/strings.xml index 72fc4559..99e4c0c0 100644 --- a/foliary/src/commonMain/composeResources/values-az/strings.xml +++ b/foliary/src/commonMain/composeResources/values-az/strings.xml @@ -28,6 +28,6 @@ Hesabdan çıx Profil Foliary - Foliary-ni aç - Çıx + Foliary-ni aç + Çıx diff --git a/foliary/src/commonMain/composeResources/values-bar/strings.xml b/foliary/src/commonMain/composeResources/values-bar/strings.xml index 617ddcc8..a34e5b71 100644 --- a/foliary/src/commonMain/composeResources/values-bar/strings.xml +++ b/foliary/src/commonMain/composeResources/values-bar/strings.xml @@ -28,6 +28,6 @@ Ausloggn Profil Foliary - Foliary öffna - Beendn + Foliary öffna + Beendn diff --git a/foliary/src/commonMain/composeResources/values-be/strings.xml b/foliary/src/commonMain/composeResources/values-be/strings.xml index 69ca4a4e..5ba04334 100644 --- a/foliary/src/commonMain/composeResources/values-be/strings.xml +++ b/foliary/src/commonMain/composeResources/values-be/strings.xml @@ -28,6 +28,6 @@ Выйсці Профіль Foliary - Адкрыць Foliary - Выхад + Адкрыць Foliary + Выхад diff --git a/foliary/src/commonMain/composeResources/values-bg/strings.xml b/foliary/src/commonMain/composeResources/values-bg/strings.xml index 584cff44..1b42efdb 100644 --- a/foliary/src/commonMain/composeResources/values-bg/strings.xml +++ b/foliary/src/commonMain/composeResources/values-bg/strings.xml @@ -28,6 +28,6 @@ Излизане Профил Foliary - Отвори Foliary - Изход + Отвори Foliary + Изход diff --git a/foliary/src/commonMain/composeResources/values-bn/strings.xml b/foliary/src/commonMain/composeResources/values-bn/strings.xml index 51937a42..5c469077 100644 --- a/foliary/src/commonMain/composeResources/values-bn/strings.xml +++ b/foliary/src/commonMain/composeResources/values-bn/strings.xml @@ -28,6 +28,6 @@ লগ আউট করুন প্রোফাইল ফোলিয়ারি - ফোলিয়ারি খুলুন - প্রস্থান করুন + ফোলিয়ারি খুলুন + প্রস্থান করুন diff --git a/foliary/src/commonMain/composeResources/values-ca/strings.xml b/foliary/src/commonMain/composeResources/values-ca/strings.xml index dd23c750..a6e92534 100644 --- a/foliary/src/commonMain/composeResources/values-ca/strings.xml +++ b/foliary/src/commonMain/composeResources/values-ca/strings.xml @@ -28,6 +28,6 @@ Tancar la sessió Perfil Foliary - Obrir Foliary - Sortir + Obrir Foliary + Sortir diff --git a/foliary/src/commonMain/composeResources/values-cs/strings.xml b/foliary/src/commonMain/composeResources/values-cs/strings.xml index b2dc3941..2b61a226 100644 --- a/foliary/src/commonMain/composeResources/values-cs/strings.xml +++ b/foliary/src/commonMain/composeResources/values-cs/strings.xml @@ -28,6 +28,6 @@ Odhlásit se Profil Foliary - Otevřít Foliary - Ukončit + Otevřít Foliary + Ukončit diff --git a/foliary/src/commonMain/composeResources/values-cy/strings.xml b/foliary/src/commonMain/composeResources/values-cy/strings.xml index a37a2ee2..cd5b3252 100644 --- a/foliary/src/commonMain/composeResources/values-cy/strings.xml +++ b/foliary/src/commonMain/composeResources/values-cy/strings.xml @@ -28,6 +28,6 @@ Allgofnodi Proffil Foliary - Agor Foliary - Gadael + Agor Foliary + Gadael diff --git a/foliary/src/commonMain/composeResources/values-da/strings.xml b/foliary/src/commonMain/composeResources/values-da/strings.xml index cd5e8792..809e2974 100644 --- a/foliary/src/commonMain/composeResources/values-da/strings.xml +++ b/foliary/src/commonMain/composeResources/values-da/strings.xml @@ -28,6 +28,6 @@ Log ud Profil Foliary - Åbn Foliary - Afslut + Åbn Foliary + Afslut diff --git a/foliary/src/commonMain/composeResources/values-de/strings.xml b/foliary/src/commonMain/composeResources/values-de/strings.xml index b4bcd51d..4a710ce2 100644 --- a/foliary/src/commonMain/composeResources/values-de/strings.xml +++ b/foliary/src/commonMain/composeResources/values-de/strings.xml @@ -28,6 +28,6 @@ Abmelden Profil Foliary - Foliary öffnen - Beenden + Foliary öffnen + Beenden diff --git a/foliary/src/commonMain/composeResources/values-el/strings.xml b/foliary/src/commonMain/composeResources/values-el/strings.xml index efd9042e..87cc1866 100644 --- a/foliary/src/commonMain/composeResources/values-el/strings.xml +++ b/foliary/src/commonMain/composeResources/values-el/strings.xml @@ -28,6 +28,6 @@ Αποσύνδεση Προφίλ Foliary - Άνοιγμα του Foliary - Έξοδος + Άνοιγμα του Foliary + Έξοδος diff --git a/foliary/src/commonMain/composeResources/values-en/strings.xml b/foliary/src/commonMain/composeResources/values-en/strings.xml index fdee028f..32566263 100644 --- a/foliary/src/commonMain/composeResources/values-en/strings.xml +++ b/foliary/src/commonMain/composeResources/values-en/strings.xml @@ -50,8 +50,8 @@ Overdue - - Open Foliary - Quit + + Open Foliary + Quit diff --git a/foliary/src/commonMain/composeResources/values-es/strings.xml b/foliary/src/commonMain/composeResources/values-es/strings.xml index 6a915c8d..75c1c633 100644 --- a/foliary/src/commonMain/composeResources/values-es/strings.xml +++ b/foliary/src/commonMain/composeResources/values-es/strings.xml @@ -28,6 +28,6 @@ Cerrar sesión Perfil Foliary - Abrir Foliary - Salir + Abrir Foliary + Salir diff --git a/foliary/src/commonMain/composeResources/values-et/strings.xml b/foliary/src/commonMain/composeResources/values-et/strings.xml index 820491e7..303c1d6a 100644 --- a/foliary/src/commonMain/composeResources/values-et/strings.xml +++ b/foliary/src/commonMain/composeResources/values-et/strings.xml @@ -28,6 +28,6 @@ Logi välja Profiil Foliary - Ava Foliary - Välju + Ava Foliary + Välju diff --git a/foliary/src/commonMain/composeResources/values-eu/strings.xml b/foliary/src/commonMain/composeResources/values-eu/strings.xml index 92129476..2dea3fb3 100644 --- a/foliary/src/commonMain/composeResources/values-eu/strings.xml +++ b/foliary/src/commonMain/composeResources/values-eu/strings.xml @@ -28,6 +28,6 @@ Saioa itxi Profila Foliary - Ireki Foliary - Irten + Ireki Foliary + Irten diff --git a/foliary/src/commonMain/composeResources/values-fa/strings.xml b/foliary/src/commonMain/composeResources/values-fa/strings.xml index 68869a36..1caec81f 100644 --- a/foliary/src/commonMain/composeResources/values-fa/strings.xml +++ b/foliary/src/commonMain/composeResources/values-fa/strings.xml @@ -28,6 +28,6 @@ خروج از حساب پروفایل فولیاری - باز کردن فولیاری - خروج + باز کردن فولیاری + خروج diff --git a/foliary/src/commonMain/composeResources/values-fi/strings.xml b/foliary/src/commonMain/composeResources/values-fi/strings.xml index 092f168f..51d6686a 100644 --- a/foliary/src/commonMain/composeResources/values-fi/strings.xml +++ b/foliary/src/commonMain/composeResources/values-fi/strings.xml @@ -28,6 +28,6 @@ Kirjaudu ulos Profiili Foliary - Avaa Foliary - Lopeta + Avaa Foliary + Lopeta diff --git a/foliary/src/commonMain/composeResources/values-fil/strings.xml b/foliary/src/commonMain/composeResources/values-fil/strings.xml index be61600e..fc20d5d3 100644 --- a/foliary/src/commonMain/composeResources/values-fil/strings.xml +++ b/foliary/src/commonMain/composeResources/values-fil/strings.xml @@ -28,6 +28,6 @@ Mag-log out Profile Foliary - Buksan ang Foliary - Lumabas + Buksan ang Foliary + Lumabas diff --git a/foliary/src/commonMain/composeResources/values-fr/strings.xml b/foliary/src/commonMain/composeResources/values-fr/strings.xml index 90530cc7..093904f3 100644 --- a/foliary/src/commonMain/composeResources/values-fr/strings.xml +++ b/foliary/src/commonMain/composeResources/values-fr/strings.xml @@ -28,6 +28,6 @@ Se déconnecter Profil Foliary - Ouvrir Foliary - Quitter + Ouvrir Foliary + Quitter diff --git a/foliary/src/commonMain/composeResources/values-ga/strings.xml b/foliary/src/commonMain/composeResources/values-ga/strings.xml index 8fb2a5fe..761501f5 100644 --- a/foliary/src/commonMain/composeResources/values-ga/strings.xml +++ b/foliary/src/commonMain/composeResources/values-ga/strings.xml @@ -28,6 +28,6 @@ Logáil amach Próifíl Foliary - Oscail Foliary - Scoir + Oscail Foliary + Scoir diff --git a/foliary/src/commonMain/composeResources/values-gl/strings.xml b/foliary/src/commonMain/composeResources/values-gl/strings.xml index 4c842a2f..9406cf5f 100644 --- a/foliary/src/commonMain/composeResources/values-gl/strings.xml +++ b/foliary/src/commonMain/composeResources/values-gl/strings.xml @@ -28,6 +28,6 @@ Pechar sesión Perfil Foliary - Abrir Foliary - Saír + Abrir Foliary + Saír diff --git a/foliary/src/commonMain/composeResources/values-he/strings.xml b/foliary/src/commonMain/composeResources/values-he/strings.xml index 8541c2dc..37ed2e9a 100644 --- a/foliary/src/commonMain/composeResources/values-he/strings.xml +++ b/foliary/src/commonMain/composeResources/values-he/strings.xml @@ -28,6 +28,6 @@ התנתקות פרופיל Foliary - פתיחת Foliary - יציאה + פתיחת Foliary + יציאה diff --git a/foliary/src/commonMain/composeResources/values-hi/strings.xml b/foliary/src/commonMain/composeResources/values-hi/strings.xml index 3eca2813..250b313e 100644 --- a/foliary/src/commonMain/composeResources/values-hi/strings.xml +++ b/foliary/src/commonMain/composeResources/values-hi/strings.xml @@ -28,6 +28,6 @@ लॉग आउट करें प्रोफ़ाइल फोलियरी - फोलियरी खोलें - बाहर निकलें + फोलियरी खोलें + बाहर निकलें diff --git a/foliary/src/commonMain/composeResources/values-hr/strings.xml b/foliary/src/commonMain/composeResources/values-hr/strings.xml index 3f4228c3..c9d4344b 100644 --- a/foliary/src/commonMain/composeResources/values-hr/strings.xml +++ b/foliary/src/commonMain/composeResources/values-hr/strings.xml @@ -28,6 +28,6 @@ Odjava Profil Foliary - Otvori Foliary - Izađi + Otvori Foliary + Izađi diff --git a/foliary/src/commonMain/composeResources/values-hu/strings.xml b/foliary/src/commonMain/composeResources/values-hu/strings.xml index a50ac37d..06414628 100644 --- a/foliary/src/commonMain/composeResources/values-hu/strings.xml +++ b/foliary/src/commonMain/composeResources/values-hu/strings.xml @@ -28,6 +28,6 @@ Kijelentkezés Profil Foliary - Foliary megnyitása - Kilépés + Foliary megnyitása + Kilépés diff --git a/foliary/src/commonMain/composeResources/values-id/strings.xml b/foliary/src/commonMain/composeResources/values-id/strings.xml index d8d7c14f..3e783408 100644 --- a/foliary/src/commonMain/composeResources/values-id/strings.xml +++ b/foliary/src/commonMain/composeResources/values-id/strings.xml @@ -28,6 +28,6 @@ Keluar Profil Foliary - Buka Foliary - Keluar + Buka Foliary + Keluar diff --git a/foliary/src/commonMain/composeResources/values-it/strings.xml b/foliary/src/commonMain/composeResources/values-it/strings.xml index cc8528d3..1c8e4d2f 100644 --- a/foliary/src/commonMain/composeResources/values-it/strings.xml +++ b/foliary/src/commonMain/composeResources/values-it/strings.xml @@ -28,6 +28,6 @@ Disconnetti Profilo Foliary - Apri Foliary - Esci + Apri Foliary + Esci diff --git a/foliary/src/commonMain/composeResources/values-ja/strings.xml b/foliary/src/commonMain/composeResources/values-ja/strings.xml index 06bae8d1..29518207 100644 --- a/foliary/src/commonMain/composeResources/values-ja/strings.xml +++ b/foliary/src/commonMain/composeResources/values-ja/strings.xml @@ -28,6 +28,6 @@ ログアウト プロフィール Foliary - Foliaryを開く - 終了 + Foliaryを開く + 終了 diff --git a/foliary/src/commonMain/composeResources/values-kk/strings.xml b/foliary/src/commonMain/composeResources/values-kk/strings.xml index def3cf9a..477ad49c 100644 --- a/foliary/src/commonMain/composeResources/values-kk/strings.xml +++ b/foliary/src/commonMain/composeResources/values-kk/strings.xml @@ -28,6 +28,6 @@ Жүйеден шығу Профиль Foliary - Foliary-ді ашу - Шығу + Foliary-ді ашу + Шығу diff --git a/foliary/src/commonMain/composeResources/values-km/strings.xml b/foliary/src/commonMain/composeResources/values-km/strings.xml index 9f5b592f..686b8069 100644 --- a/foliary/src/commonMain/composeResources/values-km/strings.xml +++ b/foliary/src/commonMain/composeResources/values-km/strings.xml @@ -28,6 +28,6 @@ ចាកចេញ ប្រវត្តិរូប Foliary - បើក Foliary - ចាកចេញ + បើក Foliary + ចាកចេញ diff --git a/foliary/src/commonMain/composeResources/values-ko/strings.xml b/foliary/src/commonMain/composeResources/values-ko/strings.xml index 129880ba..b141951e 100644 --- a/foliary/src/commonMain/composeResources/values-ko/strings.xml +++ b/foliary/src/commonMain/composeResources/values-ko/strings.xml @@ -28,6 +28,6 @@ 로그아웃 프로필 Foliary - Foliary 열기 - 종료 + Foliary 열기 + 종료 diff --git a/foliary/src/commonMain/composeResources/values-lt/strings.xml b/foliary/src/commonMain/composeResources/values-lt/strings.xml index b01f60a4..b1e8f8f7 100644 --- a/foliary/src/commonMain/composeResources/values-lt/strings.xml +++ b/foliary/src/commonMain/composeResources/values-lt/strings.xml @@ -28,6 +28,6 @@ Atsijungti Profilis Foliary - Atidaryti „Foliary“ - Išeiti + Atidaryti „Foliary“ + Išeiti diff --git a/foliary/src/commonMain/composeResources/values-lv/strings.xml b/foliary/src/commonMain/composeResources/values-lv/strings.xml index b0ff8dba..851dbeb4 100644 --- a/foliary/src/commonMain/composeResources/values-lv/strings.xml +++ b/foliary/src/commonMain/composeResources/values-lv/strings.xml @@ -28,6 +28,6 @@ Izrakstīties Profils Foliary - Atvērt Foliary - Iziet + Atvērt Foliary + Iziet diff --git a/foliary/src/commonMain/composeResources/values-ms/strings.xml b/foliary/src/commonMain/composeResources/values-ms/strings.xml index 671feb5f..dca79ef0 100644 --- a/foliary/src/commonMain/composeResources/values-ms/strings.xml +++ b/foliary/src/commonMain/composeResources/values-ms/strings.xml @@ -28,6 +28,6 @@ Log keluar Profil Foliary - Buka Foliary - Keluar + Buka Foliary + Keluar diff --git a/foliary/src/commonMain/composeResources/values-mt/strings.xml b/foliary/src/commonMain/composeResources/values-mt/strings.xml index 0153f461..f7683db6 100644 --- a/foliary/src/commonMain/composeResources/values-mt/strings.xml +++ b/foliary/src/commonMain/composeResources/values-mt/strings.xml @@ -28,6 +28,6 @@ Oħroġ mill-kont Profil Foliary - Iftaħ Foliary - Oħroġ + Iftaħ Foliary + Oħroġ diff --git a/foliary/src/commonMain/composeResources/values-nap/strings.xml b/foliary/src/commonMain/composeResources/values-nap/strings.xml index 4b3abb88..3f079919 100644 --- a/foliary/src/commonMain/composeResources/values-nap/strings.xml +++ b/foliary/src/commonMain/composeResources/values-nap/strings.xml @@ -28,6 +28,6 @@ Scollegat\'te Prufilo Foliary - Apre Foliary - Jesce + Apre Foliary + Jesce diff --git a/foliary/src/commonMain/composeResources/values-nl/strings.xml b/foliary/src/commonMain/composeResources/values-nl/strings.xml index 58f12ffd..60321c9f 100644 --- a/foliary/src/commonMain/composeResources/values-nl/strings.xml +++ b/foliary/src/commonMain/composeResources/values-nl/strings.xml @@ -28,6 +28,6 @@ Uitloggen Profiel Foliary - Foliary openen - Afsluiten + Foliary openen + Afsluiten diff --git a/foliary/src/commonMain/composeResources/values-no/strings.xml b/foliary/src/commonMain/composeResources/values-no/strings.xml index 0c5b2051..c71ec28a 100644 --- a/foliary/src/commonMain/composeResources/values-no/strings.xml +++ b/foliary/src/commonMain/composeResources/values-no/strings.xml @@ -28,6 +28,6 @@ Logg ut Profil Foliary - Åpne Foliary - Avslutt + Åpne Foliary + Avslutt diff --git a/foliary/src/commonMain/composeResources/values-pa/strings.xml b/foliary/src/commonMain/composeResources/values-pa/strings.xml index 58b4edbd..9a30c697 100644 --- a/foliary/src/commonMain/composeResources/values-pa/strings.xml +++ b/foliary/src/commonMain/composeResources/values-pa/strings.xml @@ -28,6 +28,6 @@ ਲੌਗ ਆਊਟ ਪ੍ਰੋਫ਼ਾਈਲ ਫੋਲੀਅਰੀ - ਫੋਲੀਅਰੀ ਖੋਲ੍ਹੋ - ਬੰਦ ਕਰੋ + ਫੋਲੀਅਰੀ ਖੋਲ੍ਹੋ + ਬੰਦ ਕਰੋ diff --git a/foliary/src/commonMain/composeResources/values-pl/strings.xml b/foliary/src/commonMain/composeResources/values-pl/strings.xml index 6d7d8868..90ed09d0 100644 --- a/foliary/src/commonMain/composeResources/values-pl/strings.xml +++ b/foliary/src/commonMain/composeResources/values-pl/strings.xml @@ -28,6 +28,6 @@ Wyloguj się Profil Foliary - Otwórz Foliary - Zakończ + Otwórz Foliary + Zakończ diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml index 5b077d9b..7f6e097d 100644 --- a/foliary/src/commonMain/composeResources/values/strings.xml +++ b/foliary/src/commonMain/composeResources/values/strings.xml @@ -1,6 +1,8 @@ Foliary + Confirm + Cancel @@ -62,17 +64,15 @@ Due date Add due date Clear due date - Confirm - Cancel Overdue - - Open Foliary - Quit + + Open Foliary + Quit diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt index dbac1f7d..72fbd354 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt @@ -32,8 +32,8 @@ import com.composables.icons.lucide.X import dev.appoutlet.foliary.core.ui.component.card.FoliaryCard import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.create_task_due_date_clear_a11y -import foliary.foliary.generated.resources.date_picker_cancel -import foliary.foliary.generated.resources.date_picker_confirm +import foliary.foliary.generated.resources.general_cancel +import foliary.foliary.generated.resources.general_confirm import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone @@ -170,7 +170,7 @@ private fun DatePickerBottomSheet( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) ) { TextButton(onClick = onDismiss) { - Text(text = stringResource(Res.string.date_picker_cancel)) + Text(text = stringResource(Res.string.general_cancel)) } TextButton( @@ -180,7 +180,7 @@ private fun DatePickerBottomSheet( }, enabled = datePickerState.selectedDateMillis != null ) { - Text(text = stringResource(Res.string.date_picker_confirm)) + Text(text = stringResource(Res.string.general_confirm)) } } } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt index 36d7b377..eabd4941 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.test.v2.runComposeUiTest import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.create_task_due_date_clear_a11y import foliary.foliary.generated.resources.create_task_due_date_placeholder -import foliary.foliary.generated.resources.date_picker_confirm +import foliary.foliary.generated.resources.general_confirm import io.kotest.matchers.shouldBe import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -101,7 +101,7 @@ class FoliaryDatePickerTest { @Test fun `should open date picker bottom sheet when card is clicked`() = runComposeUiTest { val placeholder = getString(Res.string.create_task_due_date_placeholder) - val confirmText = getString(Res.string.date_picker_confirm) + val confirmText = getString(Res.string.general_confirm) setContent { FoliaryDatePicker( diff --git a/i18n.lock b/i18n.lock index d0060787..e9b9da60 100644 --- a/i18n.lock +++ b/i18n.lock @@ -31,5 +31,5 @@ checksums: profile_title: d7878693f91303a438852d617f6d35df today_title: f3258b28a1e37c2f4b5878b903761453 task_item_overdue: 24caaa2b5d7a2447ab7664e3771cf98c - open_app: 98d48f4e2cbf39c43abbc6dd32eb4ff9 - quit: e4546c0aa9f1fa3e788cadec5e62f8ef + system_tray_open_app: 98d48f4e2cbf39c43abbc6dd32eb4ff9 + system_tray_quit: e4546c0aa9f1fa3e788cadec5e62f8ef From 05aa18841ed0339bef6732dddc1f5cf06a505b98 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 17:30:40 +0100 Subject: [PATCH 04/10] date picker --- .../component/datepicker/FoliaryDatePicker.kt | 139 ++++++++---------- 1 file changed, 61 insertions(+), 78 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt index 72fbd354..ab2f146f 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt @@ -1,5 +1,6 @@ package dev.appoutlet.foliary.core.ui.component.datepicker +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -8,7 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerState +import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -17,6 +18,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,50 +26,35 @@ 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.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.X +import dev.appoutlet.foliary.core.ui.component.button.FoliarySecondaryButton import dev.appoutlet.foliary.core.ui.component.card.FoliaryCard import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.create_task_due_date_clear_a11y import foliary.foliary.generated.resources.general_cancel import foliary.foliary.generated.resources.general_confirm -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atTime -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource -import kotlin.time.Instant - -private const val EndOfDayHour = 23 -private const val EndOfDayMinute = 59 -private const val EndOfDaySecond = 59 -private const val EndOfDayNanosecond = 999_999_999 -private const val PlaceholderAlpha = 0.6f @OptIn(ExperimentalMaterial3Api::class) @Composable fun FoliaryDatePicker( label: String, placeholder: String, - selectedDate: Instant?, - onDateSelected: (Instant?) -> Unit, + selectedDate: Long?, + selectedDateDisplayText: String?, + onDateSelected: (Long?) -> Unit, modifier: Modifier = Modifier, ) { var showBottomSheet by rememberSaveable { mutableStateOf(false) } - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = selectedDate?.toPickerMillis() - ) Box(modifier = modifier) { DatePickerCard( label = label, placeholder = placeholder, - selectedDate = selectedDate, + selectedDateDisplayText = selectedDateDisplayText, onClick = { showBottomSheet = true }, onClear = { onDateSelected(null) } ) @@ -75,9 +62,9 @@ fun FoliaryDatePicker( if (showBottomSheet) { DatePickerBottomSheet( - datePickerState = datePickerState, + selectedDateMillis = selectedDate, onDismiss = { showBottomSheet = false }, - onConfirm = { onDateSelected(it.toEndOfDayInstant()) } + onConfirm = { onDateSelected(it) } ) } } @@ -86,52 +73,54 @@ fun FoliaryDatePicker( private fun DatePickerCard( label: String, placeholder: String, - selectedDate: Instant?, + selectedDateDisplayText: String?, onClick: () -> Unit, onClear: () -> Unit, ) { - FoliaryCard( - modifier = Modifier - .fillMaxWidth() - .testTag("FoliaryDatePicker:Card") - .clickable(onClick = onClick) - ) { - Column( - modifier = Modifier.padding(all = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + val dateTextColor by animateColorAsState( + targetValue = if (selectedDateDisplayText != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onBackground + } + ) + + FoliaryCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag("FoliaryDatePicker:Card") + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground - ) + Column( + modifier = Modifier.padding(16.dp).weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground + ) - Box(modifier = Modifier.fillMaxWidth()) { Text( - text = selectedDate?.let { formatDate(it) } ?: placeholder, - modifier = Modifier - .align(Alignment.CenterStart) - .testTag("FoliaryDatePicker:Value"), + modifier = Modifier.testTag("FoliaryDatePicker:Value"), + text = selectedDateDisplayText ?: placeholder, style = MaterialTheme.typography.bodyMedium, - color = datePickerValueColor(selectedDate) + color = dateTextColor ) + } + - if (selectedDate != null) { - DateClearButton( - modifier = Modifier.align(Alignment.CenterEnd), - onClick = onClear - ) - } + if (selectedDateDisplayText != null) { + DateClearButton( + modifier = Modifier.padding(end = 16.dp), + onClick = onClear, + ) } } } -} -@Composable -private fun datePickerValueColor(selectedDate: Instant?): Color = if (selectedDate != null) { - MaterialTheme.colorScheme.onSurface -} else { - MaterialTheme.colorScheme.onBackground.copy(alpha = PlaceholderAlpha) } @Composable @@ -140,8 +129,8 @@ private fun DateClearButton( modifier: Modifier = Modifier, ) { IconButton( + modifier = modifier.testTag("FoliaryDatePicker:ClearButton"), onClick = onClick, - modifier = modifier.testTag("FoliaryDatePicker:ClearButton") ) { Icon( imageVector = Lucide.X, @@ -154,14 +143,25 @@ private fun DateClearButton( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DatePickerBottomSheet( - datePickerState: DatePickerState, + selectedDateMillis: Long?, onDismiss: () -> Unit, onConfirm: (Long) -> Unit, ) { + // TODO add min date.. + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedDateMillis) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( - onDismissRequest = onDismiss + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background ) { - DatePicker(state = datePickerState) + DatePicker( + state = datePickerState, + colors = DatePickerDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ) + ) Row( modifier = Modifier @@ -173,7 +173,7 @@ private fun DatePickerBottomSheet( Text(text = stringResource(Res.string.general_cancel)) } - TextButton( + FoliarySecondaryButton( onClick = { datePickerState.selectedDateMillis?.let(onConfirm) onDismiss() @@ -186,20 +186,3 @@ private fun DatePickerBottomSheet( } } -private fun formatDate(instant: Instant): String = instant.toLocalDate().toString() - -private fun Instant.toLocalDate(): LocalDate = toLocalDateTime(TimeZone.currentSystemDefault()).date - -private fun Instant.toPickerMillis(): Long = toLocalDate() - .atTime(LocalTime(0, 0)) - .toInstant(TimeZone.currentSystemDefault()) - .toEpochMilliseconds() - -private fun Long.toEndOfDayInstant(): Instant { - val localDate = Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()).date - return localDate.toEndOfDayInstant() -} - -private fun LocalDate.toEndOfDayInstant(): Instant = - atTime(LocalTime(EndOfDayHour, EndOfDayMinute, EndOfDaySecond, EndOfDayNanosecond)) - .toInstant(TimeZone.currentSystemDefault()) From 130128c9ae67ee9d1d60be7b14a76daf8a663283 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 18:03:18 +0100 Subject: [PATCH 05/10] min and max date --- .../component/datepicker/FoliaryDatePicker.kt | 14 ++++- .../datepicker/MinMaxSelectableDates.kt | 13 +++++ .../datepicker/FoliaryDatePickerTest.kt | 43 ++++++++++++---- .../datepicker/MinMaxSelectableDatesTest.kt | 51 +++++++++++++++++++ 4 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDates.kt create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDatesTest.kt diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt index ab2f146f..52aeadee 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt @@ -37,6 +37,7 @@ import foliary.foliary.generated.resources.create_task_due_date_clear_a11y import foliary.foliary.generated.resources.general_cancel import foliary.foliary.generated.resources.general_confirm import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -47,6 +48,8 @@ fun FoliaryDatePicker( selectedDateDisplayText: String?, onDateSelected: (Long?) -> Unit, modifier: Modifier = Modifier, + minDateMillis: Long = Instant.DISTANT_PAST.toEpochMilliseconds(), + maxDateMillis: Long= Instant.DISTANT_FUTURE.toEpochMilliseconds(), ) { var showBottomSheet by rememberSaveable { mutableStateOf(false) } @@ -63,6 +66,8 @@ fun FoliaryDatePicker( if (showBottomSheet) { DatePickerBottomSheet( selectedDateMillis = selectedDate, + minDateMillis = minDateMillis, + maxDateMillis = maxDateMillis, onDismiss = { showBottomSheet = false }, onConfirm = { onDateSelected(it) } ) @@ -144,11 +149,16 @@ private fun DateClearButton( @Composable private fun DatePickerBottomSheet( selectedDateMillis: Long?, + minDateMillis: Long, + maxDateMillis: Long, onDismiss: () -> Unit, onConfirm: (Long) -> Unit, ) { - // TODO add min date.. - val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedDateMillis) + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = selectedDateMillis, + selectableDates = MinMaxSelectableDates(minDateMillis, maxDateMillis) + ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDates.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDates.kt new file mode 100644 index 00000000..2fdaccaa --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDates.kt @@ -0,0 +1,13 @@ +package dev.appoutlet.foliary.core.ui.component.datepicker + +import androidx.compose.material3.SelectableDates +import kotlin.time.Instant + +class MinMaxSelectableDates( + private val minDateMillis: Long = Instant.DISTANT_PAST.toEpochMilliseconds(), + private val maxDateMillis: Long = Instant.DISTANT_FUTURE.toEpochMilliseconds(), +) : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return utcTimeMillis in minDateMillis..maxDateMillis + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt index eabd4941..b5300447 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt @@ -12,11 +12,8 @@ import foliary.foliary.generated.resources.create_task_due_date_clear_a11y import foliary.foliary.generated.resources.create_task_due_date_placeholder import foliary.foliary.generated.resources.general_confirm import io.kotest.matchers.shouldBe -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import kotlin.test.Test -import kotlin.time.Instant @OptIn(ExperimentalTestApi::class) class FoliaryDatePickerTest { @@ -30,6 +27,7 @@ class FoliaryDatePickerTest { label = "Due date", placeholder = placeholder, selectedDate = null, + selectedDateDisplayText = null, onDateSelected = {} ) } @@ -42,19 +40,19 @@ class FoliaryDatePickerTest { @Test fun `should show selected date when selectedDate is not null`() = runComposeUiTest { val placeholder = getString(Res.string.create_task_due_date_placeholder) - val selectedDate = Instant.parse("2026-07-21T12:00:00Z") - val expectedDateText = selectedDate.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + val selectedDateDisplayText = "2026-07-21" setContent { FoliaryDatePicker( label = "Due date", placeholder = placeholder, - selectedDate = selectedDate, + selectedDate = 1_752_960_000_000, + selectedDateDisplayText = selectedDateDisplayText, onDateSelected = {} ) } - onNodeWithText(expectedDateText).assertIsDisplayed() + onNodeWithText(selectedDateDisplayText).assertIsDisplayed() onNodeWithTag("FoliaryDatePicker:Value", useUnmergedTree = true).assertIsDisplayed() } @@ -68,6 +66,7 @@ class FoliaryDatePickerTest { label = "Due date", placeholder = placeholder, selectedDate = null, + selectedDateDisplayText = null, onDateSelected = {} ) } @@ -79,14 +78,14 @@ class FoliaryDatePickerTest { fun `clear button should call onDateSelected with null`() = runComposeUiTest { val placeholder = getString(Res.string.create_task_due_date_placeholder) val clearDescription = getString(Res.string.create_task_due_date_clear_a11y) - val selectedDate = Instant.parse("2026-07-21T12:00:00Z") var cleared = false setContent { FoliaryDatePicker( label = "Due date", placeholder = placeholder, - selectedDate = selectedDate, + selectedDate = 1_752_960_000_000, + selectedDateDisplayText = "2026-07-21", onDateSelected = { cleared = it == null } ) } @@ -108,6 +107,7 @@ class FoliaryDatePickerTest { label = "Due date", placeholder = placeholder, selectedDate = null, + selectedDateDisplayText = null, onDateSelected = {} ) } @@ -118,4 +118,29 @@ class FoliaryDatePickerTest { onNodeWithText(confirmText).assertExists() } + + @Test + fun `should not open date picker bottom sheet when clear button is clicked`() = runComposeUiTest { + val placeholder = getString(Res.string.create_task_due_date_placeholder) + val clearDescription = getString(Res.string.create_task_due_date_clear_a11y) + val confirmText = getString(Res.string.general_confirm) + + setContent { + FoliaryDatePicker( + label = "Due date", + placeholder = placeholder, + selectedDate = 1_752_960_000_000, + selectedDateDisplayText = "2026-07-21", + onDateSelected = {} + ) + } + + onNodeWithContentDescription(clearDescription) + .assertIsDisplayed() + .performClick() + + waitForIdle() + + onNodeWithText(confirmText).assertDoesNotExist() + } } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDatesTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDatesTest.kt new file mode 100644 index 00000000..d0c1f97a --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDatesTest.kt @@ -0,0 +1,51 @@ +package dev.appoutlet.foliary.core.ui.component.datepicker + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.time.Instant + +class MinMaxSelectableDatesTest { + + @Test + fun `should allow any date when using default bounds`() = runTest { + val subject = MinMaxSelectableDates() + + subject.isSelectableDate(Instant.parse("2026-07-21T00:00:00Z").toEpochMilliseconds()) shouldBe true + } + + @Test + fun `should allow date equal to min bound`() = runTest { + val subject = MinMaxSelectableDates(minDateMillis = 100) + + subject.isSelectableDate(100) shouldBe true + } + + @Test + fun `should allow date equal to max bound`() = runTest { + val subject = MinMaxSelectableDates(maxDateMillis = 100) + + subject.isSelectableDate(100) shouldBe true + } + + @Test + fun `should allow date between min and max bounds`() = runTest { + val subject = MinMaxSelectableDates(minDateMillis = 100, maxDateMillis = 200) + + subject.isSelectableDate(150) shouldBe true + } + + @Test + fun `should reject date below min bound`() = runTest { + val subject = MinMaxSelectableDates(minDateMillis = 100) + + subject.isSelectableDate(99) shouldBe false + } + + @Test + fun `should reject date above max bound`() = runTest { + val subject = MinMaxSelectableDates(maxDateMillis = 100) + + subject.isSelectableDate(101) shouldBe false + } +} From 1ecbd84efab92b6527622916593e00362dd94308 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 18:11:27 +0100 Subject: [PATCH 06/10] add field on CreateTaskScreen.kt --- .../feature/createtask/CreateTaskScreen.kt | 5 +- .../createtask/CreateTaskScreenTest.kt | 56 +++++++++---------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt index c4810663..98fd13cc 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt @@ -45,6 +45,7 @@ import foliary.foliary.generated.resources.create_task_title_placeholder import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import org.jetbrains.compose.resources.stringResource +import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @Composable @@ -73,8 +74,10 @@ fun CreateTaskScreen(viewData: CreateTaskViewData, onEvent: (CreateTaskEvent) -> .testTag("CreateTaskScreen:DueDatePicker"), label = stringResource(Res.string.create_task_due_date_label), placeholder = stringResource(Res.string.create_task_due_date_placeholder), - selectedDate = viewData.dueDate, + selectedDate = viewData.dueDate.selectedDateMillis, + selectedDateDisplayText = viewData.dueDate.selectedDateDisplayText, onDateSelected = { onEvent(CreateTaskEvent.DueDateChanged(it)) }, + maxDateMillis = Clock.System.now().toEpochMilliseconds(), ) Spacer(Modifier.size(64.dp)) } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt index 05be7f3b..a26e66c3 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt @@ -11,6 +11,9 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.back_icon_button_a11y import foliary.foliary.generated.resources.create_task_description_label @@ -42,6 +45,10 @@ class CreateTaskScreenTest { onNodeWithText(getString(Res.string.create_task_title_placeholder)).assertIsDisplayed() onNodeWithText(getString(Res.string.create_task_description_label)).assertIsDisplayed() onNodeWithText(getString(Res.string.create_task_description_placeholder)).assertIsDisplayed() + onNodeWithText(getString(Res.string.create_task_due_date_label)).assertIsDisplayed() + onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed() + onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) + .assertDoesNotExist() onNodeWithText(getString(Res.string.create_task_save)).assertIsDisplayed().assertIsNotEnabled() } @@ -89,51 +96,42 @@ class CreateTaskScreenTest { } @Test - fun `should show due date placeholder when no date is selected`() = runComposeUiTest { + fun `should show selected due date and clear it`() = runComposeUiTest { + val dueDate = Instant.parse("2026-07-21T12:00:00Z") + val dueDateMillis = dueDate.toEpochMilliseconds() + val expectedDateText = dueDate.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + val events = mutableListOf() + var viewData by mutableStateOf(CreateTaskViewData()) + setContent { - CreateTaskScreen(viewData = CreateTaskViewData(), onEvent = {}) + CreateTaskScreen(viewData = viewData, onEvent = events::add) } onNodeWithText(getString(Res.string.create_task_due_date_label)).assertIsDisplayed() onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed() onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) .assertDoesNotExist() - } - - @Test - fun `should show selected due date and clear button`() = runComposeUiTest { - val dueDate = Instant.parse("2026-07-21T12:00:00Z") - val expectedDateText = dueDate.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() - setContent { - CreateTaskScreen( - viewData = CreateTaskViewData(dueDate = dueDate), - onEvent = {} + viewData = CreateTaskViewData( + dueDate = DueDateViewData( + selectedDateMillis = dueDateMillis, + selectedDateDisplayText = expectedDateText, ) - } + ) + waitForIdle() - onNodeWithText(getString(Res.string.create_task_due_date_label)).assertIsDisplayed() onNodeWithText(expectedDateText).assertIsDisplayed() onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) .assertIsDisplayed() - } + .performClick() - @Test - fun `should emit DueDateChanged null when clear button is clicked`() = runComposeUiTest { - val dueDate = Instant.parse("2026-07-21T12:00:00Z") - val events = mutableListOf() + events.contains(CreateTaskEvent.DueDateChanged(null)) shouldBe true - setContent { - CreateTaskScreen( - viewData = CreateTaskViewData(dueDate = dueDate), - onEvent = events::add, - ) - } + viewData = CreateTaskViewData() + waitForIdle() + onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed() onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) - .assertIsDisplayed() - .performClick() - - events.contains(CreateTaskEvent.DueDateChanged(null)) shouldBe true + .assertDoesNotExist() } } From 349065e62249d8ed5979632fadd0b252d777351f Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 18:48:52 +0100 Subject: [PATCH 07/10] format date --- .../core/provider/time/TimeProvider.kt | 24 ++++++++++ .../feature/createtask/CreateTaskViewModel.kt | 32 ++++++++++--- .../data/time/DefaultTimeProviderTest.kt | 16 +++++++ .../createtask/CreateTaskScreenTest.kt | 2 +- .../createtask/CreateTaskViewModelTest.kt | 46 +++++++++++++++---- 5 files changed, 102 insertions(+), 18 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt index e8023215..41eb496b 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt @@ -1,8 +1,15 @@ package dev.appoutlet.foliary.core.provider.time +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atTime +import kotlinx.datetime.format +import kotlinx.datetime.format.DateTimeComponents +import kotlinx.datetime.format.DateTimeFormat +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.char +import kotlinx.datetime.offsetAt import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import org.koin.core.annotation.Single @@ -15,10 +22,20 @@ private val LastNanosecondOfTheDay = LocalTime.fromNanosecondOfDay((1.days.inWho interface TimeProvider { fun now(): Instant fun endOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant + fun displayText(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()) : String } @Single class DefaultTimeProvider(private val clock: Clock = Clock.System) : TimeProvider { + private val dateFormat = LocalDate.Format { + day() + chars(" ") + monthName(MonthNames.ENGLISH_ABBREVIATED) + chars(" ") + year() + } + + override fun now(): Instant = clock.now() override fun endOfToday(timeZone: TimeZone): Instant { @@ -26,4 +43,11 @@ class DefaultTimeProvider(private val clock: Clock = Clock.System) : TimeProvide .atTime(LastNanosecondOfTheDay) .toInstant(timeZone) } + + override fun displayText( + instant: Instant, + timeZone: TimeZone + ): String { + return instant.toLocalDateTime(timeZone).date.format(dateFormat) + } } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt index 5ff5c526..b2d8f58c 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt @@ -6,6 +6,8 @@ import dev.appoutlet.foliary.core.provider.time.TimeProvider import dev.appoutlet.foliary.core.provider.uuid.UuidProvider import dev.appoutlet.foliary.data.task.TaskRepository import dev.appoutlet.foliary.data.task.database.entity.Task +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import org.koin.core.annotation.KoinViewModel import kotlin.time.Instant import kotlin.uuid.Uuid @@ -22,14 +24,23 @@ class CreateTaskViewModel( when (event) { is CreateTaskEvent.TitleChanged -> onTitleChange(event.title) is CreateTaskEvent.DescriptionChanged -> onDescriptionChange(event.description) - is CreateTaskEvent.DueDateChanged -> onDueDateChange(event.dueDate) + is CreateTaskEvent.DueDateChanged -> onDueDateChange(event.dueDateMillis) CreateTaskEvent.SaveClicked -> onSaveClick() CreateTaskEvent.BackClicked -> onBackClick() } } - private fun onDueDateChange(dueDate: Instant?) = intent { - reduce { state.copy(dueDate = dueDate) } + private fun onDueDateChange(dueDateMillis: Long?) = intent { + reduce { + state.copy( + dueDate = CreateTaskViewData.DueDateViewData( + selectedDateMillis = dueDateMillis, + selectedDateDisplayText = dueDateMillis?.toInstant()?.let { + timeProvider.displayText(it) + }, + ) + ) + } } private fun onTitleChange(title: String) = intent { @@ -53,7 +64,7 @@ class CreateTaskViewModel( title = state.title.trim(), description = state.description?.trim(), creationDate = timeProvider.now(), - dueDate = state.dueDate, + dueDate = state.dueDate.selectedDateMillis?.toInstant(), completionDate = null, priority = null, url = null, @@ -68,20 +79,27 @@ class CreateTaskViewModel( private fun onBackClick() = intent { postSideEffect(CreateTaskAction.NavigateBack) } + + private fun Long.toInstant() = Instant.fromEpochMilliseconds(this) } data class CreateTaskViewData( val id: String? = null, val title: String = "", val description: String? = null, - val dueDate: Instant? = null, + val dueDate: DueDateViewData = DueDateViewData(), val saveButtonEnabled: Boolean = false, -) +) { + data class DueDateViewData( + val selectedDateMillis: Long? = null, + val selectedDateDisplayText: String? = null, + ) +} sealed interface CreateTaskEvent { data class TitleChanged(val title: String) : CreateTaskEvent data class DescriptionChanged(val description: String?) : CreateTaskEvent - data class DueDateChanged(val dueDate: Instant?) : CreateTaskEvent + data class DueDateChanged(val dueDateMillis: Long?) : CreateTaskEvent data object SaveClicked : CreateTaskEvent data object BackClicked : CreateTaskEvent } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt index ac3dc940..23046d06 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt @@ -24,6 +24,22 @@ class DefaultTimeProviderTest { subject.endOfToday(timezone) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z") } + @Test + fun `should format instant to display text for the configured time zone`() { + val timezone = TimeZone.UTC + val instant = Instant.parse("2026-07-22T02:10:02.999Z") + val subject = DefaultTimeProvider() + subject.displayText(instant, timezone) shouldBe "22 Jul 2026" + } + + @Test + fun `should format instant to display text respecting the time zone`() { + val instant = Instant.parse("2026-07-22T02:10:02.999Z") + val subject = DefaultTimeProvider() + subject.displayText(instant, TimeZone.UTC) shouldBe "22 Jul 2026" + subject.displayText(instant, TimeZone.of("UTC-10")) shouldBe "21 Jul 2026" + } + private class FixedClock(private val instant: Instant) : Clock { override fun now(): Instant = instant } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt index a26e66c3..e7137e1b 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt @@ -113,7 +113,7 @@ class CreateTaskScreenTest { .assertDoesNotExist() viewData = CreateTaskViewData( - dueDate = DueDateViewData( + dueDate = CreateTaskViewData.DueDateViewData( selectedDateMillis = dueDateMillis, selectedDateDisplayText = expectedDateText, ) diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt index 338a0888..bba52d8e 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt @@ -14,9 +14,12 @@ import dev.mokkery.matcher.capture.capture import dev.mokkery.matcher.capture.get import dev.mokkery.mock import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import kotlin.test.Test import kotlin.time.Instant import kotlin.uuid.Uuid +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime class CreateTaskViewModelTest : ViewModelTest() { @@ -67,28 +70,35 @@ class CreateTaskViewModelTest : @Test fun `default state should have dueDate unset`() = test { - currentState.dueDate shouldBe null + currentState.dueDate shouldBe CreateTaskViewData.DueDateViewData() } @Test fun `DueDateChanged - should update dueDate`() = test { - val dueDate = Instant.parse("2026-07-21T12:00:00Z") + val dueDateMillis = 1_752_996_000_000 - viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDate)) + viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis)) - awaitState().dueDate shouldBe dueDate + awaitState().dueDate.also { + it.selectedDateMillis shouldBe dueDateMillis + it.selectedDateDisplayText shouldNotBe null + } } @Test fun `DueDateChanged - null should clear dueDate`() = test { - val dueDate = Instant.parse("2026-07-21T12:00:00Z") + val dueDateMillis = 1_752_996_000_000 + val expectedDueDate = CreateTaskViewData.DueDateViewData( + selectedDateMillis = dueDateMillis, + selectedDateDisplayText = dueDateMillis.toDisplayText(), + ) - viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDate)) - expectState { copy(dueDate = dueDate) } + viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis)) + expectState { copy(dueDate = expectedDueDate) } viewModel.onEvent(CreateTaskEvent.DueDateChanged(null)) - awaitState().dueDate shouldBe null + awaitState().dueDate shouldBe CreateTaskViewData.DueDateViewData() } @Test @@ -133,6 +143,7 @@ class CreateTaskViewModelTest : val id = Uuid.random() val title = "Task title" val dueDate = Instant.parse("2026-07-21T12:00:00Z") + val dueDateMillis = dueDate.toEpochMilliseconds() val taskCapture = Capture.slot() val creationDate = Instant.parse("2026-07-21T12:00:00Z") @@ -143,8 +154,17 @@ class CreateTaskViewModelTest : viewModel.onEvent(CreateTaskEvent.TitleChanged(title)) expectState { copy(title = title, saveButtonEnabled = true) } - viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDate)) - expectState { copy(title = title, dueDate = dueDate, saveButtonEnabled = true) } + viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis)) + expectState { + copy( + title = title, + dueDate = CreateTaskViewData.DueDateViewData( + selectedDateMillis = dueDateMillis, + selectedDateDisplayText = dueDateMillis.toDisplayText(), + ), + saveButtonEnabled = true, + ) + } viewModel.onEvent(CreateTaskEvent.SaveClicked) awaitState().saveButtonEnabled shouldBe false @@ -154,3 +174,9 @@ class CreateTaskViewModelTest : awaitSideEffect() shouldBe CreateTaskAction.NavigateBack } } + +private fun Long.toDisplayText(): String = + Instant.fromEpochMilliseconds(this) + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + .toString() From 599c7b7fb273a42b91a6d4f3e14e4185caf24918 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 19:04:36 +0100 Subject: [PATCH 08/10] min due date --- .../core/provider/time/TimeProvider.kt | 8 +++++ .../feature/createtask/CreateTaskScreen.kt | 7 ++--- .../feature/createtask/CreateTaskViewModel.kt | 29 ++++++++++--------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt index 41eb496b..ba0d4b0d 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt @@ -17,10 +17,12 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Instant +private val StartOfDay = LocalTime(0, 0) private val LastNanosecondOfTheDay = LocalTime.fromNanosecondOfDay((1.days.inWholeNanoseconds - 1)) interface TimeProvider { fun now(): Instant + fun startOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant fun endOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant fun displayText(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()) : String } @@ -38,6 +40,12 @@ class DefaultTimeProvider(private val clock: Clock = Clock.System) : TimeProvide override fun now(): Instant = clock.now() + override fun startOfToday(timeZone: TimeZone): Instant { + return now().toLocalDateTime(timeZone).date + .atTime(StartOfDay) + .toInstant(timeZone) + } + override fun endOfToday(timeZone: TimeZone): Instant { return now().toLocalDateTime(timeZone).date .atTime(LastNanosecondOfTheDay) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt index 98fd13cc..aacf10c0 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt @@ -45,7 +45,6 @@ import foliary.foliary.generated.resources.create_task_title_placeholder import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import org.jetbrains.compose.resources.stringResource -import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @Composable @@ -74,10 +73,10 @@ fun CreateTaskScreen(viewData: CreateTaskViewData, onEvent: (CreateTaskEvent) -> .testTag("CreateTaskScreen:DueDatePicker"), label = stringResource(Res.string.create_task_due_date_label), placeholder = stringResource(Res.string.create_task_due_date_placeholder), - selectedDate = viewData.dueDate.selectedDateMillis, - selectedDateDisplayText = viewData.dueDate.selectedDateDisplayText, + selectedDate = viewData.dueDate?.selectedDateMillis, + selectedDateDisplayText = viewData.dueDate?.selectedDateDisplayText, onDateSelected = { onEvent(CreateTaskEvent.DueDateChanged(it)) }, - maxDateMillis = Clock.System.now().toEpochMilliseconds(), + minDateMillis = viewData.minDueDateMillis, ) Spacer(Modifier.size(64.dp)) } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt index b2d8f58c..01d22027 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt @@ -6,8 +6,6 @@ import dev.appoutlet.foliary.core.provider.time.TimeProvider import dev.appoutlet.foliary.core.provider.uuid.UuidProvider import dev.appoutlet.foliary.data.task.TaskRepository import dev.appoutlet.foliary.data.task.database.entity.Task -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import org.koin.core.annotation.KoinViewModel import kotlin.time.Instant import kotlin.uuid.Uuid @@ -18,7 +16,11 @@ class CreateTaskViewModel( private val timeProvider: TimeProvider, private val uuidProvider: UuidProvider, ) : MviViewModel() { - override val container = container(CreateTaskViewData()) + override val container = container( + CreateTaskViewData( + minDueDateMillis = timeProvider.startOfToday().toEpochMilliseconds(), + ) + ) fun onEvent(event: CreateTaskEvent) { when (event) { @@ -33,12 +35,12 @@ class CreateTaskViewModel( private fun onDueDateChange(dueDateMillis: Long?) = intent { reduce { state.copy( - dueDate = CreateTaskViewData.DueDateViewData( - selectedDateMillis = dueDateMillis, - selectedDateDisplayText = dueDateMillis?.toInstant()?.let { - timeProvider.displayText(it) - }, - ) + dueDate = dueDateMillis?.let { + CreateTaskViewData.DueDateViewData( + selectedDateMillis = it, + selectedDateDisplayText = timeProvider.displayText(it.toInstant()), + ) + } ) } } @@ -64,7 +66,7 @@ class CreateTaskViewModel( title = state.title.trim(), description = state.description?.trim(), creationDate = timeProvider.now(), - dueDate = state.dueDate.selectedDateMillis?.toInstant(), + dueDate = state.dueDate?.selectedDateMillis?.toInstant(), completionDate = null, priority = null, url = null, @@ -87,12 +89,13 @@ data class CreateTaskViewData( val id: String? = null, val title: String = "", val description: String? = null, - val dueDate: DueDateViewData = DueDateViewData(), + val dueDate: DueDateViewData? = null, + val minDueDateMillis: Long, val saveButtonEnabled: Boolean = false, ) { data class DueDateViewData( - val selectedDateMillis: Long? = null, - val selectedDateDisplayText: String? = null, + val selectedDateMillis: Long, + val selectedDateDisplayText: String, ) } From f9106bde3c90b4eea21f1c731e01c45381b55df0 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 19:07:20 +0100 Subject: [PATCH 09/10] min due date tests --- .../feature/createtask/CreateTaskViewModel.kt | 6 ++- .../data/time/DefaultTimeProviderTest.kt | 8 +++ .../createtask/CreateTaskScreenTest.kt | 42 ++++++++------- .../createtask/CreateTaskViewDataFixture.kt | 25 +++++++++ .../createtask/CreateTaskViewModelTest.kt | 54 ++++++++++--------- 5 files changed, 92 insertions(+), 43 deletions(-) create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt index 01d22027..0380d709 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt @@ -96,7 +96,11 @@ data class CreateTaskViewData( data class DueDateViewData( val selectedDateMillis: Long, val selectedDateDisplayText: String, - ) + ) { + companion object + } + + companion object } sealed interface CreateTaskEvent { diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt index 23046d06..466c84c4 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt @@ -16,6 +16,14 @@ class DefaultTimeProviderTest { subject.now().toEpochMilliseconds() shouldBeAtLeast now.toEpochMilliseconds() } + @Test + fun `should return the start of today for the configured time zone`() { + val timezone = TimeZone.UTC + val today = Instant.parse("2026-07-22T02:10:02.999Z") + val subject = DefaultTimeProvider(clock = FixedClock(today)) + subject.startOfToday(timezone) shouldBe Instant.parse("2026-07-22T00:00:00Z") + } + @Test fun `should return the end of today for the configured time zone`() { val timezone = TimeZone.UTC diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt index e7137e1b..e504280b 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt @@ -26,18 +26,23 @@ import foliary.foliary.generated.resources.create_task_title import foliary.foliary.generated.resources.create_task_title_label import foliary.foliary.generated.resources.create_task_title_placeholder import io.kotest.matchers.shouldBe -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import kotlin.test.Test -import kotlin.time.Instant @OptIn(ExperimentalTestApi::class) class CreateTaskScreenTest { @Test fun `should render create task form`() = runComposeUiTest { setContent { - CreateTaskScreen(viewData = CreateTaskViewData(), onEvent = {}) + CreateTaskScreen( + viewData = CreateTaskViewData.fixture( + title = "", + description = null, + dueDate = null, + saveButtonEnabled = false, + ), + onEvent = {} + ) } onNodeWithText(getString(Res.string.create_task_title)).assertIsDisplayed() @@ -58,7 +63,7 @@ class CreateTaskScreenTest { setContent { CreateTaskScreen( - viewData = CreateTaskViewData(saveButtonEnabled = true), + viewData = CreateTaskViewData.fixture(), onEvent = events::add, ) } @@ -81,7 +86,15 @@ class CreateTaskScreenTest { val description = "Use the garden store nearby" setContent { - CreateTaskScreen(viewData = CreateTaskViewData(), onEvent = {}) + CreateTaskScreen( + viewData = CreateTaskViewData.fixture( + title = "", + description = null, + dueDate = null, + saveButtonEnabled = false, + ), + onEvent = {} + ) } onNodeWithTag("CreateTaskScreen:TitleField", useUnmergedTree = true) @@ -97,11 +110,9 @@ class CreateTaskScreenTest { @Test fun `should show selected due date and clear it`() = runComposeUiTest { - val dueDate = Instant.parse("2026-07-21T12:00:00Z") - val dueDateMillis = dueDate.toEpochMilliseconds() - val expectedDateText = dueDate.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + val dueDate = CreateTaskViewData.DueDateViewData.fixture() val events = mutableListOf() - var viewData by mutableStateOf(CreateTaskViewData()) + var viewData by mutableStateOf(CreateTaskViewData.fixture(dueDate = null)) setContent { CreateTaskScreen(viewData = viewData, onEvent = events::add) @@ -112,22 +123,17 @@ class CreateTaskScreenTest { onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) .assertDoesNotExist() - viewData = CreateTaskViewData( - dueDate = CreateTaskViewData.DueDateViewData( - selectedDateMillis = dueDateMillis, - selectedDateDisplayText = expectedDateText, - ) - ) + viewData = CreateTaskViewData.fixture(dueDate = dueDate) waitForIdle() - onNodeWithText(expectedDateText).assertIsDisplayed() + onNodeWithText(dueDate.selectedDateDisplayText).assertIsDisplayed() onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) .assertIsDisplayed() .performClick() events.contains(CreateTaskEvent.DueDateChanged(null)) shouldBe true - viewData = CreateTaskViewData() + viewData = CreateTaskViewData.fixture(dueDate = null) waitForIdle() onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed() diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt new file mode 100644 index 00000000..0a9b3da0 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt @@ -0,0 +1,25 @@ +package dev.appoutlet.foliary.feature.createtask + +fun CreateTaskViewData.Companion.fixture( + id: String? = null, + title: String = "Task fixture", + description: String? = "Task description", + dueDate: CreateTaskViewData.DueDateViewData? = CreateTaskViewData.DueDateViewData.fixture(), + minDueDateMillis: Long = 1_752_960_000_000, + saveButtonEnabled: Boolean = true, +) = CreateTaskViewData( + id = id, + title = title, + description = description, + dueDate = dueDate, + minDueDateMillis = minDueDateMillis, + saveButtonEnabled = saveButtonEnabled, +) + +fun CreateTaskViewData.DueDateViewData.Companion.fixture( + selectedDateMillis: Long = 1_752_960_000_000, + selectedDateDisplayText: String = "21 Jul 2026", +) = CreateTaskViewData.DueDateViewData( + selectedDateMillis = selectedDateMillis, + selectedDateDisplayText = selectedDateDisplayText, +) diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt index bba52d8e..28002933 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt @@ -12,26 +12,31 @@ import dev.mokkery.everySuspend import dev.mokkery.matcher.capture.Capture import dev.mokkery.matcher.capture.capture import dev.mokkery.matcher.capture.get +import dev.mokkery.matcher.any import dev.mokkery.mock import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import kotlin.test.Test import kotlin.time.Instant import kotlin.uuid.Uuid -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime class CreateTaskViewModelTest : ViewModelTest() { private val mockTaskRepository = mock(mode = MockMode.autoUnit) private val mockTimeProvider = mock() private val mockUuidProvider = mock() - - override fun createViewModel() = CreateTaskViewModel( - taskRepository = mockTaskRepository, - timeProvider = mockTimeProvider, - uuidProvider = mockUuidProvider - ) + private val startOfToday = Instant.parse("2026-07-22T00:00:00Z") + private val expectedDisplayText = "22 Jul 2026" + + override fun createViewModel(): CreateTaskViewModel { + every(mockTimeProvider::startOfToday) returns startOfToday + every { mockTimeProvider.displayText(any()) } returns expectedDisplayText + return CreateTaskViewModel( + taskRepository = mockTaskRepository, + timeProvider = mockTimeProvider, + uuidProvider = mockUuidProvider + ) + } @Test fun `TitleChanged - blank title button disabled`() = test { @@ -69,8 +74,9 @@ class CreateTaskViewModelTest : } @Test - fun `default state should have dueDate unset`() = test { - currentState.dueDate shouldBe CreateTaskViewData.DueDateViewData() + fun `default state should have dueDate unset and minDueDate set to start of today`() = test { + currentState.dueDate shouldBe null + currentState.minDueDateMillis shouldBe startOfToday.toEpochMilliseconds() } @Test @@ -80,17 +86,17 @@ class CreateTaskViewModelTest : viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis)) awaitState().dueDate.also { - it.selectedDateMillis shouldBe dueDateMillis - it.selectedDateDisplayText shouldNotBe null + it?.selectedDateMillis shouldBe dueDateMillis + it?.selectedDateDisplayText shouldNotBe null } } @Test fun `DueDateChanged - null should clear dueDate`() = test { val dueDateMillis = 1_752_996_000_000 - val expectedDueDate = CreateTaskViewData.DueDateViewData( + val expectedDueDate = CreateTaskViewData.DueDateViewData.fixture( selectedDateMillis = dueDateMillis, - selectedDateDisplayText = dueDateMillis.toDisplayText(), + selectedDateDisplayText = expectedDisplayText, ) viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis)) @@ -98,7 +104,7 @@ class CreateTaskViewModelTest : viewModel.onEvent(CreateTaskEvent.DueDateChanged(null)) - awaitState().dueDate shouldBe CreateTaskViewData.DueDateViewData() + awaitState().dueDate shouldBe null } @Test @@ -113,7 +119,13 @@ class CreateTaskViewModelTest : every(mockTimeProvider::now) returns creationDate everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit - currentState shouldBe CreateTaskViewData() + currentState shouldBe CreateTaskViewData.fixture( + title = "", + description = null, + dueDate = null, + minDueDateMillis = startOfToday.toEpochMilliseconds(), + saveButtonEnabled = false, + ) viewModel.onEvent(CreateTaskEvent.TitleChanged(title)) expectState { copy(title = title, saveButtonEnabled = true) } @@ -158,9 +170,9 @@ class CreateTaskViewModelTest : expectState { copy( title = title, - dueDate = CreateTaskViewData.DueDateViewData( + dueDate = CreateTaskViewData.DueDateViewData.fixture( selectedDateMillis = dueDateMillis, - selectedDateDisplayText = dueDateMillis.toDisplayText(), + selectedDateDisplayText = expectedDisplayText, ), saveButtonEnabled = true, ) @@ -174,9 +186,3 @@ class CreateTaskViewModelTest : awaitSideEffect() shouldBe CreateTaskAction.NavigateBack } } - -private fun Long.toDisplayText(): String = - Instant.fromEpochMilliseconds(this) - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - .toString() From c184f768a901c2627eab7edf8a24f1784ed65b9c Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Mon, 27 Jul 2026 19:13:38 +0100 Subject: [PATCH 10/10] fix date --- .../foliary/core/provider/time/TimeProvider.kt | 17 +++++++++-------- .../component/datepicker/FoliaryDatePicker.kt | 5 +---- .../feature/createtask/CreateTaskViewModel.kt | 6 ++++-- .../data/time/DefaultTimeProviderTest.kt | 16 ++++++++++++++++ .../feature/createtask/CreateTaskScreenTest.kt | 6 +++--- .../createtask/CreateTaskViewModelTest.kt | 8 +++++--- 6 files changed, 38 insertions(+), 20 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt index ba0d4b0d..8e3ddf0e 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt @@ -5,11 +5,7 @@ import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atTime import kotlinx.datetime.format -import kotlinx.datetime.format.DateTimeComponents -import kotlinx.datetime.format.DateTimeFormat import kotlinx.datetime.format.MonthNames -import kotlinx.datetime.format.char -import kotlinx.datetime.offsetAt import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import org.koin.core.annotation.Single @@ -24,7 +20,8 @@ interface TimeProvider { fun now(): Instant fun startOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant fun endOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant - fun displayText(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()) : String + fun endOfDay(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant + fun displayText(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()): String } @Single @@ -37,7 +34,6 @@ class DefaultTimeProvider(private val clock: Clock = Clock.System) : TimeProvide year() } - override fun now(): Instant = clock.now() override fun startOfToday(timeZone: TimeZone): Instant { @@ -46,12 +42,17 @@ class DefaultTimeProvider(private val clock: Clock = Clock.System) : TimeProvide .toInstant(timeZone) } - override fun endOfToday(timeZone: TimeZone): Instant { - return now().toLocalDateTime(timeZone).date + override fun endOfDay( + instant: Instant, + timeZone: TimeZone + ): Instant { + return instant.toLocalDateTime(timeZone).date .atTime(LastNanosecondOfTheDay) .toInstant(timeZone) } + override fun endOfToday(timeZone: TimeZone) = endOfDay(now(), timeZone) + override fun displayText( instant: Instant, timeZone: TimeZone diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt index 52aeadee..3bc002a5 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt @@ -49,7 +49,7 @@ fun FoliaryDatePicker( onDateSelected: (Long?) -> Unit, modifier: Modifier = Modifier, minDateMillis: Long = Instant.DISTANT_PAST.toEpochMilliseconds(), - maxDateMillis: Long= Instant.DISTANT_FUTURE.toEpochMilliseconds(), + maxDateMillis: Long = Instant.DISTANT_FUTURE.toEpochMilliseconds(), ) { var showBottomSheet by rememberSaveable { mutableStateOf(false) } @@ -116,7 +116,6 @@ private fun DatePickerCard( ) } - if (selectedDateDisplayText != null) { DateClearButton( modifier = Modifier.padding(end = 16.dp), @@ -125,7 +124,6 @@ private fun DatePickerCard( } } } - } @Composable @@ -195,4 +193,3 @@ private fun DatePickerBottomSheet( } } } - diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt index 0380d709..8ae905cd 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt @@ -66,7 +66,9 @@ class CreateTaskViewModel( title = state.title.trim(), description = state.description?.trim(), creationDate = timeProvider.now(), - dueDate = state.dueDate?.selectedDateMillis?.toInstant(), + dueDate = state.dueDate?.selectedDateMillis?.toInstant()?.let { + timeProvider.endOfDay(it) + }, completionDate = null, priority = null, url = null, @@ -82,7 +84,7 @@ class CreateTaskViewModel( postSideEffect(CreateTaskAction.NavigateBack) } - private fun Long.toInstant() = Instant.fromEpochMilliseconds(this) + private fun Long.toInstant() = Instant.fromEpochMilliseconds(this) } data class CreateTaskViewData( diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt index 466c84c4..280b6487 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt @@ -32,6 +32,22 @@ class DefaultTimeProviderTest { subject.endOfToday(timezone) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z") } + @Test + fun `should return the end of day for the configured time zone`() { + val timezone = TimeZone.UTC + val instant = Instant.parse("2026-07-22T02:10:02.999Z") + val subject = DefaultTimeProvider() + subject.endOfDay(instant, timezone) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z") + } + + @Test + fun `should return the end of day respecting the time zone`() { + val instant = Instant.parse("2026-07-22T02:10:02.999Z") + val subject = DefaultTimeProvider() + subject.endOfDay(instant, TimeZone.UTC) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z") + subject.endOfDay(instant, TimeZone.of("UTC-10")) shouldBe Instant.parse("2026-07-22T09:59:59.999999999Z") + } + @Test fun `should format instant to display text for the configured time zone`() { val timezone = TimeZone.UTC diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt index e504280b..55d06508 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt @@ -1,5 +1,8 @@ package dev.appoutlet.foliary.feature.createtask +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled @@ -11,9 +14,6 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runComposeUiTest -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.back_icon_button_a11y import foliary.foliary.generated.resources.create_task_description_label diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt index 28002933..c7a646a0 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt @@ -9,10 +9,10 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.everySuspend +import dev.mokkery.matcher.any import dev.mokkery.matcher.capture.Capture import dev.mokkery.matcher.capture.capture import dev.mokkery.matcher.capture.get -import dev.mokkery.matcher.any import dev.mokkery.mock import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -151,16 +151,18 @@ class CreateTaskViewModelTest : } @Test - fun `SaveClicked - should persist selected dueDate`() = test { + fun `SaveClicked - should persist selected dueDate as end of day`() = test { val id = Uuid.random() val title = "Task title" val dueDate = Instant.parse("2026-07-21T12:00:00Z") val dueDateMillis = dueDate.toEpochMilliseconds() + val endOfDayDueDate = Instant.parse("2026-07-21T23:59:59.999999999Z") val taskCapture = Capture.slot() val creationDate = Instant.parse("2026-07-21T12:00:00Z") every(mockUuidProvider::random) returns id every(mockTimeProvider::now) returns creationDate + every { mockTimeProvider.endOfDay(dueDate) } returns endOfDayDueDate everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit viewModel.onEvent(CreateTaskEvent.TitleChanged(title)) @@ -181,7 +183,7 @@ class CreateTaskViewModelTest : viewModel.onEvent(CreateTaskEvent.SaveClicked) awaitState().saveButtonEnabled shouldBe false - taskCapture.get().dueDate shouldBe dueDate + taskCapture.get().dueDate shouldBe endOfDayDueDate awaitSideEffect() shouldBe CreateTaskAction.NavigateBack }