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