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