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" }