diff --git a/foliary/src/commonMain/composeResources/drawable/today_celebration.xml b/foliary/src/commonMain/composeResources/drawable/today_celebration.xml
new file mode 100644
index 00000000..d1251c43
--- /dev/null
+++ b/foliary/src/commonMain/composeResources/drawable/today_celebration.xml
@@ -0,0 +1,298 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml
index 94a49ea3..2480cf4d 100644
--- a/foliary/src/commonMain/composeResources/values/strings.xml
+++ b/foliary/src/commonMain/composeResources/values/strings.xml
@@ -56,6 +56,11 @@
No tasks
You don't have any tasks for today. Feel free to take a break or click on the button below to create a new task.
Create a new task
+ You did it!
+ You have completed every task planned for today and thats outstanding! It's time to celebrate!
+ Create a new task
+ Completed today (%1$d)
+ Toggle completed tasks
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/modifier/FoliaryShadow.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/modifier/FoliaryShadow.kt
index 240df3f9..557d0428 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/modifier/FoliaryShadow.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/modifier/FoliaryShadow.kt
@@ -1,5 +1,6 @@
package dev.appoutlet.foliary.core.ui.component.modifier
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -12,7 +13,11 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
val FoliaryShadowColorDefault: Color
- @Composable get() = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
+ @Composable get() = if (isSystemInDarkTheme()) {
+ MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
+ } else {
+ MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
+ }
@Composable
fun Modifier.foliaryShadow(
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/textfield/FoliaryTextField.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/textfield/FoliaryTextField.kt
index 08cab991..6030ddab 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/textfield/FoliaryTextField.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/textfield/FoliaryTextField.kt
@@ -17,7 +17,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
@@ -46,7 +45,7 @@ fun FoliaryTextField(
visualTransformation: VisualTransformation = VisualTransformation.None,
onTextLayout: (TextLayoutResult) -> Unit = {},
interactionSource: MutableInteractionSource? = null,
- cursorBrush: Brush = SolidColor(Color.Black),
+ cursorBrush: Brush = SolidColor(MaterialTheme.colorScheme.onSurface),
) {
val placeholderAlpha by animateFloatAsState(targetValue = if (value.isBlank()) 1f else 0f)
@@ -56,7 +55,7 @@ fun FoliaryTextField(
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
- textStyle = textStyle,
+ textStyle = textStyle.copy(color = MaterialTheme.colorScheme.onSurface),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
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 5783731f..3aafdece 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
@@ -41,7 +41,7 @@ val primaryDark = Color(0xFF00E796)
val onPrimaryDark = Color(0xFF00382A)
val primaryContainerDark = Color(0xFF1D5643)
val onPrimaryContainerDark = Color(0xFFB9E6D3)
-val secondaryDark = Color(0xFF1F6F6E)
+val secondaryDark = Color(0xFF16504f)
val onSecondaryDark = Color(0xFFF4FFFB)
val secondaryContainerDark = Color(0xFF35473C)
val onSecondaryContainerDark = Color(0xFFD2E4D9)
@@ -53,10 +53,10 @@ val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
-val backgroundDark = Color(0xFF2B3228)
-val onBackgroundDark = Color(0xFFE4E2DD)
-val surfaceDark = Color(0xFF1B1F19)
-val onSurfaceDark = Color(0xFFE5E2E1)
+val backgroundDark = Color(0xFF1B1F19)
+val onBackgroundDark = Color(0xFFb6b3af)
+val surfaceDark = Color(0xFF141712)
+val onSurfaceDark = Color(0xFFf3f2f1)
val surfaceVariantDark = Color(0xFF404945)
val onSurfaceVariantDark = Color(0xFFC0C9C3)
val outlineDark = Color(0x1FFFFFFF)
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt
index 3d28ea7b..f4324edd 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt
@@ -13,8 +13,9 @@ class TaskRepositoryImpl(
private val timeProvider: TimeProvider,
) : TaskRepository {
override fun findTodayTasks(): Flow> {
+ val startOfToday = timeProvider.startOfToday()
val endOfToday = timeProvider.endOfToday()
- return taskDao.findTodayTasks(endOfToday)
+ return taskDao.findTodayTasks(startOfToday, endOfToday)
}
override fun observeById(id: Uuid) = taskDao.observeById(id)
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt
index 765fa9b5..e92e7ebc 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt
@@ -34,10 +34,10 @@ interface TaskDao {
"""
SELECT *
FROM Task
- WHERE (dueDate <= :endOfToday OR dueDate IS NULL)
- AND completionDate IS NULL
+ WHERE (completionDate IS NULL AND (dueDate <= :endOfToday OR dueDate IS NULL))
+ OR (completionDate IS NOT NULL AND completionDate >= :startOfToday AND completionDate <= :endOfToday)
ORDER BY dueDate IS NULL, dueDate ASC
"""
)
- fun findTodayTasks(endOfToday: Instant): Flow>
+ fun findTodayTasks(startOfToday: Instant, endOfToday: Instant): Flow>
}
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt
index f40bc9a4..36f0fcf5 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt
@@ -2,9 +2,11 @@ package dev.appoutlet.foliary.feature.today
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -22,8 +24,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
@@ -32,12 +33,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
+import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import dev.appoutlet.foliary.core.navigation.Navigator
@@ -48,12 +53,19 @@ import dev.appoutlet.foliary.core.ui.component.modifier.FoliaryShadowColorDefaul
import dev.appoutlet.foliary.core.ui.component.modifier.foliaryShadow
import dev.appoutlet.foliary.core.ui.component.modifier.widthInCompact
import dev.appoutlet.foliary.core.ui.component.task.FoliaryTaskCard
+import dev.appoutlet.foliary.core.ui.component.task.FoliaryTaskCardViewData
import dev.appoutlet.foliary.feature.createtask.CreateTaskNavKey
import dev.appoutlet.foliary.feature.main.getWindowDecorationPadding
import dev.appoutlet.foliary.feature.signin.SignInNavKey
import dev.appoutlet.foliary.feature.taskdetail.TaskDetailNavKey
import foliary.foliary.generated.resources.Res
import foliary.foliary.generated.resources.today_add_task_a11y
+import foliary.foliary.generated.resources.today_celebration
+import foliary.foliary.generated.resources.today_celebration_button
+import foliary.foliary.generated.resources.today_celebration_description
+import foliary.foliary.generated.resources.today_celebration_title
+import foliary.foliary.generated.resources.today_completed_header
+import foliary.foliary.generated.resources.today_completed_header_a11y
import foliary.foliary.generated.resources.today_empty
import foliary.foliary.generated.resources.today_empty_button
import foliary.foliary.generated.resources.today_empty_description
@@ -75,14 +87,14 @@ fun TodayScreen(lazyListState: LazyListState) {
) { viewData ->
when (viewData) {
TodayViewData.Idle -> {}
- is TodayViewData.Loaded -> TodayScreenContent(
- lazyListState,
- viewData,
- viewModel::onEvent
- )
-
TodayViewData.Loading -> LoadingIndicator()
- is TodayViewData.Empty -> TodayScreenEmpty(viewData, viewModel::onEvent)
+ is TodayViewData.Loaded,
+ is TodayViewData.Empty,
+ is TodayViewData.Celebration -> TodayScreenContent(
+ lazyListState = lazyListState,
+ viewData = viewData,
+ onEvent = viewModel::onEvent
+ )
}
}
}
@@ -90,9 +102,13 @@ fun TodayScreen(lazyListState: LazyListState) {
@Composable
internal fun TodayScreenContent(
lazyListState: LazyListState,
- viewData: TodayViewData.Loaded,
+ viewData: TodayViewData,
onEvent: (TodayEvent) -> Unit
) {
+ val userName = viewData.userName
+ val completedTasks = viewData.completedTasks
+
+ var completedCollapsed by remember { mutableStateOf(true) }
val showActionShadow by remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > 1 }
}
@@ -105,18 +121,116 @@ internal fun TodayScreenContent(
) {
stickyHeader { TodayAddButton(onEvent, showActionShadow) }
item { } // Required for better UX
- item { TodayHeader(viewData.userName) }
- items(viewData.tasks, key = { it.id }) { task ->
- Box(modifier = Modifier.fillMaxWidth().testTag("TodayScreen:TaskItem")) {
- FoliaryTaskCard(
- modifier = Modifier.widthInCompact()
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp)
- .align(Alignment.Center),
- task = task,
- onClick = { onEvent(TodayEvent.OnTaskClick(task.id)) }
+ item { TodayHeader(userName) }
+
+ when (viewData) {
+ is TodayViewData.Empty -> item {
+ TodayScreenEmptyContent(modifier = Modifier.animateItem(), onEvent = onEvent)
+ }
+
+ is TodayViewData.Celebration -> item {
+ TodayScreenCelebrationContent(modifier = Modifier.animateItem(), onEvent = onEvent)
+ }
+
+ is TodayViewData.Loaded -> {
+ items(viewData.pendingTasks, key = { it.id }) { task ->
+ TodayTaskItem(modifier = Modifier.animateItem(), task = task, onEvent = onEvent)
+ }
+ }
+
+ else -> {}
+ }
+
+ if (completedTasks.isNotEmpty()) {
+ item(key = "CompletedHeader") {
+ CompletedTodayHeader(
+ modifier = Modifier.animateItem(),
+ count = completedTasks.size,
+ collapsed = completedCollapsed,
+ onToggle = { completedCollapsed = !completedCollapsed }
)
}
+
+ if (!completedCollapsed) {
+ items(completedTasks, key = { it.id }) { task ->
+ TodayTaskItem(
+ task = task,
+ onEvent = onEvent,
+ modifier = Modifier.animateItem()
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TodayTaskItem(
+ task: FoliaryTaskCardViewData,
+ onEvent: (TodayEvent) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .testTag("TodayScreen:TaskItem")
+ ) {
+ FoliaryTaskCard(
+ modifier = Modifier.widthInCompact()
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .align(Alignment.Center),
+ task = task,
+ onCompletedChange = { isCompleted ->
+ if (isCompleted) {
+ onEvent(TodayEvent.MarkTaskAsCompleted(task.id))
+ } else {
+ onEvent(TodayEvent.MarkTaskAsNotCompleted(task.id))
+ }
+ },
+ onClick = { onEvent(TodayEvent.OnTaskClick(task.id)) }
+ )
+ }
+}
+
+@Composable
+private fun CompletedTodayHeader(
+ count: Int,
+ collapsed: Boolean,
+ onToggle: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val chevronRotation by animateFloatAsState(
+ targetValue = if (collapsed) RotationCollapsed else RotationExpanded
+ )
+
+ Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ Row(
+ modifier = Modifier
+ .widthInCompact()
+ .fillMaxWidth()
+ .clickable(
+ onClick = onToggle,
+ onClickLabel = stringResource(Res.string.today_completed_header_a11y)
+ )
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .testTag("TodayScreen:CompletedHeader"),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+
+ Text(
+ text = stringResource(Res.string.today_completed_header, count),
+ style = MaterialTheme.typography.labelMedium
+ )
+
+ Icon(
+ modifier = Modifier.rotate(chevronRotation),
+ imageVector = Lucide.ChevronDown,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onBackground
+ )
}
}
}
@@ -173,50 +287,89 @@ private fun TodayHeader(userName: String, modifier: Modifier = Modifier) {
}
}
-private fun onAction(action: TodayAction, navigator: Navigator) {
- when (action) {
- TodayAction.NavigateToCreateTask -> navigator.navigate(CreateTaskNavKey())
- TodayAction.NavigateToSignIn -> navigator.setRoot(SignInNavKey)
- is TodayAction.NavigateToTaskDetail -> navigator.navigate(TaskDetailNavKey(action.taskId))
+@Composable
+private fun TodayScreenEmptyContent(
+ onEvent: (TodayEvent) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .widthInCompact()
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 56.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Image(
+ modifier = Modifier.height(192.dp).align(Alignment.CenterHorizontally),
+ painter = painterResource(Res.drawable.today_empty),
+ contentDescription = null
+ )
+
+ Text(
+ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
+ text = stringResource(Res.string.today_empty_title),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ Text(
+ text = stringResource(Res.string.today_empty_description),
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ FoliarySecondaryButton(
+ modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 16.dp),
+ onClick = { onEvent(TodayEvent.OnAddTaskClick) },
+ ) {
+ Text(text = stringResource(Res.string.today_empty_button))
+ }
}
}
@Composable
-internal fun TodayScreenEmpty(viewData: TodayViewData.Empty, onEvent: (TodayEvent) -> Unit) {
- Column(Modifier.fillMaxWidth()) {
- TodayAddButton(onEvent = onEvent, showActionShadow = false)
- TodayHeader(modifier = Modifier.padding(top = 16.dp), userName = viewData.userName)
- Column(
- modifier = Modifier.widthInCompact()
- .fillMaxSize()
- .align(Alignment.CenterHorizontally)
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.Center
- ) {
- Image(
- modifier = Modifier.height(192.dp).align(Alignment.CenterHorizontally),
- painter = painterResource(Res.drawable.today_empty),
- contentDescription = null
- )
+private fun TodayScreenCelebrationContent(
+ onEvent: (TodayEvent) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .widthInCompact()
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 56.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Image(
+ modifier = Modifier.height(192.dp).align(Alignment.CenterHorizontally),
+ painter = painterResource(Res.drawable.today_celebration),
+ contentDescription = null
+ )
- Text(
- modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
- text = stringResource(Res.string.today_empty_title),
- style = MaterialTheme.typography.titleLarge
- )
+ Text(
+ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
+ text = stringResource(Res.string.today_celebration_title),
+ style = MaterialTheme.typography.titleLarge
+ )
- Text(
- text = stringResource(Res.string.today_empty_description),
- style = MaterialTheme.typography.bodyMedium
- )
+ Text(
+ text = stringResource(Res.string.today_celebration_description),
+ style = MaterialTheme.typography.bodyMedium
+ )
- FoliarySecondaryButton(
- modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 16.dp),
- onClick = { onEvent(TodayEvent.OnAddTaskClick) },
- ) {
- Text(text = stringResource(Res.string.today_empty_button))
- }
+ FoliarySecondaryButton(
+ modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 16.dp),
+ onClick = { onEvent(TodayEvent.OnAddTaskClick) },
+ ) {
+ Text(text = stringResource(Res.string.today_celebration_button))
}
}
}
+
+private fun onAction(action: TodayAction, navigator: Navigator) {
+ when (action) {
+ TodayAction.NavigateToCreateTask -> navigator.navigate(CreateTaskNavKey())
+ TodayAction.NavigateToSignIn -> navigator.setRoot(SignInNavKey)
+ is TodayAction.NavigateToTaskDetail -> navigator.navigate(TaskDetailNavKey(action.taskId))
+ }
+}
+
+private const val RotationCollapsed = -90f
+private const val RotationExpanded = 0f
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt
index 694cbc86..40576b2a 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt
@@ -10,6 +10,7 @@ import dev.appoutlet.foliary.data.task.TaskRepository
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.KoinViewModel
+import kotlin.uuid.Uuid
@KoinViewModel
class TodayViewModel(
@@ -29,16 +30,42 @@ class TodayViewModel(
currentUser?.name() ?: ""
}
+ private var celebrationPending = false
+
override val container = container(TodayViewData.Idle) {
taskRepository.findTodayTasks()
.onStart { reduce { TodayViewData.Loading } }
.map { tasks -> tasks.map { foliaryTaskCardViewDataMapper(it) } }
- .collect { tasks ->
+ .map { tasks ->
+ val pendingTasks = tasks.filter { !it.isCompleted }
+ val completedTasks = tasks.filter { it.isCompleted }
+ pendingTasks to completedTasks
+ }
+ .collect { (pendingTasks, completedTasks) ->
reduce {
- if (tasks.isEmpty()) {
- TodayViewData.Empty(userName = userName)
- } else {
- TodayViewData.Loaded(userName = userName, tasks = tasks)
+ when {
+ celebrationPending && pendingTasks.isEmpty() -> {
+ celebrationPending = false
+ TodayViewData.Celebration(
+ userName = userName,
+ completedTasks = completedTasks
+ )
+ }
+ pendingTasks.isEmpty() -> {
+ celebrationPending = false
+ TodayViewData.Empty(
+ userName = userName,
+ completedTasks = completedTasks
+ )
+ }
+ else -> {
+ celebrationPending = false
+ TodayViewData.Loaded(
+ userName = userName,
+ pendingTasks = pendingTasks,
+ completedTasks = completedTasks
+ )
+ }
}
}
}
@@ -48,6 +75,8 @@ class TodayViewModel(
when (event) {
TodayEvent.OnAddTaskClick -> onAddTaskClick()
is TodayEvent.OnTaskClick -> onTaskClick(event.taskId)
+ is TodayEvent.MarkTaskAsCompleted -> onMarkTaskAsCompleted(event.taskId)
+ is TodayEvent.MarkTaskAsNotCompleted -> onMarkTaskAsNotCompleted(event.taskId)
}
}
@@ -58,19 +87,47 @@ class TodayViewModel(
private fun onTaskClick(taskId: String) = intent {
postSideEffect(TodayAction.NavigateToTaskDetail(taskId))
}
+
+ private fun onMarkTaskAsCompleted(taskId: String) = intent {
+ celebrationPending = true
+ taskRepository.markCompleted(Uuid.parse(taskId))
+ }
+
+ private fun onMarkTaskAsNotCompleted(taskId: String) = intent {
+ celebrationPending = false
+ taskRepository.markNotCompleted(Uuid.parse(taskId))
+ }
}
sealed interface TodayViewData {
- data object Idle : TodayViewData
+ val userName: String
+ val completedTasks: List
- data object Loading : TodayViewData
+ data object Idle : TodayViewData {
+ override val userName: String = ""
+ override val completedTasks: List = emptyList()
+ }
+
+ data object Loading : TodayViewData {
+ override val userName: String = ""
+ override val completedTasks: List = emptyList()
+ }
data class Loaded(
- val userName: String,
- val tasks: List,
+ override val userName: String,
+ val pendingTasks: List,
+ override val completedTasks: List,
) : TodayViewData
- data class Empty(val userName: String) : TodayViewData
+ data class Empty(
+ override val userName: String,
+ override val completedTasks: List,
+ ) : TodayViewData
+
+ data class Celebration(
+ override val userName: String,
+ override val completedTasks: List,
+ ) : TodayViewData
}
sealed interface TodayAction : Action {
@@ -82,4 +139,6 @@ sealed interface TodayAction : Action {
sealed interface TodayEvent {
data object OnAddTaskClick : TodayEvent
data class OnTaskClick(val taskId: String) : TodayEvent
+ data class MarkTaskAsCompleted(val taskId: String) : TodayEvent
+ data class MarkTaskAsNotCompleted(val taskId: String) : TodayEvent
}
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt
index 63ce801d..381c6cb9 100644
--- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt
@@ -24,11 +24,13 @@ class TaskRepositoryImplTest {
@Test
fun `should return today's tasks`() = runTest {
+ val startOfToday = Instant.parse("2026-07-22T00:00:00Z")
val endOfToday = Instant.parse("2026-07-22T23:59:59.999999999Z")
val fixtureTasks = listOf(Task.fixture())
+ every { mockTimeProvider.startOfToday() } returns startOfToday
every { mockTimeProvider.endOfToday() } returns endOfToday
- every { mockTaskDao.findTodayTasks(endOfToday) } returns flowOf(fixtureTasks)
+ every { mockTaskDao.findTodayTasks(startOfToday, endOfToday) } returns flowOf(fixtureTasks)
val result = subject.findTodayTasks().first()
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt
index 9ad21ce5..5a35eee9 100644
--- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt
@@ -69,6 +69,7 @@ class TaskDaoTest : DaoTest() {
@Test
fun `should return today's tasks`() = runTest {
+ val startOfToday = Instant.parse("2026-07-21T00:00:00Z")
val endOfToday = Instant.parse("2026-07-21T23:59:59.999999999Z")
val overdueTask = Task.fixture(
@@ -96,23 +97,50 @@ class TaskDaoTest : DaoTest() {
)
val noDueDateTask = Task.fixture(
- title = "Future task",
+ title = "No due date task",
dueDate = null,
completionDate = null,
)
- val completedTask = Task.fixture(
- title = "Completed task",
+ val completedTodayTask = Task.fixture(
+ title = "Completed today task",
dueDate = Instant.parse("2026-07-20T21:00:00Z"),
completionDate = Instant.parse("2026-07-21T10:00:00Z"),
)
- dao.save(overdueTask, dueTodayTask, dueAtEndOfDayTask, futureTask, completedTask, noDueDateTask)
+ val completedYesterdayTask = Task.fixture(
+ title = "Completed yesterday task",
+ dueDate = Instant.parse("2026-07-20T21:00:00Z"),
+ completionDate = Instant.parse("2026-07-20T10:00:00Z"),
+ )
- val result = dao.findTodayTasks(endOfToday).first()
+ val completedTomorrowTask = Task.fixture(
+ title = "Completed tomorrow task",
+ dueDate = Instant.parse("2026-07-20T21:00:00Z"),
+ completionDate = Instant.parse("2026-07-22T10:00:00Z"),
+ )
+
+ dao.save(
+ overdueTask,
+ dueTodayTask,
+ dueAtEndOfDayTask,
+ futureTask,
+ noDueDateTask,
+ completedTodayTask,
+ completedYesterdayTask,
+ completedTomorrowTask,
+ )
+
+ val result = dao.findTodayTasks(startOfToday, endOfToday).first()
val resultIds = result.map { it.id }
- resultIds shouldBe listOf(overdueTask.id, dueTodayTask.id, dueAtEndOfDayTask.id, noDueDateTask.id)
+ resultIds shouldBe listOf(
+ completedTodayTask.id,
+ overdueTask.id,
+ dueTodayTask.id,
+ dueAtEndOfDayTask.id,
+ noDueDateTask.id,
+ )
}
@Test
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt
index 62d3573c..3a627a8a 100644
--- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt
@@ -12,6 +12,10 @@ import dev.appoutlet.foliary.core.ui.component.task.FoliaryTaskCardViewData
import dev.appoutlet.foliary.core.ui.component.task.fixture
import foliary.foliary.generated.resources.Res
import foliary.foliary.generated.resources.today_add_task_a11y
+import foliary.foliary.generated.resources.today_celebration_button
+import foliary.foliary.generated.resources.today_celebration_description
+import foliary.foliary.generated.resources.today_celebration_title
+import foliary.foliary.generated.resources.today_completed_header
import foliary.foliary.generated.resources.today_empty_button
import foliary.foliary.generated.resources.today_empty_description
import foliary.foliary.generated.resources.today_empty_title
@@ -31,7 +35,11 @@ class TodayScreenTest {
setContent {
TodayScreenContent(
lazyListState = rememberLazyListState(),
- viewData = TodayViewData.Loaded(userName = userName, tasks = listOf(task)),
+ viewData = TodayViewData.Loaded(
+ userName = userName,
+ pendingTasks = listOf(task),
+ completedTasks = emptyList()
+ ),
onEvent = {},
)
}
@@ -51,7 +59,8 @@ class TodayScreenTest {
lazyListState = rememberLazyListState(),
viewData = TodayViewData.Loaded(
userName = "Messias",
- tasks = listOf(task),
+ pendingTasks = listOf(task),
+ completedTasks = emptyList()
),
onEvent = { event = it },
)
@@ -73,7 +82,8 @@ class TodayScreenTest {
lazyListState = rememberLazyListState(),
viewData = TodayViewData.Loaded(
userName = "Messias",
- tasks = listOf(FoliaryTaskCardViewData.fixture()),
+ pendingTasks = listOf(FoliaryTaskCardViewData.fixture()),
+ completedTasks = emptyList()
),
onEvent = { event = it },
)
@@ -91,8 +101,9 @@ class TodayScreenTest {
val userName = "Messias"
setContent {
- TodayScreenEmpty(
- viewData = TodayViewData.Empty(userName = userName),
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Empty(userName = userName, completedTasks = emptyList()),
onEvent = {},
)
}
@@ -109,8 +120,9 @@ class TodayScreenTest {
var event: TodayEvent? = null
setContent {
- TodayScreenEmpty(
- viewData = TodayViewData.Empty(userName = "Messias"),
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Empty(userName = "Messias", completedTasks = emptyList()),
onEvent = { event = it },
)
}
@@ -121,4 +133,165 @@ class TodayScreenTest {
event shouldBe TodayEvent.OnAddTaskClick
}
+
+ @Test
+ fun `should render pending tasks above completed section`() = runComposeUiTest {
+ val pendingTask = FoliaryTaskCardViewData.fixture(
+ id = "pending-task",
+ title = "Pending task",
+ isCompleted = false
+ )
+ val completedTask = FoliaryTaskCardViewData.fixture(
+ id = "completed-task",
+ title = "Completed task",
+ isCompleted = true
+ )
+
+ setContent {
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Loaded(
+ userName = "Messias",
+ pendingTasks = listOf(pendingTask),
+ completedTasks = listOf(completedTask)
+ ),
+ onEvent = {},
+ )
+ }
+
+ onNodeWithText(pendingTask.title).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.today_completed_header, 1)).assertIsDisplayed()
+
+ onNodeWithTag("TodayScreen:CompletedHeader").performClick()
+ onNodeWithText(completedTask.title).assertIsDisplayed()
+ }
+
+ @Test
+ fun `should show completed tasks when header is tapped`() = runComposeUiTest {
+ val completedTask = FoliaryTaskCardViewData.fixture(
+ id = "completed-task",
+ title = "Completed task",
+ isCompleted = true
+ )
+
+ setContent {
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Loaded(
+ userName = "Messias",
+ pendingTasks = emptyList(),
+ completedTasks = listOf(completedTask)
+ ),
+ onEvent = {},
+ )
+ }
+
+ onNodeWithText(completedTask.title).assertDoesNotExist()
+
+ onNodeWithTag("TodayScreen:CompletedHeader")
+ .assertIsDisplayed()
+ .performClick()
+
+ onNodeWithText(completedTask.title).assertIsDisplayed()
+ }
+
+ @Test
+ fun `should hide completed tasks when header is tapped again`() = runComposeUiTest {
+ val completedTask = FoliaryTaskCardViewData.fixture(
+ id = "completed-task",
+ title = "Completed task",
+ isCompleted = true
+ )
+
+ setContent {
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Loaded(
+ userName = "Messias",
+ pendingTasks = emptyList(),
+ completedTasks = listOf(completedTask)
+ ),
+ onEvent = {},
+ )
+ }
+
+ onNodeWithTag("TodayScreen:CompletedHeader").performClick()
+ onNodeWithText(completedTask.title).assertIsDisplayed()
+
+ onNodeWithTag("TodayScreen:CompletedHeader").performClick()
+ onNodeWithText(completedTask.title).assertDoesNotExist()
+ }
+
+ @Test
+ fun `should render celebration content`() = runComposeUiTest {
+ val completedTask = FoliaryTaskCardViewData.fixture(id = "completed-task", isCompleted = true)
+
+ setContent {
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Celebration(
+ userName = "Messias",
+ completedTasks = listOf(completedTask)
+ ),
+ onEvent = {},
+ )
+ }
+
+ onNodeWithText(getString(Res.string.today_celebration_title)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.today_celebration_description)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.today_celebration_button)).assertIsDisplayed()
+
+ onNodeWithTag("TodayScreen:CompletedHeader").performClick()
+ onNodeWithText(completedTask.title).assertIsDisplayed()
+ }
+
+ @Test
+ fun `should emit MarkTaskAsCompleted when an unfinished task checkbox is checked`() = runComposeUiTest {
+ val task = FoliaryTaskCardViewData.fixture(id = "task-id", isCompleted = false)
+ var event: TodayEvent? = null
+
+ setContent {
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Loaded(
+ userName = "Messias",
+ pendingTasks = listOf(task),
+ completedTasks = emptyList()
+ ),
+ onEvent = { event = it },
+ )
+ }
+
+ onNodeWithTag("FoliaryTaskCard:Checkbox", useUnmergedTree = true)
+ .assertIsDisplayed()
+ .performClick()
+
+ event shouldBe TodayEvent.MarkTaskAsCompleted(task.id)
+ }
+
+ @Test
+ fun `should emit MarkTaskAsNotCompleted when a completed task checkbox is unchecked`() = runComposeUiTest {
+ val task = FoliaryTaskCardViewData.fixture(id = "task-id", isCompleted = true)
+ var event: TodayEvent? = null
+
+ setContent {
+ TodayScreenContent(
+ lazyListState = rememberLazyListState(),
+ viewData = TodayViewData.Loaded(
+ userName = "Messias",
+ pendingTasks = emptyList(),
+ completedTasks = listOf(task)
+ ),
+ onEvent = { event = it },
+ )
+ }
+
+ onNodeWithTag("TodayScreen:CompletedHeader").performClick()
+
+ onNodeWithTag("FoliaryTaskCard:Checkbox", useUnmergedTree = true)
+ .assertIsDisplayed()
+ .performClick()
+
+ event shouldBe TodayEvent.MarkTaskAsNotCompleted(task.id)
+ }
}
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt
index 707079a3..3ce38551 100644
--- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt
@@ -12,10 +12,16 @@ import dev.appoutlet.foliary.data.task.database.entity.Task
import dev.appoutlet.foliary.data.task.database.entity.fixture
import dev.mokkery.answering.returns
import dev.mokkery.every
+import dev.mokkery.everySuspend
import dev.mokkery.mock
+import dev.mokkery.verifySuspend
import io.github.jan.supabase.auth.user.UserInfo
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlin.test.Test
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.uuid.Uuid
class TodayViewModelTest : ViewModelTest() {
private val mockTaskRepository = mock()
@@ -36,7 +42,7 @@ class TodayViewModelTest : ViewModelTest