diff --git a/foliary/src/androidHostTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.android.kt b/foliary/src/androidHostTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.android.kt new file mode 100644 index 00000000..83db01de --- /dev/null +++ b/foliary/src/androidHostTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.android.kt @@ -0,0 +1,6 @@ +package dev.appoutlet.foliary.data.authentication + +import eu.anifantakis.lib.ksafe.KSafe +import kotlin.io.path.createTempDirectory + +actual fun platformKSafe(): KSafe = error("") diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml index 2480cf4d..3bbfc0bf 100644 --- a/foliary/src/commonMain/composeResources/values/strings.xml +++ b/foliary/src/commonMain/composeResources/values/strings.xml @@ -71,6 +71,7 @@ Add description... Save Title is required + Priority Due date Add due date Clear due date diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/pill/PriorityPill.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/pill/PriorityPill.kt index 09bb04c6..6045c009 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/pill/PriorityPill.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/pill/PriorityPill.kt @@ -2,48 +2,24 @@ package dev.appoutlet.foliary.core.ui.component.pill import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.composables.icons.lucide.Flag -import com.composables.icons.lucide.Lucide +import dev.appoutlet.foliary.core.ui.priority.viewData import dev.appoutlet.foliary.core.ui.theme.FoliaryTheme import dev.appoutlet.foliary.data.task.database.entity.Priority -import foliary.foliary.generated.resources.Res -import foliary.foliary.generated.resources.priority_pill_blocker -import foliary.foliary.generated.resources.priority_pill_high -import foliary.foliary.generated.resources.priority_pill_highest -import foliary.foliary.generated.resources.priority_pill_low -import foliary.foliary.generated.resources.priority_pill_lowest -import foliary.foliary.generated.resources.priority_pill_medium -import org.jetbrains.compose.resources.stringResource - -private val HighContentColor = Color(0xFFC4A319) -private val HighestContentColor = Color(0xFFAD5203) @Composable fun PriorityPill(priority: Priority, modifier: Modifier = Modifier) { - val defaultContentColor = MaterialTheme.colorScheme.onSurface - val blockerContentColor = MaterialTheme.colorScheme.error - - val (text, contentColor) = when (priority) { - Priority.LOWEST -> Res.string.priority_pill_lowest to defaultContentColor - Priority.LOW -> Res.string.priority_pill_low to defaultContentColor - Priority.MEDIUM -> Res.string.priority_pill_medium to defaultContentColor - Priority.HIGH -> Res.string.priority_pill_high to HighContentColor - Priority.HIGHEST -> Res.string.priority_pill_highest to HighestContentColor - Priority.BLOCKER -> Res.string.priority_pill_blocker to blockerContentColor - } + val priorityViewData = priority.viewData() FoliaryPill( modifier = modifier.testTag("PriorityPill"), - icon = Lucide.Flag, - text = stringResource(text), - contentColor = contentColor + icon = priorityViewData.icon, + text = priorityViewData.text, + contentColor = priorityViewData.color ) } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/priority/PriorityViewData.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/priority/PriorityViewData.kt new file mode 100644 index 00000000..fe931b51 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/priority/PriorityViewData.kt @@ -0,0 +1,95 @@ +package dev.appoutlet.foliary.core.ui.priority + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import com.composables.icons.lucide.ChevronDown +import com.composables.icons.lucide.ChevronUp +import com.composables.icons.lucide.ChevronsDown +import com.composables.icons.lucide.ChevronsUp +import com.composables.icons.lucide.ChevronsUpDown +import com.composables.icons.lucide.CircleMinus +import com.composables.icons.lucide.Lucide +import dev.appoutlet.foliary.core.ui.theme.LocalIsDarkTheme +import dev.appoutlet.foliary.data.task.database.entity.Priority +import foliary.foliary.generated.resources.Res +import foliary.foliary.generated.resources.priority_pill_blocker +import foliary.foliary.generated.resources.priority_pill_high +import foliary.foliary.generated.resources.priority_pill_highest +import foliary.foliary.generated.resources.priority_pill_low +import foliary.foliary.generated.resources.priority_pill_lowest +import foliary.foliary.generated.resources.priority_pill_medium +import org.jetbrains.compose.resources.stringResource + +data class PriorityViewData( + val text: String, + val color: Color, + val icon: ImageVector, +) + +val PriorityHighLight = Color(0xFFBD8100) +val PriorityHighestLight = Color(0xFFA65200) +val PriorityBlockerLight = Color(0xFF93000A) + +val PriorityHighDark = Color(0xFFFFAA5C) +val PriorityHighestDark = Color(0xFFFF8080) +val PriorityBlockerDark = Color(0xFFFF5449) + +private data class PriorityColors( + val high: Color, + val highest: Color, + val blocker: Color +) + +@Composable +fun Priority.viewData(): PriorityViewData { + val isDarkTheme = LocalIsDarkTheme.current + + val colors = remember(isDarkTheme) { + if (isDarkTheme) { + PriorityColors(PriorityHighDark, PriorityHighestDark, PriorityBlockerDark) + } else { + PriorityColors(PriorityHighLight, PriorityHighestLight, PriorityBlockerLight) + } + } + + return when (this) { + Priority.LOWEST -> PriorityViewData( + text = stringResource(Res.string.priority_pill_lowest), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .4f), + icon = Lucide.ChevronsDown, + ) + + Priority.LOW -> PriorityViewData( + text = stringResource(Res.string.priority_pill_low), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .7f), + icon = Lucide.ChevronDown, + ) + + Priority.MEDIUM -> PriorityViewData( + text = stringResource(Res.string.priority_pill_medium), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 1f), + icon = Lucide.ChevronsUpDown, + ) + + Priority.HIGH -> PriorityViewData( + text = stringResource(Res.string.priority_pill_high), + color = colors.high, + icon = Lucide.ChevronUp, + ) + + Priority.HIGHEST -> PriorityViewData( + text = stringResource(Res.string.priority_pill_highest), + color = colors.highest, + icon = Lucide.ChevronsUp, + ) + + Priority.BLOCKER -> PriorityViewData( + text = stringResource(Res.string.priority_pill_blocker), + color = colors.blocker, + icon = Lucide.CircleMinus, + ) + } +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Color.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Color.kt index 3aafdece..243438d6 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Color.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Color.kt @@ -1,4 +1,5 @@ package dev.appoutlet.foliary.core.ui.theme +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color val primaryLight = Color(0xFF1A4D3E) @@ -72,3 +73,5 @@ val surfaceContainerLowDark = Color(0xFF22261E) val surfaceContainerDark = Color(0xFF272B22) val surfaceContainerHighDark = Color(0xFF32372B) val surfaceContainerHighestDark = Color(0xFF3D4235) + +val LocalIsDarkTheme = compositionLocalOf { false } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Theme.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Theme.kt index 3b5de6d8..c8073e26 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Theme.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider private val lightScheme = lightColorScheme( primary = primaryLight, @@ -91,12 +92,14 @@ fun FoliaryTheme( val colorScheme = if (isDarkTheme) darkScheme else lightScheme - MaterialTheme( - colorScheme = colorScheme, - typography = getTypography(colorScheme), - content = content, - shapes = shapes - ) + CompositionLocalProvider(LocalIsDarkTheme provides isDarkTheme) { + MaterialTheme( + colorScheme = colorScheme, + typography = getTypography(colorScheme), + content = content, + shapes = shapes + ) + } } @Composable diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt index a8f8735b..80e54c8a 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt @@ -1,5 +1,5 @@ package dev.appoutlet.foliary.data.task.database.entity enum class Priority { - LOWEST, LOW, MEDIUM, HIGH, HIGHEST, BLOCKER + BLOCKER, HIGHEST, HIGH, MEDIUM, LOW, LOWEST } 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 74a6ba27..14d4ff1f 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 @@ -31,6 +31,7 @@ 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_priority_label 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 @@ -57,6 +58,15 @@ fun CreateTaskScreen(viewData: CreateTaskViewData, onEvent: (CreateTaskEvent) -> Spacer(Modifier.size(16.dp)) TitleField(viewData.title, onEvent) DescriptionField(viewData.description, onEvent) + FoliaryPriorityPicker( + modifier = Modifier.widthInCompact() + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag("CreateTaskScreen:PriorityPicker"), + label = stringResource(Res.string.create_task_priority_label), + selectedPriority = viewData.priority, + onPrioritySelected = { onEvent(CreateTaskEvent.PriorityChanged(it)) }, + ) FoliaryDatePicker( modifier = Modifier.widthInCompact() .fillMaxWidth() 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 335ecb7e..f6836f6a 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 @@ -38,6 +38,7 @@ class CreateTaskViewModel( id = task.id.toString(), title = task.title, description = task.description, + priority = task.priority, dueDate = task.dueDate?.let { dueDate -> CreateTaskViewData.DueDateViewData( selectedDateMillis = dueDate.toEpochMilliseconds(), @@ -54,12 +55,17 @@ class CreateTaskViewModel( when (event) { is CreateTaskEvent.TitleChanged -> onTitleChange(event.title) is CreateTaskEvent.DescriptionChanged -> onDescriptionChange(event.description) + is CreateTaskEvent.PriorityChanged -> onPriorityChange(event.priority) is CreateTaskEvent.DueDateChanged -> onDueDateChange(event.dueDateMillis) CreateTaskEvent.SaveClicked -> onSaveClick() CreateTaskEvent.BackClicked -> onBackClick() } } + private fun onPriorityChange(priority: Priority) = intent { + reduce { state.copy(priority = priority) } + } + private fun onDueDateChange(dueDateMillis: Long?) = intent { reduce { state.copy( @@ -100,7 +106,7 @@ class CreateTaskViewModel( timeProvider.endOfDay(it) }, completionDate = null, - priority = Priority.MEDIUM, + priority = state.priority, url = null, location = null, ) @@ -121,6 +127,7 @@ data class CreateTaskViewData( val id: String? = null, val title: String = "", val description: String? = null, + val priority: Priority = Priority.MEDIUM, val dueDate: DueDateViewData? = null, val minDueDateMillis: Long, val saveButtonEnabled: Boolean = false, @@ -138,6 +145,7 @@ data class CreateTaskViewData( sealed interface CreateTaskEvent { data class TitleChanged(val title: String) : CreateTaskEvent data class DescriptionChanged(val description: String?) : CreateTaskEvent + data class PriorityChanged(val priority: Priority) : CreateTaskEvent data class DueDateChanged(val dueDateMillis: Long?) : CreateTaskEvent data object SaveClicked : CreateTaskEvent data object BackClicked : CreateTaskEvent diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/FoliaryPriorityPicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/FoliaryPriorityPicker.kt new file mode 100644 index 00000000..6dd605ce --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/FoliaryPriorityPicker.kt @@ -0,0 +1,131 @@ +package dev.appoutlet.foliary.feature.createtask + +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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +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.Check +import com.composables.icons.lucide.ChevronDown +import com.composables.icons.lucide.Lucide +import dev.appoutlet.foliary.core.ui.component.card.FoliaryCard +import dev.appoutlet.foliary.core.ui.priority.viewData +import dev.appoutlet.foliary.data.task.database.entity.Priority + +@Composable +fun FoliaryPriorityPicker( + label: String, + selectedPriority: Priority, + onPrioritySelected: (Priority) -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by rememberSaveable { mutableStateOf(false) } + + Box(modifier = modifier) { + PriorityPickerCard( + label = label, + selectedPriority = selectedPriority, + onClick = { showMenu = true } + ) + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Priority.entries.forEach { priority -> + val viewData = priority.viewData() + DropdownMenuItem( + text = { Text(text = viewData.text) }, + leadingIcon = { Icon(imageVector = viewData.icon, contentDescription = null) }, + trailingIcon = { + if (priority == selectedPriority) { + Icon(imageVector = Lucide.Check, contentDescription = null) + } + }, + onClick = { + showMenu = false + onPrioritySelected(priority) + }, + colors = MenuDefaults.itemColors( + textColor = viewData.color, + leadingIconColor = viewData.color + ) + ) + } + } + } +} + +@Composable +private fun PriorityPickerCard( + label: String, + selectedPriority: Priority, + onClick: () -> Unit, +) { + val viewData = selectedPriority.viewData() + FoliaryCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag("FoliaryPriorityPicker:Card") + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .padding(16.dp) + .weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = viewData.icon, + contentDescription = null, + tint = viewData.color + ) + + Text( + modifier = Modifier.testTag("FoliaryPriorityPicker:Value"), + text = viewData.text, + color = viewData.color, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Lucide.ChevronDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt index cdb2f2be..7b2b2f50 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt @@ -17,7 +17,7 @@ import kotlin.test.Test class FoliaryPrimaryButtonTest { @Test - fun `should show content, be enabled by default and invoke onClick when clicked`() = runComposeUiTest { + fun `should show content be enabled by default and invoke onClick when clicked`() = runComposeUiTest { var clicked = false val testTag = "PrimaryButton" val buttonText = "Click Me" diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt index d629684e..dc07aab9 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt @@ -17,7 +17,7 @@ import kotlin.test.Test class FoliarySecondaryButtonTest { @Test - fun `should show content, be enabled by default and invoke onClick when clicked`() = runComposeUiTest { + fun `should show content be enabled by default and invoke onClick when clicked`() = runComposeUiTest { var clicked = false val testTag = "SecondaryButton" val buttonText = "Click Me" diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.kt index cebefc3b..04d89ffd 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.kt @@ -18,12 +18,11 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import kotlin.io.path.createTempDirectory import kotlin.test.Test class DefaultAuthenticationRepositoryTest { private val mockAuth = mock(mode = MockMode.autoUnit) - private val ksafe = KSafe(baseDir = createTempDirectory("ksafe-test").toFile()) + private val ksafe = platformKSafe() val subject = DefaultAuthenticationRepository( lazyAuth = lazy { mockAuth }, @@ -116,3 +115,5 @@ class DefaultAuthenticationRepositoryTest { subject.currentUser() shouldBe fixture } } + +expect fun platformKSafe(): KSafe 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 2a73f933..00b4927b 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 @@ -14,6 +14,7 @@ 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 dev.appoutlet.foliary.data.task.database.entity.Priority import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.back_icon_button_a11y import foliary.foliary.generated.resources.create_task_description_label @@ -25,6 +26,8 @@ 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 foliary.foliary.generated.resources.priority_pill_high +import foliary.foliary.generated.resources.priority_pill_medium import io.kotest.matchers.shouldBe import org.jetbrains.compose.resources.getString import kotlin.test.Test @@ -50,6 +53,8 @@ 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() + onNodeWithTag("CreateTaskScreen:PriorityPicker").assertIsDisplayed() + onNodeWithText(getString(Res.string.priority_pill_medium)).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)) @@ -149,4 +154,26 @@ class CreateTaskScreenTest { onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y)) .assertDoesNotExist() } + + @Test + fun `should change priority and emit event`() = runComposeUiTest { + val events = mutableListOf() + var viewData by mutableStateOf(CreateTaskViewData.fixture(priority = Priority.MEDIUM)) + + setContent { + CreateTaskScreen(viewData = viewData, onEvent = events::add) + } + + onNodeWithTag("CreateTaskScreen:PriorityPicker").performClick() + waitForIdle() + + onNodeWithText(getString(Res.string.priority_pill_high)).performClick() + + events.contains(CreateTaskEvent.PriorityChanged(Priority.HIGH)) shouldBe true + + viewData = viewData.copy(priority = Priority.HIGH) + waitForIdle() + + onNodeWithText(getString(Res.string.priority_pill_high)).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 index 0a9b3da0..7a767f28 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt @@ -1,9 +1,12 @@ package dev.appoutlet.foliary.feature.createtask +import dev.appoutlet.foliary.data.task.database.entity.Priority + fun CreateTaskViewData.Companion.fixture( id: String? = null, title: String = "Task fixture", description: String? = "Task description", + priority: Priority = Priority.MEDIUM, dueDate: CreateTaskViewData.DueDateViewData? = CreateTaskViewData.DueDateViewData.fixture(), minDueDateMillis: Long = 1_752_960_000_000, saveButtonEnabled: Boolean = true, @@ -11,6 +14,7 @@ fun CreateTaskViewData.Companion.fixture( id = id, title = title, description = description, + priority = priority, dueDate = dueDate, minDueDateMillis = minDueDateMillis, saveButtonEnabled = saveButtonEnabled, 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 d140f155..ebb3f56c 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 @@ -4,6 +4,7 @@ import dev.appoutlet.foliary.core.provider.time.TimeProvider import dev.appoutlet.foliary.core.provider.uuid.UuidProvider import dev.appoutlet.foliary.core.testing.ViewModelTest import dev.appoutlet.foliary.data.task.TaskRepository +import dev.appoutlet.foliary.data.task.database.entity.Priority import dev.appoutlet.foliary.data.task.database.entity.Task import dev.appoutlet.foliary.data.task.database.entity.fixture import dev.mokkery.MockMode @@ -16,7 +17,6 @@ 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 @@ -41,14 +41,11 @@ class CreateTaskViewModelTest : } @Test - fun `TitleChanged - blank title button disabled`() = test { + fun `TitleChanged - should update title and save button enabled state`() = test { + val title = "Task" + viewModel.onEvent(CreateTaskEvent.TitleChanged("")) currentState.saveButtonEnabled shouldBe false - } - - @Test - fun `TitleChanged - entering a valid title enables the save button`() = test { - val title = "Task" viewModel.onEvent(CreateTaskEvent.TitleChanged(title)) @@ -76,17 +73,34 @@ class CreateTaskViewModelTest : } @Test - fun `default state should have dueDate unset and minDueDate set to start of today`() = test { - currentState.dueDate shouldBe null - currentState.minDueDateMillis shouldBe startOfToday.toEpochMilliseconds() + fun `default state should have expected values`() = test { + currentState shouldBe CreateTaskViewData( + id = null, + title = "", + description = null, + priority = Priority.MEDIUM, + dueDate = null, + minDueDateMillis = startOfToday.toEpochMilliseconds(), + saveButtonEnabled = false, + ) + } + + @Test + fun `PriorityChanged - should update priority`() = test { + viewModel.onEvent(CreateTaskEvent.PriorityChanged(Priority.HIGH)) + expectState { copy(priority = Priority.HIGH) } } @Test fun `should load saved task on init`() { val taskId = Uuid.random() - val savedTask = Task.fixture(id = taskId) + val savedTask = Task.fixture(id = taskId, priority = Priority.HIGHEST) + val taskCapture = Capture.slot() + val endOfDayDueDate = Instant.parse("2026-08-21T23:59:59.999999999Z") everySuspend { mockTaskRepository.findById(taskId) } returns savedTask + every { mockTimeProvider.endOfDay(any()) } returns endOfDayDueDate + everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit viewModel = CreateTaskViewModel( taskId = taskId.toString(), @@ -101,6 +115,7 @@ class CreateTaskViewModelTest : id = taskId.toString(), title = savedTask.title, description = savedTask.description, + priority = savedTask.priority, dueDate = savedTask.dueDate?.let { dueDate -> CreateTaskViewData.DueDateViewData.fixture( selectedDateMillis = dueDate.toEpochMilliseconds(), @@ -111,23 +126,21 @@ class CreateTaskViewModelTest : saveButtonEnabled = true, ) } - } - } - @Test - fun `DueDateChanged - should update dueDate`() = test { - val dueDateMillis = 1_752_996_000_000 + viewModel.onEvent(CreateTaskEvent.SaveClicked) + awaitState().saveButtonEnabled shouldBe false - viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis)) + taskCapture.get().also { task -> + task.priority shouldBe Priority.HIGHEST + task.dueDate shouldBe endOfDayDueDate + } - awaitState().dueDate.also { - it?.selectedDateMillis shouldBe dueDateMillis - it?.selectedDateDisplayText shouldNotBe null + awaitSideEffect() shouldBe CreateTaskAction.NavigateBack } } @Test - fun `DueDateChanged - null should clear dueDate`() = test { + fun `DueDateChanged - should update and clear dueDate`() = test { val dueDateMillis = 1_752_996_000_000 val expectedDueDate = CreateTaskViewData.DueDateViewData.fixture( selectedDateMillis = dueDateMillis, @@ -154,14 +167,6 @@ class CreateTaskViewModelTest : every(mockTimeProvider::now) returns creationDate everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit - 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) } @@ -185,6 +190,58 @@ class CreateTaskViewModelTest : awaitSideEffect() shouldBe CreateTaskAction.NavigateBack } + @Test + fun `SaveClicked - should persist task with default values`() = test { + val id = Uuid.random() + val title = "Task title" + val creationDate = Instant.parse("2026-07-21T12:00:00Z") + val taskCapture = Capture.slot() + + 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.SaveClicked) + awaitState().saveButtonEnabled shouldBe false + + taskCapture.get() shouldBe Task( + id = id, + title = title, + description = null, + creationDate = creationDate, + dueDate = null, + completionDate = null, + priority = Priority.MEDIUM, + url = null, + location = null, + ) + } + + @Test + fun `SaveClicked - should persist selected priority`() = test { + val id = Uuid.random() + val title = "Task title" + val taskCapture = Capture.slot() + + every(mockUuidProvider::random) returns id + every(mockTimeProvider::now) returns Instant.parse("2026-07-21T12:00:00Z") + everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit + + viewModel.onEvent(CreateTaskEvent.TitleChanged(title)) + expectState { copy(title = title, saveButtonEnabled = true) } + + viewModel.onEvent(CreateTaskEvent.PriorityChanged(Priority.BLOCKER)) + expectState { copy(title = title, priority = Priority.BLOCKER, saveButtonEnabled = true) } + + viewModel.onEvent(CreateTaskEvent.SaveClicked) + awaitState().saveButtonEnabled shouldBe false + + taskCapture.get().priority shouldBe Priority.BLOCKER + } + @Test fun `SaveClicked - should keep creationDate when editing an existing task`() { val taskId = Uuid.random() diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/FoliaryPriorityPickerTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/FoliaryPriorityPickerTest.kt new file mode 100644 index 00000000..087f8197 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/FoliaryPriorityPickerTest.kt @@ -0,0 +1,104 @@ +package dev.appoutlet.foliary.feature.createtask + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +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 dev.appoutlet.foliary.data.task.database.entity.Priority +import foliary.foliary.generated.resources.Res +import foliary.foliary.generated.resources.create_task_priority_label +import foliary.foliary.generated.resources.priority_pill_blocker +import foliary.foliary.generated.resources.priority_pill_high +import foliary.foliary.generated.resources.priority_pill_highest +import foliary.foliary.generated.resources.priority_pill_low +import foliary.foliary.generated.resources.priority_pill_lowest +import foliary.foliary.generated.resources.priority_pill_medium +import io.kotest.matchers.shouldBe +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class FoliaryPriorityPickerTest { + + @Test + fun `should render selected priority as pill`() = runComposeUiTest { + val label = getString(Res.string.create_task_priority_label) + + setContent { + FoliaryPriorityPicker( + selectedPriority = Priority.MEDIUM, + onPrioritySelected = {}, + label = label + ) + } + + onNodeWithText(label).assertIsDisplayed() + onNodeWithText(getString(Res.string.priority_pill_medium)).assertIsDisplayed() + onNodeWithTag("FoliaryPriorityPicker:Value", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `no menu items should exist before card is clicked`() = runComposeUiTest { + setContent { + FoliaryPriorityPicker( + selectedPriority = Priority.MEDIUM, + onPrioritySelected = {}, + label = stringResource(Res.string.create_task_priority_label) + ) + } + + onNodeWithText(getString(Res.string.priority_pill_lowest)).assertDoesNotExist() + onNodeWithText(getString(Res.string.priority_pill_high)).assertDoesNotExist() + onNodeWithText(getString(Res.string.priority_pill_blocker)).assertDoesNotExist() + } + + @Test + fun `should open menu and list all six labels when card is clicked`() = runComposeUiTest { + setContent { + FoliaryPriorityPicker( + selectedPriority = Priority.MEDIUM, + onPrioritySelected = {}, + label = stringResource(Res.string.create_task_priority_label) + ) + } + + onNodeWithTag("FoliaryPriorityPicker:Card", useUnmergedTree = true).performClick() + waitForIdle() + + onNodeWithText(getString(Res.string.priority_pill_lowest)).assertIsDisplayed() + onNodeWithText(getString(Res.string.priority_pill_low)).assertIsDisplayed() + onAllNodesWithText(getString(Res.string.priority_pill_medium)).assertCountEquals(2) + onNodeWithText(getString(Res.string.priority_pill_high)).assertIsDisplayed() + onNodeWithText(getString(Res.string.priority_pill_highest)).assertIsDisplayed() + onNodeWithText(getString(Res.string.priority_pill_blocker)).assertIsDisplayed() + } + + @Test + fun `selecting an option should invoke callback and close menu`() = runComposeUiTest { + var selectedPriority: Priority? = null + + setContent { + FoliaryPriorityPicker( + selectedPriority = Priority.MEDIUM, + onPrioritySelected = { selectedPriority = it }, + label = stringResource(Res.string.create_task_priority_label) + ) + } + + onNodeWithTag("FoliaryPriorityPicker:Card", useUnmergedTree = true).performClick() + waitForIdle() + + onNodeWithText(getString(Res.string.priority_pill_high)).performClick() + waitForIdle() + + selectedPriority shouldBe Priority.HIGH + + onNodeWithText(getString(Res.string.priority_pill_lowest)).assertDoesNotExist() + onNodeWithText(getString(Res.string.priority_pill_blocker)).assertDoesNotExist() + } +} diff --git a/foliary/src/iosTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.ios.kt b/foliary/src/iosTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.ios.kt new file mode 100644 index 00000000..c73788d4 --- /dev/null +++ b/foliary/src/iosTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.ios.kt @@ -0,0 +1,5 @@ +package dev.appoutlet.foliary.data.authentication + +import eu.anifantakis.lib.ksafe.KSafe + +actual fun platformKSafe() = KSafe() diff --git a/foliary/src/jvmTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.jvm.kt b/foliary/src/jvmTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.jvm.kt new file mode 100644 index 00000000..c73788d4 --- /dev/null +++ b/foliary/src/jvmTest/kotlin/dev/appoutlet/foliary/data/authentication/DefaultAuthenticationRepositoryTest.jvm.kt @@ -0,0 +1,5 @@ +package dev.appoutlet.foliary.data.authentication + +import eu.anifantakis.lib.ksafe.KSafe + +actual fun platformKSafe() = KSafe() diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 6c1139ec..baa28d15 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,12 +1,13 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b4322abe..d6a039c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ koin = "4.2.2" koin-compiler = "1.2.1" kotest = "6.2.5" ktor = "3.6.0" -kotlin = "2.4.21" +kotlin = "2.4.20" kotlinx-coroutines = "1.11.0" kotlinx-serialization = "1.11.0" ksp = "2.3.12" @@ -87,7 +87,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -ksafe = "eu.anifantakis:ksafe:3.2.0" +ksafe = "eu.anifantakis:ksafe:3.3.0" ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }