diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml
index 73cb403..d76edee 100644
--- a/foliary/src/commonMain/composeResources/values/strings.xml
+++ b/foliary/src/commonMain/composeResources/values/strings.xml
@@ -46,6 +46,10 @@
Foliary
+
+ Overdue
+
+
Open Foliary
Quit
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/card/FoliaryCard.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/card/FoliaryCard.kt
index d1a9d39..da0f6ff 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/card/FoliaryCard.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/card/FoliaryCard.kt
@@ -3,11 +3,13 @@ package dev.appoutlet.foliary.core.ui.component.card
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Card
+import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@@ -15,6 +17,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun FoliaryCard(
modifier: Modifier = Modifier,
+ colors: CardColors = FoliaryCardDefaults.colors(),
content: @Composable ColumnScope.() -> Unit
) {
Card(
@@ -23,18 +26,27 @@ fun FoliaryCard(
shape = MaterialTheme.shapes.extraLarge,
shadow = Shadow(
radius = 60.dp,
- spread = 0.dp,
+ spread = 10.dp,
offset = DpOffset(0.dp, 0.dp),
- color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.06f),
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f),
)
),
shape = MaterialTheme.shapes.extraLarge,
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surface,
- contentColor = MaterialTheme.colorScheme.onSurface
- ),
+ colors = colors,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
content = content
)
}
+
+object FoliaryCardDefaults {
+
+ @Composable
+ fun colors(
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ ): CardColors = CardDefaults.cardColors(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ )
+}
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/checkbox/FoliaryCheckbox.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/checkbox/FoliaryCheckbox.kt
new file mode 100644
index 0000000..8e863df
--- /dev/null
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/checkbox/FoliaryCheckbox.kt
@@ -0,0 +1,80 @@
+package dev.appoutlet.foliary.core.ui.component.checkbox
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import com.composables.icons.lucide.Check
+import com.composables.icons.lucide.Lucide
+
+@Composable
+fun FoliaryCheckbox(
+ checked: Boolean,
+ onCheckedChange: ((Boolean) -> Unit)?,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+) {
+ val containerColor by animateColorAsState(
+ targetValue = if (checked) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.background
+ },
+ label = "FoliaryCheckboxContainerColor",
+ )
+
+ val borderColor by animateColorAsState(
+ targetValue = if (checked) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ label = "FoliaryCheckboxBorderColor",
+ )
+
+ Box(
+ modifier = modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .toggleable(
+ value = checked,
+ enabled = enabled && onCheckedChange != null,
+ role = Role.Checkbox,
+ onValueChange = { onCheckedChange?.invoke(it) },
+ )
+ .background(containerColor)
+ .border(BorderStroke(2.dp, borderColor), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ AnimatedVisibility(visible = checked, enter = fadeIn(), exit = fadeOut()) {
+ Checkmark(color = MaterialTheme.colorScheme.onPrimary)
+ }
+ }
+}
+
+@Composable
+private fun Checkmark(color: Color) {
+ Icon(
+ modifier = Modifier.size(14.dp),
+ imageVector = Lucide.Check,
+ contentDescription = null,
+ tint = color
+ )
+}
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/task/TaskItem.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/task/TaskItem.kt
new file mode 100644
index 0000000..67ad370
--- /dev/null
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/task/TaskItem.kt
@@ -0,0 +1,179 @@
+package dev.appoutlet.foliary.core.ui.component.task
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+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.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.composables.icons.lucide.Info
+import com.composables.icons.lucide.Lucide
+import com.composables.icons.lucide.Play
+import dev.appoutlet.foliary.core.ui.component.card.FoliaryCard
+import dev.appoutlet.foliary.core.ui.component.card.FoliaryCardDefaults
+import dev.appoutlet.foliary.core.ui.component.checkbox.FoliaryCheckbox
+import foliary.foliary.generated.resources.Res
+import foliary.foliary.generated.resources.task_item_overdue
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun TaskItem(
+ title: String,
+ modifier: Modifier = Modifier,
+ description: String? = null,
+ isCompleted: Boolean = false,
+ onCompletedChange: (Boolean) -> Unit = { },
+ onStartClick: () -> Unit = {},
+ isOverdue: Boolean = false,
+) {
+ val cardContainerColor = animateColorAsState(
+ targetValue = if (isCompleted) {
+ MaterialTheme.colorScheme.surfaceDim
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ )
+
+ FoliaryCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = FoliaryCardDefaults.colors(containerColor = cardContainerColor.value),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ FoliaryCheckbox(
+ checked = isCompleted,
+ onCheckedChange = onCompletedChange,
+ modifier = Modifier.testTag("TaskItem:Checkbox"),
+ )
+
+ TaskItemContent(
+ title = title,
+ description = description,
+ isCompleted = isCompleted,
+ isOverdue = isOverdue,
+ modifier = Modifier.weight(1f),
+ )
+
+ StartButton(onClick = onStartClick)
+ }
+ }
+}
+
+@Composable
+private fun TaskItemContent(
+ title: String,
+ description: String?,
+ isCompleted: Boolean,
+ isOverdue: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val titleColor = animateColorAsState(
+ targetValue = if (isCompleted) {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = CompletedAlpha)
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ )
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = title,
+ modifier = Modifier.testTag("TaskItem:Title"),
+ style = MaterialTheme.typography.titleMedium,
+ color = titleColor.value,
+ textDecoration = if (isCompleted) TextDecoration.LineThrough else null,
+ maxLines = MaxTitleLines,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ description?.let {
+ Text(
+ text = it,
+ modifier = Modifier.testTag("TaskItem:Description"),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = MaxDescriptionLines,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ Row {
+ if (isOverdue && !isCompleted) {
+ OverduePill()
+ }
+ }
+ }
+}
+
+@Composable
+private fun OverduePill() {
+ Row(
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .clip(CircleShape)
+ .background(color = MaterialTheme.colorScheme.surfaceDim, shape = CircleShape)
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ .testTag("TaskItem:OverduePill"),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(12.dp),
+ imageVector = Lucide.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+
+ Text(
+ text = stringResource(Res.string.task_item_overdue),
+ modifier = Modifier,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+}
+
+@Composable
+private fun StartButton(onClick: () -> Unit) {
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier.testTag("TaskItem:StartButton"),
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.secondary
+ )
+ ) {
+ Icon(
+ imageVector = Lucide.Play,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+}
+
+private const val CompletedAlpha = 0.38f
+private const val MaxDescriptionLines = 3
+private const val MaxTitleLines = 2
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 8310bb7..7c0e19b 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
@@ -29,7 +29,7 @@ val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF313030)
val inverseOnSurfaceLight = Color(0xFFF4F0EF)
val inversePrimaryLight = Color(0xFF9ED1BD)
-val surfaceDimLight = Color(0xFFDDD9D8)
+val surfaceDimLight = Color(0xFFF4F3F0)
val surfaceBrightLight = Color(0xFFFCF8F7)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF7F3F2)
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt
index dd9184f..ceea3a1 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt
@@ -46,18 +46,16 @@ fun MainScreen() {
adaptiveInfo = currentWindowAdaptiveInfoV2(),
)
- val itemTopPadding = remember(layoutType) {
+ val (windowDecorationPadding, itemTopPadding) = remember(layoutType) {
when (layoutType) {
NavigationSuiteType.NavigationRail,
NavigationSuiteType.WideNavigationRailExpanded,
- NavigationSuiteType.WideNavigationRailCollapsed -> 16.dp
+ NavigationSuiteType.WideNavigationRailCollapsed -> getWindowDecorationPadding() to 16.dp
- else -> 0.dp
+ else -> 0.dp to 0.dp
}
}
- val windowDecorationPadding = remember { getWindowDecorationPadding() }
-
NavigationSuiteScaffold(
modifier = Modifier
.fillMaxSize()
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/task/TaskItemTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/task/TaskItemTest.kt
new file mode 100644
index 0000000..c665ff9
--- /dev/null
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/task/TaskItemTest.kt
@@ -0,0 +1,131 @@
+package dev.appoutlet.foliary.core.ui.component.task
+
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.runComposeUiTest
+import foliary.foliary.generated.resources.Res
+import foliary.foliary.generated.resources.task_item_overdue
+import io.kotest.matchers.shouldBe
+import org.jetbrains.compose.resources.getString
+import kotlin.test.Test
+
+@OptIn(ExperimentalTestApi::class)
+class TaskItemTest {
+
+ @Test
+ fun `should render title and description`() = runComposeUiTest {
+ val title = "Task title"
+ val description = "Task description"
+
+ setContent {
+ TaskItem(
+ title = title,
+ description = description,
+ )
+ }
+
+ onNodeWithTag("TaskItem:Title").assertIsDisplayed().assertTextEquals(title)
+ onNodeWithTag("TaskItem:Description").assertIsDisplayed().assertTextEquals(description)
+ }
+
+ @Test
+ fun `should not show description when null`() = runComposeUiTest {
+ setContent {
+ TaskItem(title = "Task title")
+ }
+
+ onNodeWithTag("TaskItem:Description").assertDoesNotExist()
+ }
+
+ @Test
+ fun `should show unchecked checkbox by default`() = runComposeUiTest {
+ setContent {
+ TaskItem(title = "Task title")
+ }
+
+ onNodeWithTag("TaskItem:Checkbox").assertIsDisplayed()
+ }
+
+ @Test
+ fun `should show overdue pill when isOverdue is true and not completed`() = runComposeUiTest {
+ setContent {
+ TaskItem(
+ title = "Task title",
+ isOverdue = true,
+ isCompleted = false,
+ )
+ }
+
+ onNodeWithTag("TaskItem:OverduePill").assertIsDisplayed()
+ onNodeWithText(getString(Res.string.task_item_overdue)).assertIsDisplayed()
+ }
+
+ @Test
+ fun `should not show overdue pill when not overdue`() = runComposeUiTest {
+ setContent {
+ TaskItem(
+ title = "Task title",
+ isOverdue = false,
+ )
+ }
+
+ onNodeWithTag("TaskItem:OverduePill").assertDoesNotExist()
+ }
+
+ @Test
+ fun `should not show overdue pill when completed`() = runComposeUiTest {
+ setContent {
+ TaskItem(
+ title = "Task title",
+ isOverdue = true,
+ isCompleted = true,
+ )
+ }
+
+ onNodeWithTag("TaskItem:OverduePill").assertDoesNotExist()
+ }
+
+ @Test
+ fun `should toggle completion state when checkbox is clicked`() = runComposeUiTest {
+ val completed = mutableStateOf(false)
+
+ setContent {
+ TaskItem(
+ title = "Task title",
+ isCompleted = completed.value,
+ onCompletedChange = { completed.value = it },
+ )
+ }
+
+ onNodeWithTag("TaskItem:Checkbox").assertIsOff()
+
+ onNodeWithTag("TaskItem:Checkbox").performClick()
+ onNodeWithTag("TaskItem:Checkbox").assertIsOn()
+
+ onNodeWithTag("TaskItem:Checkbox").performClick()
+ onNodeWithTag("TaskItem:Checkbox").assertIsOff()
+ }
+
+ @Test
+ fun `should call onStartClick when start button is clicked`() = runComposeUiTest {
+ var started = false
+
+ setContent {
+ TaskItem(
+ title = "Task title",
+ onStartClick = { started = true },
+ )
+ }
+
+ onNodeWithTag("TaskItem:StartButton").performClick()
+
+ started shouldBe true
+ }
+}