diff --git a/docs/devspecs/category-details/CategoryDetails.md b/docs/devspecs/category-details/CategoryDetails.md new file mode 100644 index 000000000..2e4042cff --- /dev/null +++ b/docs/devspecs/category-details/CategoryDetails.md @@ -0,0 +1,97 @@ +# 🐈 Category details + +## Overview + +Alkaa is being revamped to a new user interface, following the Kuvio design system. The first screen +selected to be implemented is the Category details screen. This is a new screen in the app, since +the category details today is a simple Bottom Sheet where the user can simply update the category +name and color or delete it entirely, removing all the categories in the process. + +![bottom-sheet.png](assets/bottom-sheet.png) + +The new screen for the Category details is going to be a full screen showing a header with more +information, all the tasks associated with this category, a separation by task state, and more. + +![new-category.png](assets/new-category.png) + +## Screen details + +The screen consists of the following components: + +- **Header** - contains the back button, and a header with the information: + - Emoji icon related to the category - e.g. "πŸš€" + - Name of the category - e.g. "Work" + - Current progress - "14 tasks - 3 completed" + - Vertical three dots icon for options. Available options: + - Rename + - Delete +- **List of tasks** - contains the list of all tasks, separate by state. If a state does not have + tasks, the state is now shown in the UI + - States: + - Due today - tasks that have the due date equal today + - Upcoming - tasks that have the due date to a date in the future + - No due date - tasks without due date + - Completed - tasks completed +- **Add task bar** - the Kuvio component for adding a Task, floating at the bottom of the screen + +## Current integration + +This screen will be integrated in the "OnCategoryClick" event, controlled by the IsNewDesignEnabled +feature flag. When enabled, it will redirect to the new screen, otherwise it will keep forwarding +to the bottom sheet. + +Components such as "add task bar" and the "task item" already exist in the Kuvio Design System. For +new complex components, such as the "header", a new component will be created on the design system +module. + +## Out of scope + +The following tasks are currently out of scope: + +- Emojis are not yet supported in the category object. For now, we are simply showing a placeholder +- When clicking on the "Available options" ("Rename" and "Delete") no action will be taken yet. + +## Acceptance criteria + +All the acceptance criteria below assume that the IsNewDesignEnabled is turned on, meaning that +the feature is available + +- Given the user navigates to the "Categories tab" +- When they click on an existing category +- Then the new screen is shown + +--- + +- Given the user has several tasks +- When the Category Details screen is opened +- All categories will be shown in their respective states + +--- + +- Given the user has no tasks +- When the Category Details is opened +- Information will be shown in the content area that no tasks were added yet + +--- + +- Given the user is in the Category Details screen +- When they add a new task +- The task will be shown in the list + +--- + +- Given the user is in the Category Details screen +- When they add a new task with a due date +- The task will be shown in the list, in the correct section + +## Important + +1. For changing the codebase, the skills inside [](../../../.claude/skills) must be used. They have + all the information on how to deal with implementing a feature, navigation, compose, testing, + etc. + +2. The proposed code needs to follow the correct architecture as closely as possible + +3. Each commit needs to be concise. + +4. At the end of the work, all tests and quality checks are passing diff --git a/docs/devspecs/category-details/assets/bottom-sheet.png b/docs/devspecs/category-details/assets/bottom-sheet.png new file mode 100644 index 000000000..a07888663 Binary files /dev/null and b/docs/devspecs/category-details/assets/bottom-sheet.png differ diff --git a/docs/devspecs/category-details/assets/new-category.png b/docs/devspecs/category-details/assets/new-category.png new file mode 100644 index 000000000..42d466792 Binary files /dev/null and b/docs/devspecs/category-details/assets/new-category.png differ diff --git a/docs/superpowers/plans/2026-03-27-category-details.md b/docs/superpowers/plans/2026-03-27-category-details.md new file mode 100644 index 000000000..75e6af01e --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-category-details.md @@ -0,0 +1,1742 @@ +# Category Details Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the new full-screen Category Details screen, replacing the bottom sheet when `IsNewDesignEnabled = true`, showing tasks grouped by state with an Add Task bar. + +**Architecture:** Extend `features/category` with a new `detail/` presentation layer. Domain grouping logic lives in a new `LoadCategoryTasks` use case. `DateTimePicker` is promoted to `libraries/designsystem` for reuse. A new `KuvioCategoryHeader` component is added to the design system. + +**Tech Stack:** Kotlin Multiplatform, Compose Multiplatform, Koin, kotlinx.datetime, Navigation3, SQLDelight (via existing DAOs), Kotest/kotlin.test + +**Spec:** `docs/superpowers/specs/2026-03-27-category-details-design.md` + +--- + +## File Map + +**New files:** +| File | Purpose | +|------|---------| +| `domain/src/commonMain/.../domain/model/TaskGroup.kt` | Sealed class grouping tasks by section | +| `domain/src/commonMain/.../domain/usecase/taskwithcategory/LoadCategoryTasks.kt` | Use case interface | +| `domain/src/commonMain/.../domain/usecase/taskwithcategory/implementation/LoadCategoryTasksImpl.kt` | Groups tasks by date/completion | +| `domain/src/commonTest/.../domain/usecase/taskwithcategory/LoadCategoryTasksTest.kt` | Unit tests for grouping logic | +| `libraries/designsystem/src/commonMain/.../designsystem/components/picker/DateTimePicker.kt` | Moved from features/task | +| `libraries/designsystem/src/commonMain/.../designsystem/components/kuvio/header/KuvioCategoryHeader.kt` | New Kuvio header component | +| `features/category/src/commonMain/.../category/presentation/detail/CategoryDetailsState.kt` | Sealed state class | +| `features/category/src/commonMain/.../category/presentation/detail/CategoryDetailsMapper.kt` | Domainβ†’view conversion + task construction | +| `features/category/src/commonMain/.../category/presentation/detail/CategoryDetailsViewModel.kt` | Orchestrates use cases, exposes state flow | +| `features/category/src/commonMain/.../category/presentation/detail/CategoryDetailsScreen.kt` | Section + Screen + Content composables | +| `features/category/src/commonTest/.../category/fake/AddTaskFake.kt` | Fake for AddTask | +| `features/category/src/commonTest/.../category/fake/UpdateTaskStatusFake.kt` | Fake for UpdateTaskStatus | +| `features/category/src/commonTest/.../category/fake/LoadCategoryTasksFake.kt` | Fake for LoadCategoryTasks | +| `features/category/src/commonTest/.../category/presentation/detail/CategoryDetailsViewModelTest.kt` | ViewModel unit tests | +| `features/category/src/commonTest/.../category/presentation/detail/CategoryDetailsScreenTest.kt` | UI composable tests | +| `features/category/src/commonTest/.../category/event/CategoryEventTest.kt` | Routing unit tests | + +**Modified files:** +| File | Change | +|------|--------| +| `domain/src/commonMain/.../domain/di/DomainModule.kt` | Register `LoadCategoryTasksImpl` | +| `libraries/designsystem/src/commonMain/.../designsystem/config/DesignSystemConfig.kt` | `const val` β†’ `var` for testability | +| `features/navigation-api/src/commonMain/.../navigationapi/destination/CategoryDestination.kt` | Add `CategoryDetails` data class | +| `features/navigation-api/src/commonMain/.../navigationapi/event/CategoryEvent.kt` | Add `OnCategoryDetailsClick` nested class | +| `features/task/src/commonMain/.../task/presentation/detail/alarm/DateTimePicker.kt` | Delete (moved to designsystem) | +| `features/task/src/commonMain/.../task/presentation/detail/TaskDetailScreen.kt` | Update import for DateTimePicker | +| `features/category/src/commonMain/.../category/navigation/CategoryNavGraph.kt` | Feature flag branch + new entry | +| `features/category/src/commonMain/.../category/di/CategoryModule.kt` | Register ViewModel + Mapper | +| `features/category/src/commonTest/.../category/fake/LoadCategoryFake.kt` | Add `clear()` method | +| `resources/commonMain/composeResources/values/strings.xml` | Add `category_header_progress` string | +| `resources/commonMain/composeResources/values-es/strings.xml` | Spanish translation | +| `resources/commonMain/composeResources/values-fr/strings.xml` | French translation | +| `resources/commonMain/composeResources/values-pt-rBR/strings.xml` | Portuguese translation | +| `shared/src/commonTest/.../alkaa/CategoryFlowTest.kt` | Add E2E tests | + +--- + +## Task 1: Domain model β€” `TaskGroup` + +**Files:** +- Create: `domain/src/commonMain/kotlin/com/escodro/domain/model/TaskGroup.kt` + +- [ ] **Create `TaskGroup.kt`** + +```kotlin +package com.escodro.domain.model + +sealed class TaskGroup { + abstract val tasks: List + + data class Overdue(override val tasks: List) : TaskGroup() + data class DueToday(override val tasks: List) : TaskGroup() + data class Upcoming(override val tasks: List) : TaskGroup() + data class NoDueDate(override val tasks: List) : TaskGroup() + data class Completed(override val tasks: List) : TaskGroup() +} +``` + +- [ ] **Run desktop tests to verify no regressions** + +```bash +./gradlew :domain:desktopTest +``` +Expected: All existing tests pass. + +- [ ] **Commit** + +```bash +git add domain/src/commonMain/kotlin/com/escodro/domain/model/TaskGroup.kt +git commit -m "feat: add TaskGroup domain model for category task sections" +``` + +--- + +## Task 2: Domain use case β€” `LoadCategoryTasks` interface + tests + +**Files:** +- Create: `domain/src/commonMain/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasks.kt` +- Create: `domain/src/commonTest/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasksTest.kt` + +- [ ] **Create the interface** + +```kotlin +package com.escodro.domain.usecase.taskwithcategory + +import com.escodro.domain.model.TaskGroup +import kotlinx.coroutines.flow.Flow + +interface LoadCategoryTasks { + operator fun invoke(categoryId: Long): Flow> +} +``` + +- [ ] **Write the failing tests** + +Reference: `DateTimeProviderFake` hardcodes `1993-04-15T16:50:00Z` (UTC). All test tasks use dates relative to `1993-04-15`. + +```kotlin +package com.escodro.domain.usecase.taskwithcategory + +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.domain.model.TaskWithCategory +import com.escodro.domain.usecase.fake.DateTimeProviderFake +import com.escodro.domain.usecase.fake.TaskWithCategoryRepositoryFake +import com.escodro.domain.usecase.taskwithcategory.implementation.LoadCategoryTasksImpl +import com.escodro.test.CoroutinesTestDispatcher +import com.escodro.test.CoroutinesTestDispatcherImpl +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class LoadCategoryTasksTest : + CoroutinesTestDispatcher by CoroutinesTestDispatcherImpl() { + + private val repository = TaskWithCategoryRepositoryFake() + private val dateProvider = DateTimeProviderFake() + private val useCase = LoadCategoryTasksImpl( + repository = repository, + dateTimeProvider = dateProvider, + ) + + @BeforeTest + fun setup() { + repository.clear() + } + + // Overdue = due date before 1993-04-15 + @Test + fun `test if overdue tasks are grouped in the overdue section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = LocalDate(1993, 4, 14)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Overdue }) + assertEquals(1, groups.filterIsInstance().first().tasks.size) + } + + @Test + fun `test if tasks due today are grouped in the due today section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = LocalDate(1993, 4, 15)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.DueToday }) + } + + @Test + fun `test if upcoming tasks are grouped in the upcoming section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = LocalDate(1993, 4, 16)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Upcoming }) + } + + @Test + fun `test if tasks without due date are grouped in the no due date section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = null) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.NoDueDate }) + } + + @Test + fun `test if completed tasks are grouped in the completed section`() = runTest { + // Given + val task = buildTask(id = 1L, isCompleted = true) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Completed }) + } + + @Test + fun `test if completed tasks with past due date are grouped in completed not overdue`() = runTest { + // Given β€” task is both overdue AND completed; should go to Completed + val task = buildTask(id = 1L, dueDate = LocalDate(1993, 4, 14), isCompleted = true) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Completed }) + assertTrue(groups.none { it is TaskGroup.Overdue }) + } + + @Test + fun `test if empty sections are not included in the result`() = runTest { + // Given β€” one overdue task only + val task = buildTask(id = 1L, dueDate = LocalDate(1993, 4, 14)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then β€” only Overdue is emitted; other sections absent + assertEquals(1, groups.size) + assertTrue(groups.first() is TaskGroup.Overdue) + } + + // Helpers + private fun buildTask( + id: Long, + dueDate: LocalDate? = null, + isCompleted: Boolean = false, + ) = Task( + id = id, + title = "Task $id", + categoryId = 1L, + isCompleted = isCompleted, + dueDate = dueDate?.let { LocalDateTime(it, LocalTime(9, 0)) }, + ) + + private fun buildTaskWithCategory(task: Task) = + TaskWithCategory(task = task, category = null) +} +``` + +- [ ] **Run tests to verify they fail (class does not exist yet)** + +```bash +./gradlew :domain:desktopTest --tests "*.LoadCategoryTasksTest" +``` +Expected: Compilation failure β€” `LoadCategoryTasksImpl` not found. + +- [ ] **Create `LoadCategoryTasksImpl`** + +```kotlin +package com.escodro.domain.usecase.taskwithcategory.implementation + +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.domain.provider.DateTimeProvider +import com.escodro.domain.repository.TaskWithCategoryRepository +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +internal class LoadCategoryTasksImpl( + private val repository: TaskWithCategoryRepository, + private val dateTimeProvider: DateTimeProvider, +) : LoadCategoryTasks { + + override fun invoke(categoryId: Long): Flow> = + repository.findAllTasksWithCategoryId(categoryId).map { list -> + val tasks = list.map { it.task } + val today = dateTimeProvider.getCurrentInstant() + .toLocalDateTime(TimeZone.UTC).date + buildGroups(tasks, today) + } + + private fun buildGroups(tasks: List, today: kotlinx.datetime.LocalDate): List { + val completed = tasks.filter { it.isCompleted } + val active = tasks.filter { !it.isCompleted } + + val noDueDate = active.filter { it.dueDate == null } + val overdue = active.filter { it.dueDate != null && it.dueDate.date < today } + val dueToday = active.filter { it.dueDate != null && it.dueDate.date == today } + val upcoming = active.filter { it.dueDate != null && it.dueDate.date > today } + + return listOfNotNull( + TaskGroup.Overdue(overdue).takeIf { overdue.isNotEmpty() }, + TaskGroup.DueToday(dueToday).takeIf { dueToday.isNotEmpty() }, + TaskGroup.Upcoming(upcoming).takeIf { upcoming.isNotEmpty() }, + TaskGroup.NoDueDate(noDueDate).takeIf { noDueDate.isNotEmpty() }, + TaskGroup.Completed(completed).takeIf { completed.isNotEmpty() }, + ) + } +} +``` + +- [ ] **Run tests to verify they pass** + +```bash +./gradlew :domain:desktopTest --tests "*.LoadCategoryTasksTest" +``` +Expected: All 7 tests PASS. + +- [ ] **Register in `DomainModule.kt`** + +In `domain/src/commonMain/kotlin/com/escodro/domain/di/DomainModule.kt`, add inside the module block alongside other `taskwithcategory` use cases: + +```kotlin +factoryOf(::LoadCategoryTasksImpl) bind LoadCategoryTasks::class +``` + +Also add the import at the top: +```kotlin +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks +import com.escodro.domain.usecase.taskwithcategory.implementation.LoadCategoryTasksImpl +``` + +- [ ] **Run all domain tests** + +```bash +./gradlew :domain:desktopTest +``` +Expected: All pass. + +- [ ] **Commit** + +```bash +git add domain/src/ +git commit -m "feat: add LoadCategoryTasks use case with section grouping logic" +``` + +--- + +## Task 3: Navigation β€” destination, event, feature flag + +**Files:** +- Modify: `features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/destination/CategoryDestination.kt` +- Modify: `features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/event/CategoryEvent.kt` +- Modify: `libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/config/DesignSystemConfig.kt` +- Create: `features/category/src/commonTest/kotlin/com/escodro/category/event/CategoryEventTest.kt` + +- [ ] **Add `CategoryDetails` to `CategoryDestination.kt`** + +```kotlin +@Serializable +data class CategoryDetails(val categoryId: Long) : Destination +``` + +Add alongside `CategoryBottomSheet`. Implements neither `TopLevel` nor `TopAppBarVisible`. + +- [ ] **Add `OnCategoryDetailsClick` to `CategoryEvent.kt`** (inside the existing `object CategoryEvent`) + +```kotlin +data class OnCategoryDetailsClick(val categoryId: Long) : Event { + override fun nextDestination(): Destination = + CategoryDestination.CategoryDetails(categoryId) +} +``` + +- [ ] **Change `IsNewDesignEnabled` from `const val` to `var` in `DesignSystemConfig.kt`** + +```kotlin +object DesignSystemConfig { + var IsNewDesignEnabled: Boolean = false +} +``` + +- [ ] **Write routing tests** + +```kotlin +package com.escodro.category.event + +import com.escodro.navigationapi.destination.CategoryDestination +import com.escodro.navigationapi.event.CategoryEvent +import kotlin.test.Test +import kotlin.test.assertIs + +internal class CategoryEventTest { + + @Test + fun `test if on category details click returns category details destination`() { + // Given + val event = CategoryEvent.OnCategoryDetailsClick(categoryId = 42L) + + // When + val destination = event.nextDestination() + + // Then + assertIs(destination) + } + + @Test + fun `test if on category click returns category bottom sheet destination`() { + // Given + val event = CategoryEvent.OnCategoryClick(categoryId = 42L) + + // When + val destination = event.nextDestination() + + // Then + assertIs(destination) + } +} +``` + +- [ ] **Run routing tests** + +```bash +./gradlew :features:category:desktopTest --tests "*.CategoryEventTest" +``` +Expected: Both tests PASS. + +- [ ] **Commit** + +```bash +git add features/navigation-api/src/ libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/config/ features/category/src/commonTest/kotlin/com/escodro/category/event/ +git commit -m "feat: add CategoryDetails destination, routing event, and testable feature flag" +``` + +--- + +## Task 4: Move `DateTimePicker` to design system + +**Files:** +- Create: `libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/picker/DateTimePicker.kt` +- Delete: `features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/DateTimePicker.kt` +- Modify: `features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailScreen.kt` (or wherever `DateTimerPicker` is imported) + +- [ ] **Copy `DateTimePicker.kt` to the new location** + +Copy the full file content from `features/task/.../alarm/DateTimePicker.kt` to `libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/picker/DateTimePicker.kt`. Change only the package declaration: + +```kotlin +package com.escodro.designsystem.components.picker +``` + +All other content β€” including the `DateTimerPicker` function name β€” remains unchanged. + +- [ ] **Delete the original file** + +```bash +git rm features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/DateTimePicker.kt +``` + +- [ ] **Update the import in the task detail screen** + +Find the import `import com.escodro.task.presentation.detail.alarm.DateTimerPicker` in task feature files and replace with: + +```kotlin +import com.escodro.designsystem.components.picker.DateTimerPicker +``` + +Run a search first: +```bash +grep -r "DateTimerPicker" features/task/src/commonMain/ --include="*.kt" -l +``` + +- [ ] **Build to verify no broken imports** + +```bash +./gradlew :features:task:assemble :libraries:designsystem:assemble +``` +Expected: Compiles cleanly. + +- [ ] **Commit** + +```bash +git add libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/picker/ +git add features/task/src/ +git commit -m "refactor: promote DateTimePicker to designsystem for cross-feature reuse" +``` + +--- + +## Task 5: Localization strings for `KuvioCategoryHeader` + +**Files:** +- Modify: `resources/commonMain/composeResources/values/strings.xml` +- Modify: `resources/commonMain/composeResources/values-es/strings.xml` +- Modify: `resources/commonMain/composeResources/values-fr/strings.xml` +- Modify: `resources/commonMain/composeResources/values-pt-rBR/strings.xml` + +Follow the `localization` skill for conventions. Key: `category_header_progress` uses `%1$d` and `%2$d` for total and completed counts. + +- [ ] **Add to `values/strings.xml`** + +```xml +%1$d tasks Β· %2$d completed +No tasks yet +Add a task using the bar below +Category options +Overdue +Due Today +Upcoming +No Due Date +Completed +``` + +- [ ] **Add translations to `values-es/strings.xml`** + +```xml +%1$d tareas Β· %2$d completadas +Sin tareas +Agrega una tarea usando la barra de abajo +Opciones de categorΓ­a +Atrasadas +Para hoy +PrΓ³ximas +Sin fecha +Completadas +``` + +- [ ] **Add translations to `values-fr/strings.xml`** + +```xml +%1$d tΓ’ches Β· %2$d terminΓ©es +Aucune tΓ’che +Ajoutez une tΓ’che en utilisant la barre ci-dessous +Options de catΓ©gorie +En retard +Pour aujourd\'hui +Γ€ venir +Sans date +TerminΓ©es +``` + +- [ ] **Add translations to `values-pt-rBR/strings.xml`** + +```xml +%1$d tarefas Β· %2$d concluΓ­das +Nenhuma tarefa +Adicione uma tarefa usando a barra abaixo +OpΓ§Γ΅es de categoria +Atrasadas +Para hoje +PrΓ³ximas +Sem data +ConcluΓ­das +``` + +- [ ] **Commit** + +```bash +git add resources/ +git commit -m "feat: add CategoryDetails localization strings" +``` + +--- + +## Task 6: New `KuvioCategoryHeader` design system component + +**Files:** +- Create: `libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/kuvio/header/KuvioCategoryHeader.kt` + +Reference existing Kuvio components (e.g., `KuvioAddTaskBar.kt`, `KuvioTaskItem.kt`) for style patterns. Max ~60 lines. No raw `Text()` or hardcoded colors. + +- [ ] **Implement `KuvioCategoryHeader.kt`** + +```kotlin +package com.escodro.designsystem.components.kuvio.header + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.escodro.designsystem.components.kuvio.icon.placeholder.KuvioPlaceholderIcon +import com.escodro.designsystem.components.kuvio.text.body.KuvioBodyMediumText +import com.escodro.designsystem.components.kuvio.text.title.KuvioTitleLargeText +import com.escodro.designsystem.icons.KuvioIcons +import com.escodro.resources.Res +import com.escodro.resources.category_details_options_content_description +import com.escodro.resources.category_header_progress +import org.jetbrains.compose.resources.stringResource + +@Composable +fun KuvioCategoryHeader( + name: String, + color: Color, + totalTasks: Int, + completedTasks: Int, + onOptionsClick: () -> Unit, + modifier: Modifier = Modifier, + emoji: String? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CategoryEmojiBox(color = color) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + KuvioTitleLargeText(text = name) + KuvioBodyMediumText( + text = stringResource(Res.string.category_header_progress, totalTasks, completedTasks), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onOptionsClick) { + Icon( + imageVector = KuvioIcons.MoreVert, + contentDescription = stringResource(Res.string.category_details_options_content_description), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun CategoryEmojiBox(color: Color, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(color.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + KuvioPlaceholderIcon(tint = color) + } +} +``` + +> Note: If `KuvioPlaceholderIcon` does not exist, use any existing icon from `KuvioIcons` as the placeholder, or a simple `Box` with the first letter of the category name. + +- [ ] **Add light and dark previews** (at the bottom of the file) + +```kotlin +// Preview imports omitted for brevity β€” follow the pattern in KuvioAddTaskBar.kt +@Preview +@Composable +private fun KuvioCategoryHeaderPreview() { + AlkaaThemePreview { + KuvioCategoryHeader( + name = "Work", + color = Color(0xFF6200EA), + totalTasks = 14, + completedTasks = 3, + onOptionsClick = {}, + ) + } +} + +@Preview +@Composable +private fun KuvioCategoryHeaderDarkPreview() { + AlkaaThemePreview(darkTheme = true) { + KuvioCategoryHeader( + name = "Work", + color = Color(0xFF6200EA), + totalTasks = 14, + completedTasks = 3, + onOptionsClick = {}, + ) + } +} +``` + +- [ ] **Build design system to verify it compiles** + +```bash +./gradlew :libraries:designsystem:assemble +``` +Expected: Compiles cleanly. + +- [ ] **Commit** + +```bash +git add libraries/designsystem/src/ +git commit -m "feat: add KuvioCategoryHeader design system component" +``` + +--- + +## Task 7: Presentation state, mapper, and fakes + +**Files:** +- Create: `features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsState.kt` +- Create: `features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsMapper.kt` +- Create: `features/category/src/commonTest/kotlin/com/escodro/category/fake/AddTaskFake.kt` +- Create: `features/category/src/commonTest/kotlin/com/escodro/category/fake/UpdateTaskStatusFake.kt` +- Create: `features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryTasksFake.kt` +- Modify: `features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryFake.kt` + +- [ ] **Create `CategoryDetailsState.kt`** + +```kotlin +package com.escodro.category.presentation.detail + +import androidx.compose.ui.graphics.Color +import com.escodro.categoryapi.model.Category +import com.escodro.domain.model.TaskGroup + +sealed class CategoryDetailsState { + data object Loading : CategoryDetailsState() + data class Error(val throwable: Throwable) : CategoryDetailsState() + data class Success( + val category: Category, + val categoryColor: Color, + val groups: List, + val totalTasks: Int, + val completedTasks: Int, + ) : CategoryDetailsState() +} +``` + +- [ ] **Create `CategoryDetailsMapper.kt`** + +```kotlin +package com.escodro.category.presentation.detail + +import androidx.compose.ui.graphics.Color +import com.escodro.category.mapper.CategoryMapper +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import kotlinx.datetime.LocalDateTime +import com.escodro.domain.model.Category as DomainCategory + +internal class CategoryDetailsMapper( + private val categoryMapper: CategoryMapper, +) { + + fun toViewState( + domainCategory: DomainCategory, + groups: List, + ): CategoryDetailsState.Success { + val viewCategory = categoryMapper.toView(domainCategory) + val allTasks = groups.flatMap { it.tasks } + return CategoryDetailsState.Success( + category = viewCategory, + categoryColor = Color(viewCategory.color), + groups = groups, + totalTasks = allTasks.size, + completedTasks = groups.filterIsInstance() + .sumOf { it.tasks.size }, + ) + } + + fun toTask( + title: String, + dueDate: LocalDateTime?, + categoryId: Long, + ): Task = Task( + title = title, + dueDate = dueDate, + categoryId = categoryId, + ) +} +``` + +- [ ] **Add `clear()` to `LoadCategoryFake.kt`** + +Find the `var categoryToBeReturned` field and add: +```kotlin +fun clear() { + categoryToBeReturned = null +} +``` + +- [ ] **Create `AddTaskFake.kt`** + +```kotlin +package com.escodro.category.fake + +import com.escodro.domain.model.Task +import com.escodro.domain.usecase.task.AddTask + +internal class AddTaskFake : AddTask { + val addedTasks = mutableListOf() + + override suspend fun invoke(task: Task) { + addedTasks.add(task) + } + + fun clear() { + addedTasks.clear() + } +} +``` + +- [ ] **Create `UpdateTaskStatusFake.kt`** + +```kotlin +package com.escodro.category.fake + +import com.escodro.domain.usecase.task.UpdateTaskStatus + +internal class UpdateTaskStatusFake : UpdateTaskStatus { + val updatedIds = mutableListOf() + + override suspend fun invoke(taskId: Long) { + updatedIds.add(taskId) + } + + fun clear() { + updatedIds.clear() + } +} +``` + +- [ ] **Create `LoadCategoryTasksFake.kt`** + +```kotlin +package com.escodro.category.fake + +import com.escodro.domain.model.TaskGroup +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class LoadCategoryTasksFake : LoadCategoryTasks { + private val flow = MutableStateFlow>(emptyList()) + + fun emit(groups: List) { + flow.value = groups + } + + override fun invoke(categoryId: Long): Flow> = flow + + fun clear() { + flow.value = emptyList() + } +} +``` + +- [ ] **Build to verify compilation** + +```bash +./gradlew :features:category:assemble +``` + +- [ ] **Commit** + +```bash +git add features/category/src/ +git commit -m "feat: add CategoryDetails state, mapper, and test fakes" +``` + +--- + +## Task 8: `CategoryDetailsViewModel` with TDD + +**Files:** +- Create: `features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModel.kt` +- Create: `features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModelTest.kt` + +- [ ] **Write failing tests first** + +```kotlin +package com.escodro.category.presentation.detail + +import com.escodro.category.fake.AddTaskFake +import com.escodro.category.fake.LoadCategoryFake +import com.escodro.category.fake.LoadCategoryTasksFake +import com.escodro.category.fake.UpdateTaskStatusFake +import com.escodro.category.mapper.CategoryMapper +import com.escodro.coroutines.AppCoroutineScope +import com.escodro.domain.model.Category +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.test.CoroutinesTestDispatcher +import com.escodro.test.CoroutinesTestDispatcherImpl +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +internal class CategoryDetailsViewModelTest : + CoroutinesTestDispatcher by CoroutinesTestDispatcherImpl() { + + private val loadCategoryFake = LoadCategoryFake() + private val loadCategoryTasksFake = LoadCategoryTasksFake() + private val addTaskFake = AddTaskFake() + private val updateTaskStatusFake = UpdateTaskStatusFake() + private val mapper = CategoryDetailsMapper(CategoryMapper()) + private val appScope = AppCoroutineScope(context = testDispatcher()) + + private val viewModel = CategoryDetailsViewModel( + loadCategory = loadCategoryFake, + loadCategoryTasks = loadCategoryTasksFake, + addTask = addTaskFake, + updateTaskStatus = updateTaskStatusFake, + mapper = mapper, + applicationScope = appScope, + ) + + @BeforeTest + fun setup() { + loadCategoryFake.clear() + loadCategoryTasksFake.clear() + addTaskFake.clear() + updateTaskStatusFake.clear() + } + + @Test + fun `test if success state is emitted when category and tasks load`() = runTest { + // Given + loadCategoryFake.categoryToBeReturned = Category(id = 1L, name = "Work", color = "#FF0000") + loadCategoryTasksFake.emit(emptyList()) + + // When + val state = viewModel.loadContent(categoryId = 1L).first() + + // Then + assertIs(state) + } + + @Test + fun `test if total and completed task counts are correct`() = runTest { + // Given + loadCategoryFake.categoryToBeReturned = Category(id = 1L, name = "Work", color = "#FF0000") + val completedTask = Task(id = 1L, title = "Done", isCompleted = true) + val pendingTask = Task(id = 2L, title = "Todo") + loadCategoryTasksFake.emit( + listOf( + TaskGroup.NoDueDate(tasks = listOf(pendingTask)), + TaskGroup.Completed(tasks = listOf(completedTask)), + ) + ) + + // When + val state = viewModel.loadContent(categoryId = 1L).first() + require(state is CategoryDetailsState.Success) + + // Then + assertEquals(2, state.totalTasks) + assertEquals(1, state.completedTasks) + } + + @Test + fun `test if adding a task assigns the correct category id`() = runTest { + // Given + val categoryId = 42L + + // When + viewModel.addTask(title = "New task", dueDate = null, categoryId = categoryId) + testScheduler.advanceUntilIdle() + + // Then + assertEquals(1, addTaskFake.addedTasks.size) + assertEquals(categoryId, addTaskFake.addedTasks.first().categoryId) + } + + @Test + fun `test if blank title does not trigger add task`() = runTest { + // Given / When + viewModel.addTask(title = " ", dueDate = null, categoryId = 1L) + testScheduler.advanceUntilIdle() + + // Then + assertTrue(addTaskFake.addedTasks.isEmpty()) + } + + @Test + fun `test if update task status triggers the use case`() = runTest { + // When + viewModel.updateTaskStatus(taskId = 7L) + testScheduler.advanceUntilIdle() + + // Then + assertTrue(updateTaskStatusFake.updatedIds.contains(7L)) + } + + @Test + fun `test if error state is emitted when loading fails`() = runTest { + // Given β€” category not found + loadCategoryFake.categoryToBeReturned = null + loadCategoryTasksFake.emit(emptyList()) + + // When + val state = viewModel.loadContent(categoryId = 1L).first() + + // Then + assertIs(state) + } + + @Test + fun `test if state re-emits after task status update`() = runTest { + // Given + loadCategoryFake.categoryToBeReturned = Category(id = 1L, name = "Work", color = "#FF0000") + val task = Task(id = 1L, title = "Task 1") + loadCategoryTasksFake.emit(listOf(TaskGroup.NoDueDate(tasks = listOf(task)))) + val flow = viewModel.loadContent(categoryId = 1L) + require(flow.first() is CategoryDetailsState.Success) + + // When β€” trigger status update, then simulate DB re-emission + viewModel.updateTaskStatus(taskId = 1L) + testScheduler.advanceUntilIdle() + loadCategoryTasksFake.emit( + listOf(TaskGroup.Completed(tasks = listOf(task.copy(isCompleted = true)))) + ) + + // Then β€” updated state reflects the completion + val newState = flow.first() + require(newState is CategoryDetailsState.Success) + assertEquals(1, newState.completedTasks) + } +} +``` + +> Note: `AppCoroutineScope(context = testDispatcher())` creates a real scope bound to the test dispatcher. Use `testScheduler.advanceUntilIdle()` (from `CoroutinesTestDispatcher` delegate) to drain pending coroutines instead of relying on scope-level helpers. + +- [ ] **Run tests to confirm they fail** + +```bash +./gradlew :features:category:desktopTest --tests "*.CategoryDetailsViewModelTest" +``` +Expected: Compilation failure. + +- [ ] **Create `CategoryDetailsViewModel.kt`** + +```kotlin +package com.escodro.category.presentation.detail + +import com.escodro.domain.usecase.category.LoadCategory +import com.escodro.domain.usecase.task.AddTask +import com.escodro.domain.usecase.task.UpdateTaskStatus +import androidx.lifecycle.ViewModel +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDateTime + +internal class CategoryDetailsViewModel( + private val loadCategory: LoadCategory, + private val loadCategoryTasks: LoadCategoryTasks, + private val addTask: AddTask, + private val updateTaskStatus: UpdateTaskStatus, + private val mapper: CategoryDetailsMapper, + private val applicationScope: CoroutineScope, +) : ViewModel() { + + fun loadContent(categoryId: Long): Flow = combine( + flow { emit(loadCategory(categoryId)) }, + loadCategoryTasks(categoryId), + ) { category, groups -> + if (category == null) { + CategoryDetailsState.Error(IllegalStateException("Category not found")) + } else { + mapper.toViewState(category, groups) + } + }.catch { e -> + emit(CategoryDetailsState.Error(e)) + } + + fun addTask(title: String, dueDate: LocalDateTime?, categoryId: Long) { + if (title.isBlank()) return + applicationScope.launch { + addTask(mapper.toTask(title, dueDate, categoryId)) + } + } + + fun updateTaskStatus(taskId: Long) { + applicationScope.launch { + updateTaskStatus(taskId) + } + } +} +``` + +> Note: The base class is `androidx.lifecycle.ViewModel` (confirmed from `CategoryEditViewModel`'s imports). + +- [ ] **Run ViewModel tests** + +```bash +./gradlew :features:category:desktopTest --tests "*.CategoryDetailsViewModelTest" +``` +Expected: All tests PASS. + +- [ ] **Register in `CategoryModule.kt`** + +Add to `features/category/src/commonMain/kotlin/com/escodro/category/di/CategoryModule.kt`: + +```kotlin +viewModelOf(::CategoryDetailsViewModel) +factoryOf(::CategoryDetailsMapper) +``` + +Add the imports: +```kotlin +import com.escodro.category.presentation.detail.CategoryDetailsMapper +import com.escodro.category.presentation.detail.CategoryDetailsViewModel +``` + +- [ ] **Commit** + +```bash +git add features/category/src/ +git commit -m "feat: add CategoryDetailsViewModel with state flow and task mutations" +``` + +--- + +## Task 9: `CategoryDetailsScreen` composables + +**Files:** +- Create: `features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreen.kt` + +The screen follows the three-layer pattern: `CategoryDetailsSection` (Loader, public) β†’ `CategoryDetailsScreen` (stateless, internal) β†’ `CategoryDetailsContent` (rendering, internal). + +- [ ] **Implement `CategoryDetailsScreen.kt`** + +```kotlin +package com.escodro.category.presentation.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +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.unit.dp +import com.escodro.categoryapi.model.Category +import com.escodro.designsystem.components.kuvio.bar.KuvioAddTaskBar +import com.escodro.designsystem.components.kuvio.header.KuvioCategoryHeader +import com.escodro.designsystem.components.kuvio.item.KuvioTaskItem +import com.escodro.designsystem.components.kuvio.item.KuvioTaskItemData +import com.escodro.designsystem.components.kuvio.item.KuvioTaskItemState +import com.escodro.designsystem.components.kuvio.text.label.KuvioLabelMediumText +import com.escodro.designsystem.components.picker.DateTimerPicker +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import kotlinx.datetime.LocalDateTime +import org.koin.compose.viewmodel.koinViewModel +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.graphics.Color + +// ── Section (Loader) ────────────────────────────────────────────────────────── + +@Composable +fun CategoryDetailsSection( + categoryId: Long, + isSinglePane: Boolean, + onBackClick: () -> Unit, + onTaskClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel: CategoryDetailsViewModel = koinViewModel() + val state by remember(categoryId) { + viewModel.loadContent(categoryId) + }.collectAsState(initial = CategoryDetailsState.Loading) + + CategoryDetailsScreen( + state = state, + isSinglePane = isSinglePane, + onAddTask = { title, dueDate -> viewModel.addTask(title, dueDate, categoryId) }, + onUpdateTaskStatus = { taskId -> viewModel.updateTaskStatus(taskId) }, + onTaskClick = onTaskClick, + onOptionsClick = {}, + onBackClick = onBackClick, + modifier = modifier, + ) +} + +// ── Screen (Stateless) ──────────────────────────────────────────────────────── + +@Composable +internal fun CategoryDetailsScreen( + state: CategoryDetailsState, + isSinglePane: Boolean, + onAddTask: (String, LocalDateTime?) -> Unit, + onUpdateTaskStatus: (Long) -> Unit, + onTaskClick: (Long) -> Unit, + onOptionsClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is CategoryDetailsState.Loading -> Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + + is CategoryDetailsState.Error -> Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + KuvioLabelMediumText(text = state.throwable.message ?: "Error") + } + + is CategoryDetailsState.Success -> CategoryDetailsContent( + category = state.category, + categoryColor = state.categoryColor, + groups = state.groups, + totalTasks = state.totalTasks, + completedTasks = state.completedTasks, + onAddTask = onAddTask, + onUpdateTaskStatus = onUpdateTaskStatus, + onTaskClick = onTaskClick, + onOptionsClick = onOptionsClick, + onBackClick = onBackClick, + modifier = modifier, + ) + } +} + +// ── Content (Rendering) ─────────────────────────────────────────────────────── + +@Composable +internal fun CategoryDetailsContent( + category: Category, + categoryColor: Color, + groups: List, + totalTasks: Int, + completedTasks: Int, + onAddTask: (String, LocalDateTime?) -> Unit, + onUpdateTaskStatus: (Long) -> Unit, + onTaskClick: (Long) -> Unit, + onOptionsClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var taskTitle by remember { mutableStateOf("") } + var selectedDate by remember { mutableStateOf(null) } + var datePickerOpen by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxSize()) { + KuvioCategoryHeader( + name = category.name, + color = categoryColor, + totalTasks = totalTasks, + completedTasks = completedTasks, + onOptionsClick = onOptionsClick, + modifier = Modifier.fillMaxWidth(), + ) + + if (groups.isEmpty()) { + CategoryDetailsEmptyState(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 8.dp), + ) { + groups.forEach { group -> + item { + TaskGroupHeader( + group = group, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + items(group.tasks) { task -> + KuvioTaskItem( + data = task.toItemData(categoryColor, isOverdue = group is TaskGroup.Overdue), + onCheckClick = { onUpdateTaskStatus(task.id) }, + onItemClick = { onTaskClick(task.id) }, + ) + } + } + } + } + + KuvioAddTaskBar( + value = taskTitle, + onValueChange = { taskTitle = it }, + onAddClick = { + onAddTask(taskTitle, selectedDate) + taskTitle = "" + selectedDate = null + }, + onDateClick = { + datePickerOpen = true + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (datePickerOpen) { + DateTimerPicker( + initialDateTime = selectedDate, + isDialogOpen = datePickerOpen, + onCloseDialog = { datePickerOpen = false }, + onDateChange = { date -> + selectedDate = date + datePickerOpen = false + }, + ) + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +@Composable +private fun TaskGroupHeader(group: TaskGroup, modifier: Modifier = Modifier) { + val label = when (group) { + is TaskGroup.Overdue -> "Overdue" // Use stringResource in production + is TaskGroup.DueToday -> "Due Today" + is TaskGroup.Upcoming -> "Upcoming" + is TaskGroup.NoDueDate -> "No Due Date" + is TaskGroup.Completed -> "Completed" + } + KuvioLabelMediumText( + text = label.uppercase(), + modifier = modifier, + ) +} + +@Composable +private fun CategoryDetailsEmptyState(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + KuvioLabelMediumText(text = "No tasks yet") // Use stringResource + } +} + +private fun Task.toItemData(categoryColor: Color, isOverdue: Boolean = false) = KuvioTaskItemData( + title = title, + state = when { + isCompleted -> KuvioTaskItemState.COMPLETED + isOverdue -> KuvioTaskItemState.OVERDUE + else -> KuvioTaskItemState.PENDING + }, + categoryColor = categoryColor, +) +``` + +> Note: Replace hardcoded strings (`"Overdue"`, `"No tasks yet"`, etc.) with `stringResource(Res.string.category_details_section_overdue)` calls using the keys added in Task 5. The stubs above are for readability; the real implementation must use string resources. + +- [ ] **Build to verify compilation** + +```bash +./gradlew :features:category:assemble +``` + +- [ ] **Commit** + +```bash +git add features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreen.kt +git commit -m "feat: add CategoryDetailsScreen composables (Section/Screen/Content)" +``` + +--- + +## Task 10: Wire navigation in `CategoryNavGraph` + +**Files:** +- Modify: `features/category/src/commonMain/kotlin/com/escodro/category/navigation/CategoryNavGraph.kt` + +- [ ] **Update the category list entry and add the new `CategoryDetails` entry** + +Replace the existing `entry` block and add the new `entry` block: + +```kotlin +entry( + metadata = NavDisplay.transitionSpec { FadeInTransition } + + NavDisplay.popTransitionSpec { FadeOutTransition } + + NavDisplay.predictivePopTransitionSpec { FadeOutTransition }, +) { + CategoryListSection( + onAddClick = { + navEventController.sendEvent(CategoryEvent.OnNewCategoryClick) + }, + onItemClick = { categoryId: Long? -> + if (DesignSystemConfig.IsNewDesignEnabled && categoryId != null) { + navEventController.sendEvent(CategoryEvent.OnCategoryDetailsClick(categoryId)) + } else { + navEventController.sendEvent(CategoryEvent.OnCategoryClick(categoryId)) + } + }, + ) +} + +entry { backStackEntry -> + val dest = backStackEntry.toRoute() + val isSinglePane = currentWindowAdaptiveInfo().windowSizeClass.isSinglePane() + CategoryDetailsSection( + categoryId = dest.categoryId, + isSinglePane = isSinglePane, + onBackClick = { navEventController.sendEvent(Event.OnBack) }, + onTaskClick = { taskId -> + navEventController.sendEvent(TaskEvent.OnTaskClick(id = taskId)) + }, + ) +} +``` + +Add required imports: +```kotlin +import com.escodro.category.presentation.detail.CategoryDetailsSection +import com.escodro.designsystem.config.DesignSystemConfig +import com.escodro.navigationapi.destination.CategoryDestination.CategoryDetails +import com.escodro.navigationapi.event.CategoryEvent +import com.escodro.navigationapi.event.TaskEvent +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.WindowSizeClass +``` + +- [ ] **Build Android app to verify full wiring** + +```bash +./gradlew :app:assembleDebug +``` +Expected: Compiles cleanly. + +- [ ] **Commit** + +```bash +git add features/category/src/commonMain/kotlin/com/escodro/category/navigation/ +git commit -m "feat: wire CategoryDetails into NavGraph with feature flag branch" +``` + +--- + +## Task 11: UI tests β€” `CategoryDetailsScreenTest` + +**Files:** +- Create: `features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreenTest.kt` + +Reference: `write-ui-tests` skill. Extends `AlkaaTest()`. Snake_case names. `runComposeUiTest {}`. Wraps in `AlkaaThemePreview`. Strings via `runBlocking { getString(...) }`. + +- [ ] **Write UI tests** + +```kotlin +package com.escodro.category.presentation.detail + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.graphics.Color +import com.escodro.categoryapi.model.Category +import com.escodro.designsystem.theme.AlkaaThemePreview +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.test.AlkaaTest +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import com.escodro.resources.Res +import com.escodro.resources.category_details_empty_title +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +internal class CategoryDetailsScreenTest : AlkaaTest() { + + private val testCategory = Category(id = 1L, name = "Work", color = 0xFF6200EA.toInt()) + private val testColor = Color(0xFF6200EA) + + @Test + fun test_emptyStateIsShownWhenNoTasks() = runComposeUiTest { + // Given + val emptyTitle = runBlocking { getString(Res.string.category_details_empty_title) } + + // When + setContent { + AlkaaThemePreview { + CategoryDetailsContent( + category = testCategory, + categoryColor = testColor, + groups = emptyList(), + totalTasks = 0, + completedTasks = 0, + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + onBackClick = {}, + ) + } + } + + // Then + onNodeWithText(emptyTitle).assertIsDisplayed() + } + + @Test + fun test_taskGroupsAreShown() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "Buy milk") + val groups = listOf(TaskGroup.NoDueDate(tasks = listOf(task))) + + // When + setContent { + AlkaaThemePreview { + CategoryDetailsContent( + category = testCategory, + categoryColor = testColor, + groups = groups, + totalTasks = 1, + completedTasks = 0, + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + onBackClick = {}, + ) + } + } + + // Then + onNodeWithText("Buy milk").assertIsDisplayed() + } + + @Test + fun test_correctSectionHeadersAreDisplayed() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "Task 1") + val groups = listOf(TaskGroup.NoDueDate(tasks = listOf(task))) + val sectionHeader = runBlocking { + getString(Res.string.category_details_section_no_due_date).uppercase() + } + + // When + setContent { + AlkaaThemePreview { + CategoryDetailsContent( + category = testCategory, + categoryColor = testColor, + groups = groups, + totalTasks = 1, + completedTasks = 0, + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + onBackClick = {}, + ) + } + } + + // Then + onNodeWithText(sectionHeader).assertIsDisplayed() + } +} +``` + +- [ ] **Run UI tests** + +```bash +./gradlew :features:category:desktopTest --tests "*.CategoryDetailsScreenTest" +``` +Expected: All pass. + +- [ ] **Commit** + +```bash +git add features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreenTest.kt +git commit -m "test: add CategoryDetailsScreenTest UI tests" +``` + +--- + +## Task 12: E2E tests β€” `CategoryFlowTest` additions + +**Files:** +- Modify: `shared/src/commonTest/kotlin/com/escodro/alkaa/CategoryFlowTest.kt` + +Reference: `write-e2e-tests` skill. Uses `uiTest {}`. DAO-based seeding. Content descriptions for navigation. `@BeforeTest` / `@AfterTest` with flag reset. + +- [ ] **Add E2E tests to `CategoryFlowTest.kt`** + +Add these tests to the existing class. Add a `@BeforeTest` cleanup that resets `IsNewDesignEnabled = false` and a `@AfterTest` that also resets it. + +Use the existing `addCategory(name)` and `navigateToCategory()` helpers already present in `CategoryFlowTest`. The `addCategory` helper clicks "Add category", types the name, and saves β€” no direct DAO insertion needed for category seeding. + +Add these tests to the existing class, and add flag teardown around each test: + +```kotlin +@BeforeTest +fun setUpCategoryDetails() { + DesignSystemConfig.IsNewDesignEnabled = false // reset before each test +} + +@AfterTest +fun tearDownCategoryDetails() { + DesignSystemConfig.IsNewDesignEnabled = false // ensure clean state after each test +} + +@Test +fun when_category_is_clicked_and_flag_enabled_then_details_screen_is_shown() = uiTest { + // Given + DesignSystemConfig.IsNewDesignEnabled = true + navigateToCategory() + addCategory("Work") + + // When + onNodeWithText("Work").performClick() + + // Then β€” the header with the category name is displayed (not the bottom sheet) + onNodeWithText("Work").assertIsDisplayed() + // Bottom sheet "Save" is absent (would be present if bottom sheet opened instead) + onNodeWithContentDescription( + runBlocking { getString(Res.string.task_detail_save_cd) } + ).assertDoesNotExist() +} + +@Test +fun when_category_is_clicked_and_flag_disabled_then_bottom_sheet_is_shown() = uiTest { + // Given β€” flag is false (reset in @BeforeTest) + navigateToCategory() + addCategory("Work") + + // When + onNodeWithText("Work").performClick() + + // Then β€” bottom sheet is present (Save button visible) + onNodeWithContentDescription( + runBlocking { getString(Res.string.task_detail_save_cd) } + ).assertIsDisplayed() +} + +@Test +fun when_task_is_added_in_category_details_then_it_appears_in_the_list() = uiTest { + // Given + DesignSystemConfig.IsNewDesignEnabled = true + navigateToCategory() + addCategory("Work") + onNodeWithText("Work").performClick() + + // When β€” type in the AddTaskBar and submit + val addBarPlaceholder = runBlocking { getString(Res.string.kuvio_add_task_bar_placeholder) } + onNodeWithText(addBarPlaceholder).performClick() + onNodeWithText(addBarPlaceholder).performTextInput("Buy groceries") + onNodeWithContentDescription( + runBlocking { getString(Res.string.kuvio_add_button_cd) } + ).performClick() + + // Then + onNodeWithText("Buy groceries").assertIsDisplayed() +} +``` + +> Note: Verify the exact content description resource keys (`task_detail_save_cd`, `kuvio_add_button_cd`, `kuvio_add_task_bar_placeholder`) by checking the resources files. If keys differ, update accordingly. Use `runBlocking { getString(Res.string.xyz) }` for all string lookups β€” never hardcode. + +- [ ] **Run E2E tests** + +```bash +./gradlew :shared:desktopTest --tests "*.CategoryFlowTest" +``` +Expected: New tests PASS (adjust for any content-description mismatches found at runtime). + +- [ ] **Commit** + +```bash +git add shared/src/commonTest/kotlin/com/escodro/alkaa/CategoryFlowTest.kt +git commit -m "test: add CategoryDetails E2E flow tests" +``` + +--- + +## Task 13: Final quality check + +- [ ] **Run ktlint format** + +```bash +./gradlew ktlintFormat +``` + +- [ ] **Run detekt** + +```bash +./gradlew detektAll +``` + +- [ ] **Run all tests** + +```bash +./gradlew allTests +``` +Expected: All pass. + +- [ ] **Run full check** + +```bash +./gradlew check +``` +Expected: No failures. + +- [ ] **Commit any ktlint fixes** + +```bash +git add -p +git commit -m "style: apply ktlint formatting to CategoryDetails implementation" +``` + +--- + +## Acceptance Criteria Checklist + +- [ ] AC1: `IsNewDesignEnabled = true` β†’ clicking a category opens the new screen +- [ ] AC2: `IsNewDesignEnabled = false` β†’ clicking a category opens the bottom sheet +- [ ] AC3: Tasks appear in correct sections (Overdue β†’ DueToday β†’ Upcoming β†’ NoDueDate β†’ Completed) +- [ ] AC4: Empty state shown when no tasks exist +- [ ] AC5: Adding a task via AddTaskBar shows it in the list +- [ ] AC6: Adding a task with a due date shows it in the correct section +- [ ] AC7: Completing a task moves it to the Completed section diff --git a/docs/superpowers/specs/2026-03-27-category-details-design.md b/docs/superpowers/specs/2026-03-27-category-details-design.md new file mode 100644 index 000000000..a58c51568 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-category-details-design.md @@ -0,0 +1,417 @@ +# Category Details Screen β€” Design Spec + +**Date:** 2026-03-27 +**Feature branch:** `feature-category-details` +**Source spec:** `docs/devspecs/category-details/CategoryDetails.md` + +--- + +## Overview + +A new full-screen Category Details screen replaces the current bottom sheet when `IsNewDesignEnabled` is `true`. It shows the category header, all associated tasks grouped by state, an empty state when there are no tasks, and a pinned Add Task bar at the bottom. + +The original requirements (`CategoryDetails.md`) define four task sections: Due Today, Upcoming, No Due Date, and Completed. This design intentionally adds a fifth section β€” **Overdue** β€” for tasks whose due date has passed and which are not yet completed. This aligns with the `KuvioTaskItemState.OVERDUE` state already present in the design system and provides clearer urgency signalling to users. + +--- + +## Approach + +Extend the existing `features/category` module. No new feature module is created. Task grouping logic lives in a new domain use case. The `DateTimePicker.kt` file is promoted from `features/task` to `libraries/designsystem` so it can be shared across features. The header becomes a new Kuvio component. + +--- + +## Section 1 β€” Architecture Overview + +``` +libraries/designsystem + └─ components/picker/DateTimePicker.kt (moved from features/task β€” file renamed, function name unchanged) + └─ components/kuvio/header/KuvioCategoryHeader.kt (new) + +domain + └─ model/TaskGroup.kt (new sealed class) + └─ usecase/taskwithcategory/LoadCategoryTasks.kt (new interface) + └─ usecase/taskwithcategory/implementation/LoadCategoryTasksImpl.kt (new) + └─ di/DomainModule.kt (register LoadCategoryTasksImpl) + +features/navigation-api + └─ destination/CategoryDestination.kt (add CategoryDetails data class) + └─ event/CategoryEvent.kt (add nested OnCategoryDetailsClick; OnCategoryClick unchanged) + +features/category (impl) + └─ presentation/detail/CategoryDetailsViewModel.kt (new) + └─ presentation/detail/CategoryDetailsMapper.kt (new) + └─ presentation/detail/CategoryDetailsScreen.kt (new) + └─ navigation/CategoryNavGraph.kt (update category list entry + add CategoryDetails entry) + └─ di/CategoryModule.kt (register ViewModel + Mapper) +``` + +No changes to `KoinHelper.kt` β€” `domainModule` and `categoryModule` are already registered there. +No build file changes needed β€” `features/category/build.gradle.kts` already declares `implementation(projects.libraries.designsystem)`. + +--- + +## Section 2 β€” Domain Layer + +### `TaskGroup` sealed class (`domain/model/`) + +```kotlin +sealed class TaskGroup { + abstract val tasks: List + + data class Overdue(override val tasks: List) : TaskGroup() + data class DueToday(override val tasks: List) : TaskGroup() + data class Upcoming(override val tasks: List) : TaskGroup() + data class NoDueDate(override val tasks: List) : TaskGroup() + data class Completed(override val tasks: List) : TaskGroup() +} +``` + +### `LoadCategoryTasks` use case (`domain/usecase/taskwithcategory/`) + +```kotlin +interface LoadCategoryTasks { + operator fun invoke(categoryId: Long): Flow> +} +``` + +No `internal` modifier β€” domain use case interfaces are always public so they can be resolved across module boundaries by Koin. + +**Implementation rules:** +- Depends on `TaskWithCategoryRepository.findAllTasksWithCategoryId(categoryId)` β€” already exists end-to-end, no data layer changes needed. +- Maps `TaskWithCategory` to `Task` via `.map { it.task }` before applying any grouping logic. +- Injects `DateTimeProvider` for testable date comparisons (never `Clock.System.now()` directly). +- Groups using `dateTimeProvider.getCurrentInstant().toLocalDateTime(TimeZone.UTC).date` as reference. Using `TimeZone.UTC` ensures deterministic date comparisons regardless of the CI machine's timezone. The `DateTimeProviderFake` hardcodes `1993-04-15T16:50:00Z` which maps to `1993-04-15` in UTC. +- **Grouping priority**: check `isCompleted` first β€” a task that is both overdue and completed belongs in `Completed`, not `Overdue`. +- Grouping rules (applied in order): + 1. `isCompleted == true` β†’ `Completed` + 2. `dueDate == null` β†’ `NoDueDate` + 3. `dueDate.date < today` β†’ `Overdue` + 4. `dueDate.date == today` β†’ `DueToday` + 5. `dueDate.date > today` β†’ `Upcoming` +- Emits only **non-empty** groups (empty sections are not shown in UI). +- Section order in emitted list: **Overdue β†’ DueToday β†’ Upcoming β†’ NoDueDate β†’ Completed**. + +**Registration:** Register as `factoryOf(::LoadCategoryTasksImpl) bind LoadCategoryTasks::class` in `domain/di/DomainModule.kt`. Koin auto-wires `TaskWithCategoryRepository` and `DateTimeProvider` from the same module. + +**Data layer:** No changes needed. `findAllTasksWithCategoryId` already exists at all layers (DAO β†’ DataSource β†’ Repository). + +**Test date anchor:** `DateTimeProviderFake` returns a hardcoded date of `1993-04-15T16:50:00Z`. All tasks in `LoadCategoryTasksTest` must be constructed with dates relative to `1993-04-15`: e.g., `dueDate = LocalDate(1993, 4, 14)` for overdue, `LocalDate(1993, 4, 15)` for due today, `LocalDate(1993, 4, 16)` for upcoming. + +--- + +## Section 3 β€” Koin DI + +- Register `LoadCategoryTasksImpl` in `domain/di/DomainModule.kt`: `factoryOf(::LoadCategoryTasksImpl) bind LoadCategoryTasks::class`. +- Register `CategoryDetailsViewModel` and `CategoryDetailsMapper` in `features/category/.../di/CategoryModule.kt`. +- No changes to `KoinHelper.kt` needed β€” `domainModule` and `categoryModule` are already registered. + +--- + +## Section 4 β€” Navigation + +The feature flag check (`DesignSystemConfig.IsNewDesignEnabled`) lives in `CategoryNavGraph.kt` inside `features/category`, which already depends on `libraries/designsystem`. This avoids any dependency change to `features/navigation-api`. + +The NavGraph flag branch (flag-on sends `OnCategoryDetailsClick`, flag-off sends `OnCategoryClick`) is only covered at the E2E level β€” NavGraph entries are not unit-testable. This is intentional and consistent with the rest of the project. + +`DesignSystemConfig.IsNewDesignEnabled` must be changed from `const val` to a mutable `var` to allow E2E tests to toggle it: `var IsNewDesignEnabled: Boolean = false`. Each E2E test that requires it enabled sets it to `true` in `@BeforeTest` and resets it to `false` in `@AfterTest`. + +### New destination in `CategoryDestination.kt` + +```kotlin +@Serializable +data class CategoryDetails(val categoryId: Long) : Destination +``` + +- Implements **neither** `TopLevel` nor `TopAppBarVisible` β€” it is a regular full-screen push destination. + +### New event nested in `CategoryEvent.kt` (inside `object CategoryEvent`) + +```kotlin +object CategoryEvent { + // ... existing events unchanged ... + + data class OnCategoryDetailsClick(val categoryId: Long) : Event { + override fun nextDestination(): Destination = + CategoryDestination.CategoryDetails(categoryId) + } +} +``` + +`OnCategoryClick` is **not modified** β€” it continues to route to `CategoryBottomSheet`. + +### Updated category list entry in `CategoryNavGraph.kt` + +```kotlin +entry( + metadata = NavDisplay.transitionSpec { FadeInTransition } + + NavDisplay.popTransitionSpec { FadeOutTransition } + + NavDisplay.predictivePopTransitionSpec { FadeOutTransition }, +) { + CategoryListSection( + onAddClick = { + navEventController.sendEvent(CategoryEvent.OnNewCategoryClick) + }, + onItemClick = { categoryId: Long? -> + if (DesignSystemConfig.IsNewDesignEnabled && categoryId != null) { + navEventController.sendEvent(CategoryEvent.OnCategoryDetailsClick(categoryId)) + } else { + navEventController.sendEvent(CategoryEvent.OnCategoryClick(categoryId)) + } + }, + ) +} +``` + +Note: `onItemClick` receives `Long?` matching the real `CategoryListSection` signature. The null check guards against the legacy null-categoryId path. + +### New entry for `CategoryDetails` in `CategoryNavGraph.kt` + +```kotlin +entry { backStackEntry -> + val dest = backStackEntry.toRoute() + val isSinglePane = currentWindowAdaptiveInfo().windowSizeClass.isSinglePane() + CategoryDetailsSection( + categoryId = dest.categoryId, + isSinglePane = isSinglePane, + onBackClick = { navEventController.sendEvent(Event.OnBack) }, + onTaskClick = { taskId -> + navEventController.sendEvent(TaskEvent.OnTaskClick(id = taskId)) + }, + ) +} +``` + +`TaskEvent.OnTaskClick(id: Long)` already exists in `features/navigation-api` β€” no changes needed. +`isSinglePane` is computed at the NavGraph entry, consistent with `TaskNavGraph`, `SearchNavGraph`, and `PreferenceNavGraph`. + +**Navigation rules:** +- No `navEventController.sendEvent()` inside composables or ViewModels. +- Back navigation via `Event.OnBack` β€” never a custom event. + +--- + +## Section 5 β€” Design System + +### 1. Move `DateTimePicker` + +Move `features/task/.../alarm/DateTimePicker.kt` β†’ `libraries/designsystem/.../components/picker/DateTimePicker.kt`. + +The composable function inside is named `DateTimerPicker` (with a trailing 'r') β€” this is pre-existing. The file is moved without renaming the function. Update the import in the existing task detail screen. No behavioral changes. + +Note: `DateTimerPicker` retains its direct use of `Clock.System.now()` for UI-layer date initialisation. The `DateTimeProvider` injection rule applies only to domain use cases, not to Compose UI components. + +### 2. New `KuvioCategoryHeader` component + +**Location:** `libraries/designsystem/.../components/kuvio/header/KuvioCategoryHeader.kt` + +**Signature:** +```kotlin +@Composable +fun KuvioCategoryHeader( + name: String, + color: Color, + totalTasks: Int, + completedTasks: Int, + onOptionsClick: () -> Unit, + modifier: Modifier = Modifier, + emoji: String? = null, +) +``` + +The `color: androidx.compose.ui.graphics.Color` parameter receives a Compose Color converted from the view-layer `Category.color: Int` (ARGB) via `Color(category.color)`. This conversion is done in `CategoryDetailsMapper` (not in the composable). This is an intentional use of a category-specific custom color for the emoji placeholder background tint β€” not a theme token. + +**Structure:** +- Emoji placeholder: colored rounded square (`color` at 15% alpha) with a placeholder icon. The `emoji` parameter is accepted but ignored until the `Category` domain model supports it. +- Name: `KuvioTitleLargeText` +- Progress: `KuvioBodyMediumText` β€” uses a format string resource in `strings.xml` (e.g., `category_header_progress` = `"%1$d tasks Β· %2$d completed"`) with `totalTasks` and `completedTasks` as arguments. Not a plural string β€” entries go in `strings.xml` only (not `plurals.xml`). Localization via `localization` skill in all four language files. Total includes completed tasks. +- Three-dot overflow icon: `KuvioIcons` vertical dots, calls `onOptionsClick` β€” no dropdown built yet (out of scope per spec). + +**Kuvio rules:** +- Colors via `MaterialTheme.colorScheme.*` except the category tint (`color` param) which is a user-chosen custom color. +- All text via Kuvio text primitives β€” no raw `Text()`. +- Light + dark `@Preview` required. +- Max ~60 lines; extract private sub-composables if needed. +- All user-visible strings via `localization` skill β€” entries required in all four language files (en, es, fr, pt-rBR). + +--- + +## Section 6 β€” Presentation + +### `CategoryDetailsState` (sealed class) + +```kotlin +sealed class CategoryDetailsState { + data object Loading : CategoryDetailsState() + data class Error(val throwable: Throwable) : CategoryDetailsState() + data class Success( + val category: Category, + val categoryColor: Color, + val groups: List, + val totalTasks: Int, + val completedTasks: Int, + ) : CategoryDetailsState() +} +``` + +Note: `categoryColor: androidx.compose.ui.graphics.Color` is pre-converted by `CategoryDetailsMapper` (which internally uses `CategoryMapper.toView()` + `Color(viewCategory.color)`). Composables never do color conversions. `category: com.escodro.categoryapi.model.Category` (view-layer type, not domain type). + +### `CategoryDetailsMapper` + +Dedicated mapper class injected into the ViewModel alongside `CategoryMapper`. `TaskGroup` is a domain model used directly in the view layer. The mapper has two responsibilities: + +1. **`toViewState(domainCategory: DomainCategory, groups: List): CategoryDetailsState.Success`** β€” where `DomainCategory` is `com.escodro.domain.model.Category` (returned by `LoadCategory`). Uses the injected `CategoryMapper.toView(domainCategory)` to convert to `com.escodro.categoryapi.model.Category` (view type, `color: Int`). Then produces `categoryColor = androidx.compose.ui.graphics.Color(viewCategory.color)`. Computes `totalTasks` and `completedTasks`. Sets `Success.category` to the view-layer `Category`. `CategoryDetailsState.Success.category` is always `com.escodro.categoryapi.model.Category`. + +2. **`toTask(title: String, dueDate: LocalDateTime?, categoryId: Long): Task`** β€” constructs a `com.escodro.domain.model.Task` for `addTask`. Always sets `title` and `categoryId` explicitly. Relies on `Task` constructor defaults for all other fields β€” cross-reference `domain/model/Task.kt` directly. Never assumes a default for `categoryId` since it must always be the current screen's category. + +### `CategoryDetailsViewModel` + +**State exposure:** The ViewModel exposes `state: Flow` built inside `loadContent(categoryId: Long)`, called by `CategoryDetailsSection`: + +```kotlin +fun loadContent(categoryId: Long): Flow = combine( + flow { emit(loadCategory(categoryId)) }, + loadCategoryTasks(categoryId), +) { category, groups -> + if (category == null) CategoryDetailsState.Error(IllegalStateException("Category not found")) + else mapper.toViewState(category, groups) +}.catch { e -> + emit(CategoryDetailsState.Error(e)) +} +``` + +- `LoadCategory` returns `Category?` (suspend, not Flow) β€” wrap with `flow { emit(...) }` before combining. +- `.catch { e -> emit(CategoryDetailsState.Error(e)) }` handles exceptions from either upstream. +- `CategoryDetailsSection` collects state via: `val state by remember(categoryId) { viewModel.loadContent(categoryId) }.collectAsState(initial = CategoryDetailsState.Loading)`. The `remember(categoryId)` block prevents re-subscribing to the flow on every recomposition. + +**Mutations:** +- `fun addTask(title: String, dueDate: LocalDateTime?)` β€” no-op if `title.isBlank()`. Builds a `Task` via `mapper.toTask(title, dueDate, categoryId)` then calls `AddTask`. Uses `applicationScope.launch { }`. +- `fun updateTaskStatus(taskId: Long)` β€” calls `UpdateTaskStatus(taskId)`. Uses `applicationScope.launch { }`. + +**Constructor injections:** `LoadCategory`, `LoadCategoryTasks`, `AddTask`, `UpdateTaskStatus`, `CategoryDetailsMapper`, `AppCoroutineScope`. + +`categoryId: Long` is **not** a constructor parameter. Following the `CategoryEditViewModel` pattern, `categoryId` is passed via a `loadContent(categoryId: Long)` method called from `CategoryDetailsSection` after creation. This avoids `parametersOf` in Koin, and the ViewModel is registered as `viewModelOf(::CategoryDetailsViewModel)` in `CategoryModule` β€” consistent with all other ViewModels in the category module. + +Note: `CompleteTask` is a **concrete class** (not an interface) and cannot be faked for unit testing. `UpdateTaskStatus` (`domain/usecase/task/UpdateTaskStatus.kt`) is the interface to use β€” it toggles task completion state and is fully fakeable. + +### Test fakes required + +Create these fakes in `features/category/src/commonTest/kotlin/com/escodro/category/fake/`: + +| Fake | Interface | Notes | +|------|-----------|-------| +| `AddTaskFake` | `AddTask` | Do not share from `features/task` β€” would create an upward dependency | +| `UpdateTaskStatusFake` | `UpdateTaskStatus` | New | +| `LoadCategoryTasksFake` | `LoadCategoryTasks` | New | + +`LoadCategoryFake` already exists in `features/category/commonTest`. Add a `clear()` method: `fun clear() { categoryToBeReturned = null }`. Call `clear()` from `@BeforeTest` on every fake, matching the convention used by `AddCategoryFake` and all new fakes. + +### `CategoryDetailsScreen` (three-layer pattern) + +| Layer | Visibility | Responsibility | +|-------|-----------|----------------| +| `CategoryDetailsSection` | public | Injects ViewModel via `koinViewModel()`, collects `state` flow, passes to Screen | +| `CategoryDetailsScreen` | internal | Stateless; receives state + callbacks; no injection | +| `CategoryDetailsContent` | internal | Renders the actual UI; tested with Compose Testing | + +**State rendering in `CategoryDetailsScreen`:** +- `Loading` β†’ centered `CircularProgressIndicator` (or Kuvio loading composable if one exists) +- `Error` β†’ Kuvio-styled error message with retry option (or simple error text if no retry composable exists) +- `Success` β†’ delegates to `CategoryDetailsContent` + +**UI structure of `CategoryDetailsContent`:** +- `KuvioCategoryHeader` at the top of the screen. +- `LazyColumn` with task group sections: + - Each section: section label (`KuvioLabelMediumText`, uppercase) + task count + horizontal divider line. + - Each task row: `KuvioTaskItem` with appropriate `KuvioTaskItemState` (`PENDING`, `COMPLETED`, `OVERDUE`). Tapping the checkbox calls `onUpdateTaskStatus(taskId)`. Tapping the row calls `onTaskClick(taskId)`. +- **Empty state**: shown when `groups` is empty β€” Kuvio-styled illustration + message. +- `KuvioAddTaskBar` pinned at the bottom outside the scroll area (not inside `LazyColumn`). +- `DateTimerPicker` dialog shown when user taps the calendar icon on `KuvioAddTaskBar`. + +**Callbacks passed into `CategoryDetailsContent`:** +- `onAddTask(title: String, dueDate: LocalDateTime?)` +- `onUpdateTaskStatus(taskId: Long)` +- `onTaskClick(taskId: Long)` +- `onOptionsClick()` +- `onBackClick()` + +**Composable rules:** +- `koinViewModel()` / `koinInject()` only inside `CategoryDetailsSection` (the Loader). +- Every rendering composable has `modifier: Modifier = Modifier`. +- All paddings are multiples of 4 dp. +- Both dark and light `@Preview` for every meaningful composable variant. +- All strings via `localization` skill β€” no hardcoded strings. +- No raw `Text()` β€” use Kuvio text primitives. + +--- + +## Section 7 β€” Tests + +### Unit tests β€” `LoadCategoryTasksTest` (`domain/commonTest`) + +Using `DateTimeProviderFake` and `TaskWithCategoryRepositoryFake`: + +- `` `test if overdue tasks are grouped in the overdue section` `` +- `` `test if tasks due today are grouped in the due today section` `` +- `` `test if upcoming tasks are grouped in the upcoming section` `` +- `` `test if tasks without due date are grouped in the no due date section` `` +- `` `test if completed tasks are grouped in the completed section` `` +- `` `test if completed tasks with past due date are grouped in completed not overdue` `` +- `` `test if empty sections are not included in the result` `` + +### Unit tests for routing β€” `CategoryEventTest` (`features/category/commonTest`) + +(`features/navigation-api` has no `commonTest` source set β€” place routing tests in `features/category/commonTest` alongside other category tests.) + +- `` `test if on category details click returns category details destination` `` +- `` `test if on category click returns category bottom sheet destination` `` + +### Unit tests β€” `CategoryDetailsViewModelTest` (`features/category/commonTest`) + +Construct ViewModel at field level. `@BeforeTest` calls `clear()` on all fakes. + +- `` `test if success state is emitted when category and tasks load` `` +- `` `test if total and completed task counts are correct` `` +- `` `test if adding a task assigns the correct category id` `` +- `` `test if blank title does not trigger add task` `` +- `` `test if update task status triggers the use case` `` +- `` `test if state re-emits after task status update` `` +- `` `test if error state is emitted when loading fails` `` + +### UI tests β€” `CategoryDetailsScreenTest` (`features/category/commonTest`) + +Extends `AlkaaTest()`. Snake_case naming. Each test body uses `runComposeUiTest { }`. Composable wrapped in `AlkaaThemePreview`. Strings via `runBlocking { getString(...) }`. `@AfterTest` calls `clear()` on every fake. + +- `test_taskGroupsAreShown()` +- `test_emptyStateIsShownWhenNoTasks()` +- `test_correctSectionHeadersAreDisplayed()` +- `test_taskMovesToCompletedSectionOnComplete()` +- `test_clickingTaskNavigatesToTaskDetail()` + +### E2E tests β€” `CategoryFlowTest` (`shared/commonTest`) + +Extends `AlkaaTest(), KoinTest`. Data seeded via DAO in `@BeforeTest`. Entry point: `uiTest { }`. `@AfterTest` calls `afterTest()`. + +- `when_category_is_clicked_and_flag_enabled_then_details_screen_is_shown()` +- `when_category_is_clicked_and_flag_disabled_then_bottom_sheet_is_shown()` +- `when_task_is_added_then_it_appears_in_the_list()` +- `when_task_with_due_date_is_added_then_it_appears_in_correct_section()` +- `when_task_is_completed_then_it_moves_to_completed_section()` + +--- + +## Out of Scope + +- Emoji field on `Category` domain model β€” `emoji` param accepted in `KuvioCategoryHeader` but placeholder shown only. +- Rename and Delete actions from the overflow menu β€” button present but no action. + +--- + +## Acceptance Criteria + +1. Given `IsNewDesignEnabled = true`, when the user clicks a category, the new screen is shown. +2. Given `IsNewDesignEnabled = false`, when the user clicks a category, the bottom sheet is shown (backward compatibility). +3. Given the user has tasks, all tasks appear in their respective state sections (Overdue β†’ DueToday β†’ Upcoming β†’ NoDueDate β†’ Completed). +4. Given the user has no tasks, an empty state message is shown. +5. Given the user adds a task, it appears in the list. +6. Given the user adds a task with a due date, it appears in the correct section. +7. Given the user completes a task, it moves to the Completed section. diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index f0baa6e53..7a4935bf8 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -18,6 +18,7 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) + implementation(projects.libraries.test) } } diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/di/DomainModule.kt b/domain/src/commonMain/kotlin/com/escodro/domain/di/DomainModule.kt index 26e8bb2fd..4505e3d6a 100644 --- a/domain/src/commonMain/kotlin/com/escodro/domain/di/DomainModule.kt +++ b/domain/src/commonMain/kotlin/com/escodro/domain/di/DomainModule.kt @@ -45,8 +45,10 @@ import com.escodro.domain.usecase.task.implementation.UpdateTaskDescriptionImpl import com.escodro.domain.usecase.task.implementation.UpdateTaskImpl import com.escodro.domain.usecase.task.implementation.UpdateTaskStatusImpl import com.escodro.domain.usecase.task.implementation.UpdateTaskTitleImpl +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks import com.escodro.domain.usecase.taskwithcategory.LoadCompletedTasks import com.escodro.domain.usecase.taskwithcategory.LoadUncompletedTasks +import com.escodro.domain.usecase.taskwithcategory.implementation.LoadCategoryTasksImpl import com.escodro.domain.usecase.taskwithcategory.implementation.LoadUncompletedTasksImpl import com.escodro.domain.usecase.tracker.LoadCompletedTasksByPeriod import com.escodro.domain.usecase.tracker.implementation.LoadCompletedTasksByPeriodImpl @@ -104,6 +106,7 @@ val domainModule = module { // Task With Category Use Cases factoryOf(::LoadCompletedTasks) factoryOf(::LoadUncompletedTasksImpl) bind LoadUncompletedTasks::class + factoryOf(::LoadCategoryTasksImpl) bind LoadCategoryTasks::class // Alarm Use Cases factoryOf(::CancelAlarmImpl) bind CancelAlarm::class diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/model/TaskGroup.kt b/domain/src/commonMain/kotlin/com/escodro/domain/model/TaskGroup.kt new file mode 100644 index 000000000..89cdf116c --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/model/TaskGroup.kt @@ -0,0 +1,15 @@ +package com.escodro.domain.model + +sealed class TaskGroup { + abstract val tasks: List + + data class Overdue(override val tasks: List) : TaskGroup() + + data class DueToday(override val tasks: List) : TaskGroup() + + data class Upcoming(override val tasks: List) : TaskGroup() + + data class NoDueDate(override val tasks: List) : TaskGroup() + + data class Completed(override val tasks: List) : TaskGroup() +} diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasks.kt b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasks.kt new file mode 100644 index 000000000..49d775e77 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasks.kt @@ -0,0 +1,8 @@ +package com.escodro.domain.usecase.taskwithcategory + +import com.escodro.domain.model.TaskGroup +import kotlinx.coroutines.flow.Flow + +interface LoadCategoryTasks { + operator fun invoke(categoryId: Long): Flow> +} diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/usecase/taskwithcategory/implementation/LoadCategoryTasksImpl.kt b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/taskwithcategory/implementation/LoadCategoryTasksImpl.kt new file mode 100644 index 000000000..27991b03c --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/taskwithcategory/implementation/LoadCategoryTasksImpl.kt @@ -0,0 +1,41 @@ +package com.escodro.domain.usecase.taskwithcategory.implementation + +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.domain.provider.DateTimeProvider +import com.escodro.domain.repository.TaskWithCategoryRepository +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.LocalDate + +internal class LoadCategoryTasksImpl( + private val repository: TaskWithCategoryRepository, + private val dateTimeProvider: DateTimeProvider, +) : LoadCategoryTasks { + + override fun invoke(categoryId: Long): Flow> = + repository.findAllTasksWithCategoryId(categoryId).map { list -> + val tasks = list.map { it.task } + val today = dateTimeProvider.getCurrentLocalDateTime().date + buildGroups(tasks, today) + } + + private fun buildGroups(tasks: List, today: LocalDate): List { + val completed = tasks.filter { it.isCompleted } + val active = tasks.filter { !it.isCompleted } + + val noDueDate = active.filter { it.dueDate == null } + val overdue = active.filter { it.dueDate != null && it.dueDate.date < today } + val dueToday = active.filter { it.dueDate != null && it.dueDate.date == today } + val upcoming = active.filter { it.dueDate != null && it.dueDate.date > today } + + return listOfNotNull( + TaskGroup.Overdue(overdue).takeIf { overdue.isNotEmpty() }, + TaskGroup.DueToday(dueToday).takeIf { dueToday.isNotEmpty() }, + TaskGroup.Upcoming(upcoming).takeIf { upcoming.isNotEmpty() }, + TaskGroup.NoDueDate(noDueDate).takeIf { noDueDate.isNotEmpty() }, + TaskGroup.Completed(completed).takeIf { completed.isNotEmpty() }, + ) + } +} diff --git a/domain/src/commonTest/kotlin/com/escodro/domain/usecase/fake/TaskWithCategoryRepositoryFake.kt b/domain/src/commonTest/kotlin/com/escodro/domain/usecase/fake/TaskWithCategoryRepositoryFake.kt index 735cdb781..659f28cfd 100644 --- a/domain/src/commonTest/kotlin/com/escodro/domain/usecase/fake/TaskWithCategoryRepositoryFake.kt +++ b/domain/src/commonTest/kotlin/com/escodro/domain/usecase/fake/TaskWithCategoryRepositoryFake.kt @@ -4,36 +4,50 @@ import com.escodro.domain.model.Category import com.escodro.domain.model.TaskWithCategory import com.escodro.domain.repository.TaskWithCategoryRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map internal class TaskWithCategoryRepositoryFake( - private val taskRepository: TaskRepositoryFake, - private val categoryRepository: CategoryRepositoryFake, + private val taskRepository: TaskRepositoryFake? = null, + private val categoryRepository: CategoryRepositoryFake? = null, ) : TaskWithCategoryRepository { - override fun findAllTasksWithCategory(): Flow> = - flow { - val tasks = taskRepository - .findAllTasks() - .map { task -> TaskWithCategory(task, findCategory(task.categoryId)) } + private val taskWithCategoryList = MutableStateFlow>(emptyList()) - emit(tasks) - } + fun insertTaskWithCategory(taskWithCategory: TaskWithCategory) { + taskWithCategoryList.value = taskWithCategoryList.value + taskWithCategory + } + + fun clear() { + taskWithCategoryList.value = emptyList() + } - override fun findAllTasksWithCategoryId(categoryId: Long): Flow> = - flow { - val tasks = taskRepository - .findAllTasks() - .map { task -> TaskWithCategory(task, findCategory(task.categoryId)) } - .filter { it.category?.id == categoryId } + override fun findAllTasksWithCategory(): Flow> { + if (taskRepository != null && categoryRepository != null) { + return flow { + val tasks = taskRepository.findAllTasks() + .map { task -> TaskWithCategory(task, findCategory(task.categoryId)) } + emit(tasks) + } + } + return taskWithCategoryList + } - emit(tasks) + override fun findAllTasksWithCategoryId(categoryId: Long): Flow> { + if (taskRepository != null && categoryRepository != null) { + return flow { + val tasks = taskRepository.findAllTasks() + .map { task -> TaskWithCategory(task, findCategory(task.categoryId)) } + .filter { it.task.categoryId == categoryId } + emit(tasks) + } } + return taskWithCategoryList.map { list -> list.filter { it.task.categoryId == categoryId } } + } private suspend fun findCategory(categoryId: Long?): Category? { - if (categoryId == null) { - return null - } + if (categoryId == null || categoryRepository == null) return null return categoryRepository.findCategoryById(categoryId) } } diff --git a/domain/src/commonTest/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasksTest.kt b/domain/src/commonTest/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasksTest.kt new file mode 100644 index 000000000..188e905bd --- /dev/null +++ b/domain/src/commonTest/kotlin/com/escodro/domain/usecase/taskwithcategory/LoadCategoryTasksTest.kt @@ -0,0 +1,148 @@ +package com.escodro.domain.usecase.taskwithcategory + +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.domain.model.TaskWithCategory +import com.escodro.domain.usecase.fake.DateTimeProviderFake +import com.escodro.domain.usecase.fake.TaskWithCategoryRepositoryFake +import com.escodro.domain.usecase.taskwithcategory.implementation.LoadCategoryTasksImpl +import com.escodro.test.rule.CoroutinesTestDispatcher +import com.escodro.test.rule.CoroutinesTestDispatcherImpl +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class LoadCategoryTasksTest : + CoroutinesTestDispatcher by CoroutinesTestDispatcherImpl() { + + private val repository = TaskWithCategoryRepositoryFake() + private val dateProvider = DateTimeProviderFake() + private val useCase = LoadCategoryTasksImpl( + repository = repository, + dateTimeProvider = dateProvider, + ) + + @BeforeTest + fun setup() { + repository.clear() + } + + // Overdue = due date before 1993-04-15 + @Test + fun `test if overdue tasks are grouped in the overdue section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = LocalDate(year = 1993, month = Month.APRIL, day = 14)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Overdue }) + assertEquals(expected = 1, actual = groups.filterIsInstance().first().tasks.size) + } + + @Test + fun `test if tasks due today are grouped in the due today section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = LocalDate(year = 1993, month = Month.APRIL, day = 15)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.DueToday }) + } + + @Test + fun `test if upcoming tasks are grouped in the upcoming section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = LocalDate(year = 1993, month = Month.APRIL, day = 16)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Upcoming }) + } + + @Test + fun `test if tasks without due date are grouped in the no due date section`() = runTest { + // Given + val task = buildTask(id = 1L, dueDate = null) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.NoDueDate }) + } + + @Test + fun `test if completed tasks are grouped in the completed section`() = runTest { + // Given + val task = buildTask(id = 1L, isCompleted = true) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Completed }) + } + + @Test + fun `test if completed tasks with past due date are grouped in completed not overdue`() = runTest { + // Given β€” task is both overdue AND completed; should go to Completed + val overdueDate = LocalDate(year = 1993, month = Month.APRIL, day = 14) + val task = buildTask(id = 1L, dueDate = overdueDate, isCompleted = true) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then + assertTrue(groups.any { it is TaskGroup.Completed }) + assertTrue(groups.none { it is TaskGroup.Overdue }) + } + + @Test + fun `test if empty sections are not included in the result`() = runTest { + // Given β€” one overdue task only + val task = buildTask(id = 1L, dueDate = LocalDate(year = 1993, month = Month.APRIL, day = 14)) + repository.insertTaskWithCategory(buildTaskWithCategory(task)) + + // When + val groups = useCase(categoryId = 1L).first() + + // Then β€” only Overdue is emitted; other sections absent + assertEquals(expected = 1, actual = groups.size) + assertTrue(groups.first() is TaskGroup.Overdue) + } + + // Helpers + private fun buildTask( + id: Long, + dueDate: LocalDate? = null, + isCompleted: Boolean = false, + ) = Task( + id = id, + title = "Task $id", + categoryId = 1L, + isCompleted = isCompleted, + dueDate = dueDate?.let { LocalDateTime(it, LocalTime(hour = 9, minute = 0)) }, + ) + + private fun buildTaskWithCategory(task: Task) = + TaskWithCategory(task = task, category = null) +} diff --git a/features/category/build.gradle.kts b/features/category/build.gradle.kts index 93b52c13b..a213e49a1 100644 --- a/features/category/build.gradle.kts +++ b/features/category/build.gradle.kts @@ -11,6 +11,8 @@ kotlin { configureTargets("category") sourceSets { + val desktopTest by getting + commonMain.dependencies { implementation(projects.features.categoryApi) implementation(projects.domain) @@ -23,16 +25,24 @@ kotlin { implementation(libs.compose.runtime) implementation(libs.compose.material3) implementation(libs.compose.materialIconsExtended) + implementation(libs.compose.uiToolingPreview) implementation(libs.compose.components.resources) implementation(libs.koin.compose) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) implementation(libs.androidx.lifecycle.viewmodel) } commonTest.dependencies { implementation(kotlin("test")) implementation(projects.libraries.test) + implementation(libs.compose.uiTest) + } + + desktopTest.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutines.swing) } } @@ -40,3 +50,7 @@ kotlin { namespace = "com.escodro.category" } } + +dependencies { + "androidRuntimeClasspath"(libs.compose.uiTooling) +} diff --git a/features/category/src/commonMain/kotlin/com/escodro/category/di/CategoryModule.kt b/features/category/src/commonMain/kotlin/com/escodro/category/di/CategoryModule.kt index 43391dd4c..9fe5fa22b 100644 --- a/features/category/src/commonMain/kotlin/com/escodro/category/di/CategoryModule.kt +++ b/features/category/src/commonMain/kotlin/com/escodro/category/di/CategoryModule.kt @@ -4,6 +4,8 @@ import com.escodro.category.mapper.CategoryMapper import com.escodro.category.navigation.CategoryNavGraph import com.escodro.category.presentation.bottomsheet.CategoryAddViewModel import com.escodro.category.presentation.bottomsheet.CategoryEditViewModel +import com.escodro.category.presentation.detail.CategoryDetailsMapper +import com.escodro.category.presentation.detail.CategoryDetailsViewModel import com.escodro.category.presentation.list.CategoryListViewModelImpl import com.escodro.categoryapi.presentation.CategoryListViewModel import com.escodro.navigationapi.provider.NavGraph @@ -19,9 +21,11 @@ val categoryModule = module { viewModelOf(::CategoryListViewModelImpl) bind CategoryListViewModel::class viewModelOf(::CategoryAddViewModel) viewModelOf(::CategoryEditViewModel) + viewModelOf(::CategoryDetailsViewModel) // Mapper factoryOf(::CategoryMapper) + factoryOf(::CategoryDetailsMapper) // Navigation factoryOf(::CategoryNavGraph) bind NavGraph::class diff --git a/features/category/src/commonMain/kotlin/com/escodro/category/navigation/CategoryNavGraph.kt b/features/category/src/commonMain/kotlin/com/escodro/category/navigation/CategoryNavGraph.kt index f61107a41..f04b91058 100644 --- a/features/category/src/commonMain/kotlin/com/escodro/category/navigation/CategoryNavGraph.kt +++ b/features/category/src/commonMain/kotlin/com/escodro/category/navigation/CategoryNavGraph.kt @@ -4,15 +4,20 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.scene.DialogSceneStrategy import androidx.navigation3.ui.NavDisplay import com.escodro.category.presentation.bottomsheet.CategoryBottomSheet +import com.escodro.category.presentation.detail.CategoryDetailsSection import com.escodro.category.presentation.list.CategoryListSection import com.escodro.designsystem.animation.FadeInTransition import com.escodro.designsystem.animation.FadeOutTransition +import com.escodro.designsystem.animation.SlideInHorizontallyTransition +import com.escodro.designsystem.animation.SlideOutHorizontallyTransition +import com.escodro.designsystem.config.DesignSystemConfig import com.escodro.navigationapi.controller.NavEventController import com.escodro.navigationapi.destination.CategoryDestination import com.escodro.navigationapi.destination.Destination import com.escodro.navigationapi.destination.HomeDestination import com.escodro.navigationapi.event.CategoryEvent import com.escodro.navigationapi.event.Event +import com.escodro.navigationapi.event.TaskEvent import com.escodro.navigationapi.provider.NavGraph internal class CategoryNavGraph : NavGraph { @@ -29,7 +34,13 @@ internal class CategoryNavGraph : NavGraph { navEventController.sendEvent(CategoryEvent.OnNewCategoryClick) }, onItemClick = { categoryId -> - navEventController.sendEvent(CategoryEvent.OnCategoryClick(categoryId)) + if (DesignSystemConfig.isNewDesignEnabled && categoryId != null) { + navEventController.sendEvent( + CategoryEvent.OnCategoryDetailsClick(categoryId), + ) + } else { + navEventController.sendEvent(CategoryEvent.OnCategoryClick(categoryId)) + } }, ) } @@ -42,5 +53,19 @@ internal class CategoryNavGraph : NavGraph { onHideBottomSheet = { navEventController.sendEvent(Event.OnBack) }, ) } + + entry( + metadata = NavDisplay.transitionSpec { SlideInHorizontallyTransition } + + NavDisplay.popTransitionSpec { SlideOutHorizontallyTransition } + + NavDisplay.predictivePopTransitionSpec { SlideOutHorizontallyTransition }, + ) { entry -> + CategoryDetailsSection( + categoryId = entry.categoryId, + onBackClick = { navEventController.sendEvent(Event.OnBack) }, + onTaskClick = { taskId -> + navEventController.sendEvent(TaskEvent.OnTaskClick(id = taskId)) + }, + ) + } } } diff --git a/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsMapper.kt b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsMapper.kt new file mode 100644 index 000000000..1c723e75d --- /dev/null +++ b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsMapper.kt @@ -0,0 +1,42 @@ +package com.escodro.category.presentation.detail + +import androidx.compose.ui.graphics.Color +import com.escodro.category.mapper.CategoryMapper +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import kotlinx.collections.immutable.toImmutableList +import kotlinx.datetime.LocalDateTime +import com.escodro.domain.model.Category as DomainCategory + +internal class CategoryDetailsMapper( + private val categoryMapper: CategoryMapper, +) { + + fun toViewState( + domainCategory: DomainCategory, + groups: List, + ): CategoryDetailsState.Success { + val viewCategory = categoryMapper.toView(domainCategory) + val allTasks = groups.flatMap { it.tasks } + return CategoryDetailsState.Success( + data = CategoryDetailsData( + category = viewCategory, + categoryColor = Color(viewCategory.color), + groups = groups.toImmutableList(), + totalTasks = allTasks.size, + completedTasks = groups.filterIsInstance() + .sumOf { it.tasks.size }, + ), + ) + } + + fun toTask( + title: String, + dueDate: LocalDateTime?, + categoryId: Long, + ): Task = Task( + title = title, + dueDate = dueDate, + categoryId = categoryId, + ) +} diff --git a/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreen.kt b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreen.kt new file mode 100644 index 000000000..a0245a48c --- /dev/null +++ b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreen.kt @@ -0,0 +1,396 @@ +@file:Suppress("TooManyFunctions") // Preview need separate light/dark functions for now + +package com.escodro.category.presentation.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.escodro.categoryapi.model.Category +import com.escodro.designsystem.components.kuvio.bar.KuvioAddTaskBar +import com.escodro.designsystem.components.kuvio.header.KuvioCategoryHeader +import com.escodro.designsystem.components.kuvio.item.KuvioTaskItem +import com.escodro.designsystem.components.kuvio.item.KuvioTaskItemData +import com.escodro.designsystem.components.kuvio.item.KuvioTaskItemState +import com.escodro.designsystem.components.kuvio.text.KuvioBodyMediumText +import com.escodro.designsystem.components.kuvio.text.KuvioTitleMediumText +import com.escodro.designsystem.components.picker.DateTimerPicker +import com.escodro.designsystem.components.topbar.AlkaaToolbar +import com.escodro.designsystem.theme.AlkaaThemePreview +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.resources.Res +import com.escodro.resources.category_details_empty_description +import com.escodro.resources.category_details_empty_title +import com.escodro.resources.category_details_section_completed +import com.escodro.resources.category_details_section_due_today +import com.escodro.resources.category_details_section_no_due_date +import com.escodro.resources.category_details_section_overdue +import com.escodro.resources.category_details_section_upcoming +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.datetime.LocalDateTime +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject + +/** + * Alkaa Category Details Section. + * + * Public entry point for the Category Details screen. Stateless β€” delegates immediately to + * [CategoryDetailsLoader]. + * + * @param categoryId the ID of the category to display. + * @param onBackClick callback invoked when the user navigates back. + * @param onTaskClick callback invoked when the user taps a task item. + * @param modifier modifier applied to the outermost layout. + */ +@Composable +fun CategoryDetailsSection( + categoryId: Long, + onBackClick: () -> Unit, + onTaskClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + CategoryDetailsLoader( + categoryId = categoryId, + onBackClick = onBackClick, + onTaskClick = onTaskClick, + modifier = modifier, + ) +} + +@Composable +internal fun CategoryDetailsLoader( + categoryId: Long, + onBackClick: () -> Unit, + onTaskClick: (Long) -> Unit, + modifier: Modifier = Modifier, + viewModel: CategoryDetailsViewModel = koinInject(), +) { + val state by remember(categoryId) { + viewModel.loadContent(categoryId) + }.collectAsState(initial = CategoryDetailsState.Loading) + + CategoryDetailsScreen( + state = state, + onAddTask = { title, dueDate -> viewModel.addTask(title, dueDate, categoryId) }, + onUpdateTaskStatus = { taskId -> viewModel.updateTaskStatus(taskId) }, + onTaskClick = onTaskClick, + onOptionsClick = {}, // TODO: implement category options menu + onBackClick = onBackClick, + modifier = modifier, + ) +} + +@Composable +@Suppress("LongParameterList") +internal fun CategoryDetailsScreen( + state: CategoryDetailsState, + onAddTask: (String, LocalDateTime?) -> Unit, + onUpdateTaskStatus: (Long) -> Unit, + onTaskClick: (Long) -> Unit, + onOptionsClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + topBar = { + AlkaaToolbar(isSinglePane = true, onUpPress = onBackClick) + }, + modifier = modifier, + ) { paddingValues -> + when (state) { + is CategoryDetailsState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(paddingValues).fillMaxSize(), + ) { + CircularProgressIndicator() + } + } + + is CategoryDetailsState.Error -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(paddingValues).fillMaxSize(), + ) { + KuvioBodyMediumText( + text = state.throwable.message ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + ) + } + } + + is CategoryDetailsState.Success -> { + CategoryDetailsContent( + data = state.data, + onAddTask = onAddTask, + onUpdateTaskStatus = onUpdateTaskStatus, + onTaskClick = onTaskClick, + onOptionsClick = onOptionsClick, + modifier = Modifier.padding(paddingValues), + ) + } + } + } +} + +@Composable +internal fun CategoryDetailsContent( + data: CategoryDetailsData, + onAddTask: (String, LocalDateTime?) -> Unit, + onUpdateTaskStatus: (Long) -> Unit, + onTaskClick: (Long) -> Unit, + onOptionsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var taskTitle by rememberSaveable { mutableStateOf("") } + var selectedDate by remember { mutableStateOf(null) } + var isDatePickerOpen by rememberSaveable { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxSize()) { + KuvioCategoryHeader( + name = data.category.name, + color = data.categoryColor, + totalTasks = data.totalTasks, + completedTasks = data.completedTasks, + onOptionsClick = onOptionsClick, + ) + + if (data.groups.isEmpty()) { + CategoryDetailsEmptyState(modifier = Modifier.weight(1f).fillMaxWidth()) + } else { + CategoryDetailsTaskList( + groups = data.groups, + categoryColor = data.categoryColor, + onUpdateTaskStatus = onUpdateTaskStatus, + onTaskClick = onTaskClick, + modifier = Modifier.weight(1f), + ) + } + + KuvioAddTaskBar( + value = taskTitle, + onValueChange = { taskTitle = it }, + onAddClick = { + onAddTask(taskTitle, selectedDate) + taskTitle = "" + selectedDate = null + }, + onDateClick = { isDatePickerOpen = true }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + + DateTimerPicker( + initialDateTime = selectedDate, + isDialogOpen = isDatePickerOpen, + onCloseDialog = { isDatePickerOpen = false }, + onDateChange = { date -> + selectedDate = date + isDatePickerOpen = false + }, + ) +} + +@Composable +private fun CategoryDetailsEmptyState(modifier: Modifier = Modifier) { + Box(contentAlignment = Alignment.Center, modifier = modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + KuvioTitleMediumText(text = stringResource(Res.string.category_details_empty_title)) + KuvioBodyMediumText( + text = stringResource(Res.string.category_details_empty_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CategoryDetailsTaskList( + groups: ImmutableList, + categoryColor: Color, + onUpdateTaskStatus: (Long) -> Unit, + onTaskClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + groups.forEach { group -> + val taskState = when (group) { + is TaskGroup.Overdue -> { + KuvioTaskItemState.OVERDUE + } + + is TaskGroup.Completed -> { + KuvioTaskItemState.COMPLETED + } + + is TaskGroup.DueToday, + is TaskGroup.Upcoming, + is TaskGroup.NoDueDate, + -> { + KuvioTaskItemState.PENDING + } + } + + if (group.tasks.isNotEmpty()) { + item(key = "header_${group::class.simpleName}") { + val sectionTitle = when (group) { + is TaskGroup.Overdue -> stringResource(Res.string.category_details_section_overdue) + is TaskGroup.DueToday -> stringResource(Res.string.category_details_section_due_today) + is TaskGroup.Upcoming -> stringResource(Res.string.category_details_section_upcoming) + is TaskGroup.NoDueDate -> stringResource(Res.string.category_details_section_no_due_date) + is TaskGroup.Completed -> stringResource(Res.string.category_details_section_completed) + } + KuvioTitleMediumText( + text = sectionTitle, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) + } + + items(items = group.tasks, key = { task -> task.id }) { task -> + KuvioTaskItem( + data = KuvioTaskItemData( + title = task.title, + state = taskState, + categoryColor = categoryColor, + ), + onItemClick = { onTaskClick(task.id) }, + onCheckClick = { onUpdateTaskStatus(task.id) }, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CategoryDetailsContentEmptyLightPreview() { + AlkaaThemePreview { + CategoryDetailsContent( + data = CategoryDetailsData( + category = Category(id = 1L, name = "Work", color = 0xFF6200EA.toInt()), + categoryColor = Color(0xFF6200EA), + groups = persistentListOf(), + totalTasks = 0, + completedTasks = 0, + ), + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F1B2D) +@Composable +private fun CategoryDetailsContentEmptyDarkPreview() { + AlkaaThemePreview(isDarkTheme = true) { + CategoryDetailsContent( + data = CategoryDetailsData( + category = Category(id = 1L, name = "Work", color = 0xFF6200EA.toInt()), + categoryColor = Color(0xFF6200EA), + groups = persistentListOf(), + totalTasks = 0, + completedTasks = 0, + ), + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CategoryDetailsContentWithTasksLightPreview() { + val tasks = listOf( + Task(id = 1L, title = "Design the mockups"), + Task(id = 2L, title = "Review pull request"), + ) + AlkaaThemePreview { + CategoryDetailsContent( + data = CategoryDetailsData( + category = Category(id = 1L, name = "Work", color = 0xFF6200EA.toInt()), + categoryColor = Color(0xFF6200EA), + groups = persistentListOf(TaskGroup.NoDueDate(tasks)), + totalTasks = 2, + completedTasks = 0, + ), + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F1B2D) +@Composable +private fun CategoryDetailsContentWithTasksDarkPreview() { + val tasks = listOf( + Task(id = 1L, title = "Design the mockups"), + Task(id = 2L, title = "Review pull request"), + ) + AlkaaThemePreview(isDarkTheme = true) { + CategoryDetailsContent( + data = CategoryDetailsData( + category = Category(id = 1L, name = "Work", color = 0xFF6200EA.toInt()), + categoryColor = Color(0xFF6200EA), + groups = persistentListOf(TaskGroup.NoDueDate(tasks)), + totalTasks = 2, + completedTasks = 0, + ), + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CategoryDetailsEmptyStateLightPreview() { + AlkaaThemePreview { + CategoryDetailsEmptyState() + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F1B2D) +@Composable +private fun CategoryDetailsEmptyStateDarkPreview() { + AlkaaThemePreview(isDarkTheme = true) { + CategoryDetailsEmptyState() + } +} diff --git a/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsState.kt b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsState.kt new file mode 100644 index 000000000..adf57e14f --- /dev/null +++ b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsState.kt @@ -0,0 +1,24 @@ +package com.escodro.category.presentation.detail + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.escodro.categoryapi.model.Category +import com.escodro.domain.model.TaskGroup +import kotlinx.collections.immutable.ImmutableList + +sealed class CategoryDetailsState { + data object Loading : CategoryDetailsState() + + data class Error(val throwable: Throwable) : CategoryDetailsState() + + data class Success(val data: CategoryDetailsData) : CategoryDetailsState() +} + +@Immutable +data class CategoryDetailsData( + val category: Category, + val categoryColor: Color, + val groups: ImmutableList, + val totalTasks: Int, + val completedTasks: Int, +) diff --git a/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModel.kt b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModel.kt new file mode 100644 index 000000000..01074a60e --- /dev/null +++ b/features/category/src/commonMain/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModel.kt @@ -0,0 +1,49 @@ +package com.escodro.category.presentation.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.escodro.domain.usecase.category.LoadCategory +import com.escodro.domain.usecase.task.AddTask +import com.escodro.domain.usecase.task.UpdateTaskStatus +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDateTime + +internal class CategoryDetailsViewModel( + private val loadCategory: LoadCategory, + private val loadCategoryTasks: LoadCategoryTasks, + private val addTask: AddTask, + private val updateTaskStatus: UpdateTaskStatus, + private val mapper: CategoryDetailsMapper, +) : ViewModel() { + + fun loadContent(categoryId: Long): Flow = combine( + flow { emit(loadCategory(categoryId)) }, + loadCategoryTasks(categoryId), + ) { category, groups -> + if (category == null) { + CategoryDetailsState.Error(IllegalStateException("Category not found")) + } else { + mapper.toViewState(category, groups) + } + }.catch { e -> + emit(CategoryDetailsState.Error(e)) + } + + fun addTask(title: String, dueDate: LocalDateTime?, categoryId: Long) { + if (title.isBlank()) return + viewModelScope.launch { + addTask(mapper.toTask(title, dueDate, categoryId)) + } + } + + fun updateTaskStatus(taskId: Long) { + viewModelScope.launch { + updateTaskStatus.invoke(taskId) + } + } +} diff --git a/features/category/src/commonTest/kotlin/com/escodro/category/event/CategoryEventTest.kt b/features/category/src/commonTest/kotlin/com/escodro/category/event/CategoryEventTest.kt new file mode 100644 index 000000000..3e43c7efa --- /dev/null +++ b/features/category/src/commonTest/kotlin/com/escodro/category/event/CategoryEventTest.kt @@ -0,0 +1,33 @@ +package com.escodro.category.event + +import com.escodro.navigationapi.destination.CategoryDestination +import com.escodro.navigationapi.event.CategoryEvent +import kotlin.test.Test +import kotlin.test.assertIs + +internal class CategoryEventTest { + + @Test + fun `test if on category details click returns category details destination`() { + // Given + val event = CategoryEvent.OnCategoryDetailsClick(categoryId = 42L) + + // When + val destination = event.nextDestination() + + // Then + assertIs(destination) + } + + @Test + fun `test if on category click returns category bottom sheet destination`() { + // Given + val event = CategoryEvent.OnCategoryClick(categoryId = 42L) + + // When + val destination = event.nextDestination() + + // Then + assertIs(destination) + } +} diff --git a/features/category/src/commonTest/kotlin/com/escodro/category/fake/AddTaskFake.kt b/features/category/src/commonTest/kotlin/com/escodro/category/fake/AddTaskFake.kt new file mode 100644 index 000000000..17d2ae592 --- /dev/null +++ b/features/category/src/commonTest/kotlin/com/escodro/category/fake/AddTaskFake.kt @@ -0,0 +1,16 @@ +package com.escodro.category.fake + +import com.escodro.domain.model.Task +import com.escodro.domain.usecase.task.AddTask + +internal class AddTaskFake : AddTask { + val addedTasks = mutableListOf() + + override suspend fun invoke(task: Task) { + addedTasks.add(task) + } + + fun clear() { + addedTasks.clear() + } +} diff --git a/features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryFake.kt b/features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryFake.kt index b1fde3d1c..1f337aa9b 100644 --- a/features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryFake.kt +++ b/features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryFake.kt @@ -9,4 +9,8 @@ internal class LoadCategoryFake : LoadCategory { override suspend fun invoke(categoryId: Long): Category? = categoryToBeReturned + + fun clear() { + categoryToBeReturned = null + } } diff --git a/features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryTasksFake.kt b/features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryTasksFake.kt new file mode 100644 index 000000000..7435816d5 --- /dev/null +++ b/features/category/src/commonTest/kotlin/com/escodro/category/fake/LoadCategoryTasksFake.kt @@ -0,0 +1,20 @@ +package com.escodro.category.fake + +import com.escodro.domain.model.TaskGroup +import com.escodro.domain.usecase.taskwithcategory.LoadCategoryTasks +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class LoadCategoryTasksFake : LoadCategoryTasks { + private val flow = MutableStateFlow>(emptyList()) + + fun emit(groups: List) { + flow.value = groups + } + + override fun invoke(categoryId: Long): Flow> = flow + + fun clear() { + flow.value = emptyList() + } +} diff --git a/features/category/src/commonTest/kotlin/com/escodro/category/fake/UpdateTaskStatusFake.kt b/features/category/src/commonTest/kotlin/com/escodro/category/fake/UpdateTaskStatusFake.kt new file mode 100644 index 000000000..90c9772b8 --- /dev/null +++ b/features/category/src/commonTest/kotlin/com/escodro/category/fake/UpdateTaskStatusFake.kt @@ -0,0 +1,15 @@ +package com.escodro.category.fake + +import com.escodro.domain.usecase.task.UpdateTaskStatus + +internal class UpdateTaskStatusFake : UpdateTaskStatus { + val updatedIds = mutableListOf() + + override suspend fun invoke(taskId: Long) { + updatedIds.add(taskId) + } + + fun clear() { + updatedIds.clear() + } +} diff --git a/features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreenTest.kt b/features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreenTest.kt new file mode 100644 index 000000000..881acc1de --- /dev/null +++ b/features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsScreenTest.kt @@ -0,0 +1,190 @@ +package com.escodro.category.presentation.detail + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import com.escodro.categoryapi.model.Category +import com.escodro.designsystem.theme.AlkaaThemePreview +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.resources.Res +import com.escodro.resources.category_details_empty_title +import com.escodro.resources.category_details_section_completed +import com.escodro.resources.category_details_section_due_today +import com.escodro.resources.category_details_section_no_due_date +import com.escodro.resources.category_details_section_overdue +import com.escodro.resources.category_details_section_upcoming +import com.escodro.test.AlkaaTest +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +internal class CategoryDetailsScreenTest : AlkaaTest() { + + private val testCategory = Category(id = 1L, name = "Work", color = 0xFF6200EA.toInt()) + private val testColor = Color(0xFF6200EA) + + @Test + fun test_emptyStateIsShownWhenNoTasks() = runComposeUiTest { + // Given + val emptyTitle = runBlocking { getString(Res.string.category_details_empty_title) } + + // When + setUpContent( + CategoryDetailsData( + category = testCategory, + categoryColor = testColor, + groups = persistentListOf(), + totalTasks = 0, + completedTasks = 0, + ), + ) + + // Then + onNodeWithText(emptyTitle).assertIsDisplayed() + } + + @Test + fun test_taskGroupsAreShown() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "Buy milk") + + // When + setUpContent( + CategoryDetailsData( + category = testCategory, + categoryColor = testColor, + groups = persistentListOf(TaskGroup.NoDueDate(tasks = listOf(task))), + totalTasks = 1, + completedTasks = 0, + ), + ) + + // Then + onNodeWithText("Buy milk").assertIsDisplayed() + } + + @Test + fun test_overdueSectionHeaderIsDisplayed() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "Overdue task") + val sectionHeader = runBlocking { getString(Res.string.category_details_section_overdue) } + + // When + setUpContent( + CategoryDetailsData( + category = testCategory, + categoryColor = testColor, + groups = persistentListOf(TaskGroup.Overdue(tasks = listOf(task))), + totalTasks = 1, + completedTasks = 0, + ), + ) + + // Then + onNodeWithText(sectionHeader).assertIsDisplayed() + } + + @Test + fun test_dueTodaySectionHeaderIsDisplayed() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "Due today task") + val sectionHeader = runBlocking { getString(Res.string.category_details_section_due_today) } + + // When + setUpContent( + CategoryDetailsData( + category = testCategory, + categoryColor = testColor, + groups = persistentListOf(TaskGroup.DueToday(tasks = listOf(task))), + totalTasks = 1, + completedTasks = 0, + ), + ) + + // Then + onNodeWithText(sectionHeader).assertIsDisplayed() + } + + @Test + fun test_upcomingSectionHeaderIsDisplayed() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "Upcoming task") + val sectionHeader = runBlocking { getString(Res.string.category_details_section_upcoming) } + + // When + setUpContent( + CategoryDetailsData( + category = testCategory, + categoryColor = testColor, + groups = persistentListOf(TaskGroup.Upcoming(tasks = listOf(task))), + totalTasks = 1, + completedTasks = 0, + ), + ) + + // Then + onNodeWithText(sectionHeader).assertIsDisplayed() + } + + @Test + fun test_noDueDateSectionHeaderIsDisplayed() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "No due date task") + val sectionHeader = runBlocking { getString(Res.string.category_details_section_no_due_date) } + + // When + setUpContent( + CategoryDetailsData( + category = testCategory, + categoryColor = testColor, + groups = persistentListOf(TaskGroup.NoDueDate(tasks = listOf(task))), + totalTasks = 1, + completedTasks = 0, + ), + ) + + // Then + onNodeWithText(sectionHeader).assertIsDisplayed() + } + + @Test + fun test_completedSectionHeaderIsDisplayed() = runComposeUiTest { + // Given + val task = Task(id = 1L, title = "Completed task", isCompleted = true) + val sectionHeader = runBlocking { getString(Res.string.category_details_section_completed) } + + // When + setUpContent( + CategoryDetailsData( + category = testCategory, + categoryColor = testColor, + groups = persistentListOf(TaskGroup.Completed(tasks = listOf(task))), + totalTasks = 1, + completedTasks = 1, + ), + ) + + // Then + onNodeWithText(sectionHeader).assertIsDisplayed() + } + + private fun ComposeUiTest.setUpContent(data: CategoryDetailsData) { + setContent { + AlkaaThemePreview { + CategoryDetailsContent( + data = data, + onAddTask = { _, _ -> }, + onUpdateTaskStatus = {}, + onTaskClick = {}, + onOptionsClick = {}, + ) + } + } + } +} diff --git a/features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModelTest.kt b/features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModelTest.kt new file mode 100644 index 000000000..0a77593c9 --- /dev/null +++ b/features/category/src/commonTest/kotlin/com/escodro/category/presentation/detail/CategoryDetailsViewModelTest.kt @@ -0,0 +1,145 @@ +package com.escodro.category.presentation.detail + +import com.escodro.category.fake.AddTaskFake +import com.escodro.category.fake.LoadCategoryFake +import com.escodro.category.fake.LoadCategoryTasksFake +import com.escodro.category.fake.UpdateTaskStatusFake +import com.escodro.category.mapper.CategoryMapper +import com.escodro.domain.model.Category +import com.escodro.domain.model.Task +import com.escodro.domain.model.TaskGroup +import com.escodro.test.rule.CoroutinesTestDispatcher +import com.escodro.test.rule.CoroutinesTestDispatcherImpl +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +internal class CategoryDetailsViewModelTest : + CoroutinesTestDispatcher by CoroutinesTestDispatcherImpl() { + + private val loadCategoryFake = LoadCategoryFake() + private val loadCategoryTasksFake = LoadCategoryTasksFake() + private val addTaskFake = AddTaskFake() + private val updateTaskStatusFake = UpdateTaskStatusFake() + private val mapper = CategoryDetailsMapper(CategoryMapper()) + + private val viewModel = CategoryDetailsViewModel( + loadCategory = loadCategoryFake, + loadCategoryTasks = loadCategoryTasksFake, + addTask = addTaskFake, + updateTaskStatus = updateTaskStatusFake, + mapper = mapper, + ) + + @BeforeTest + fun setup() { + loadCategoryFake.clear() + loadCategoryTasksFake.clear() + addTaskFake.clear() + updateTaskStatusFake.clear() + } + + @Test + fun `test if success state is emitted when category and tasks load`() = runTest { + // Given + loadCategoryFake.categoryToBeReturned = Category(id = 1L, name = "Work", color = "#FF0000") + loadCategoryTasksFake.emit(emptyList()) + + // When + val state = viewModel.loadContent(categoryId = 1L).first() + + // Then + assertIs(state) + } + + @Test + fun `test if total and completed task counts are correct`() = runTest { + // Given + loadCategoryFake.categoryToBeReturned = Category(id = 1L, name = "Work", color = "#FF0000") + val completedTask = Task(id = 1L, title = "Done", isCompleted = true) + val pendingTask = Task(id = 2L, title = "Todo") + loadCategoryTasksFake.emit( + listOf( + TaskGroup.NoDueDate(tasks = listOf(pendingTask)), + TaskGroup.Completed(tasks = listOf(completedTask)), + ), + ) + + // When + val state = viewModel.loadContent(categoryId = 1L).first() + require(state is CategoryDetailsState.Success) + + // Then + assertEquals(expected = 2, actual = state.data.totalTasks) + assertEquals(expected = 1, actual = state.data.completedTasks) + } + + @Test + fun `test if adding a task assigns the correct category id`() = runTest { + // Given + val categoryId = 42L + + // When + viewModel.addTask(title = "New task", dueDate = null, categoryId = categoryId) + + // Then + assertEquals(expected = 1, actual = addTaskFake.addedTasks.size) + assertEquals(expected = categoryId, actual = addTaskFake.addedTasks.first().categoryId) + } + + @Test + fun `test if blank title does not trigger add task`() = runTest { + // Given / When + viewModel.addTask(title = " ", dueDate = null, categoryId = 1L) + + // Then + assertTrue(addTaskFake.addedTasks.isEmpty()) + } + + @Test + fun `test if update task status triggers the use case`() = runTest { + // When + viewModel.updateTaskStatus(taskId = 7L) + + // Then + assertTrue(updateTaskStatusFake.updatedIds.contains(7L)) + } + + @Test + fun `test if error state is emitted when loading fails`() = runTest { + // Given β€” category not found + loadCategoryFake.categoryToBeReturned = null + loadCategoryTasksFake.emit(emptyList()) + + // When + val state = viewModel.loadContent(categoryId = 1L).first() + + // Then + assertIs(state) + } + + @Test + fun `test if state re-emits after task status update`() = runTest { + // Given β€” first collection with pending task + loadCategoryFake.categoryToBeReturned = Category(id = 1L, name = "Work", color = "#FF0000") + val task = Task(id = 1L, title = "Task 1") + loadCategoryTasksFake.emit(listOf(TaskGroup.NoDueDate(tasks = listOf(task)))) + val firstState = viewModel.loadContent(categoryId = 1L).first() + require(firstState is CategoryDetailsState.Success) + assertEquals(expected = 0, actual = firstState.data.completedTasks) + + // When β€” simulate DB re-emission after task status update + loadCategoryTasksFake.emit( + listOf(TaskGroup.Completed(tasks = listOf(task.copy(isCompleted = true)))), + ) + + // Then β€” new collection reflects the updated completion state + val newState = viewModel.loadContent(categoryId = 1L).first() + require(newState is CategoryDetailsState.Success) + assertEquals(expected = 1, actual = newState.data.completedTasks) + } +} diff --git a/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/destination/CategoryDestination.kt b/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/destination/CategoryDestination.kt index 7b4d96410..4b2ae6f6c 100644 --- a/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/destination/CategoryDestination.kt +++ b/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/destination/CategoryDestination.kt @@ -7,4 +7,7 @@ object CategoryDestination { @Serializable data class CategoryBottomSheet(val categoryId: Long?) : Destination, TopAppBarVisible + + @Serializable + data class CategoryDetails(val categoryId: Long) : Destination } diff --git a/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/event/CategoryEvent.kt b/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/event/CategoryEvent.kt index ecc6f8111..b43e35f7f 100644 --- a/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/event/CategoryEvent.kt +++ b/features/navigation-api/src/commonMain/kotlin/com/escodro/navigationapi/event/CategoryEvent.kt @@ -14,4 +14,9 @@ object CategoryEvent { override fun nextDestination(): Destination = CategoryDestination.CategoryBottomSheet(categoryId = categoryId) } + + data class OnCategoryDetailsClick(val categoryId: Long) : Event { + override fun nextDestination(): Destination = + CategoryDestination.CategoryDetails(categoryId) + } } diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/AlarmSelection.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/AlarmSelection.kt index c6d880958..bf43bb3d6 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/AlarmSelection.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/AlarmSelection.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.escodro.designsystem.components.picker.DateTimerPicker import com.escodro.permission.api.PermissionController import com.escodro.resources.Res import com.escodro.resources.task_detail_cd_icon_alarm diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 1bbc8f8a5..3fef87090 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -20,6 +20,7 @@ kotlin { implementation(libs.compose.material3) implementation(libs.compose.materialIconsExtended) implementation(libs.compose.uiToolingPreview) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.collections.immutable) } } diff --git a/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/kuvio/header/KuvioCategoryHeader.kt b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/kuvio/header/KuvioCategoryHeader.kt new file mode 100644 index 000000000..c63823e32 --- /dev/null +++ b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/kuvio/header/KuvioCategoryHeader.kt @@ -0,0 +1,110 @@ +package com.escodro.designsystem.components.kuvio.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.escodro.designsystem.components.kuvio.icon.KuvioEmojiIcon +import com.escodro.designsystem.components.kuvio.icon.KuvioMoreIcon +import com.escodro.designsystem.components.kuvio.text.KuvioBodyMediumText +import com.escodro.designsystem.components.kuvio.text.KuvioTitleLargeText +import com.escodro.designsystem.theme.AlkaaThemePreview +import com.escodro.resources.Res +import com.escodro.resources.category_header_progress +import org.jetbrains.compose.resources.stringResource + +/** + * Header component for a Category Details screen. + * + * Displays the category emoji, name, task progress, and an options button. + * + * @param name the display name of the category. + * @param color the color associated with the category, used as the emoji box background tint. + * @param totalTasks total number of tasks in the category. + * @param completedTasks number of completed tasks in the category. + * @param onOptionsClick callback invoked when the options (more vert) button is tapped. + * @param modifier modifier applied to the outermost [Row]. + * @param emoji optional emoji character to display inside the icon box. When null a fallback + * placeholder icon is shown in its place. + */ +@Composable +fun KuvioCategoryHeader( + name: String, + color: Color, + totalTasks: Int, + completedTasks: Int, + onOptionsClick: () -> Unit, + modifier: Modifier = Modifier, + emoji: String? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + KuvioEmojiIcon( + emoji = emoji ?: "\uD83D\uDCCB", + tint = color.copy(alpha = 0.15f), + modifier = Modifier.size(48.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + KuvioTitleLargeText(text = name) + KuvioBodyMediumText( + text = stringResource(Res.string.category_header_progress, totalTasks, completedTasks), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onOptionsClick) { + KuvioMoreIcon( + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun KuvioCategoryHeaderLightPreview() { + AlkaaThemePreview { + KuvioCategoryHeader( + name = "Work", + color = Color(0xFF6200EA), + totalTasks = 14, + completedTasks = 3, + onOptionsClick = {}, + emoji = "\uD83D\uDCBC", + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F1B2D) +@Composable +private fun KuvioCategoryHeaderDarkPreview() { + AlkaaThemePreview(isDarkTheme = true) { + KuvioCategoryHeader( + name = "Work", + color = Color(0xFF6200EA), + totalTasks = 14, + completedTasks = 3, + onOptionsClick = {}, + emoji = "\uD83D\uDCBC", + ) + } +} diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/DateTimePicker.kt b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/picker/DateTimePicker.kt similarity index 98% rename from features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/DateTimePicker.kt rename to libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/picker/DateTimePicker.kt index ee71fb843..ec2604f06 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/alarm/DateTimePicker.kt +++ b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/components/picker/DateTimePicker.kt @@ -1,4 +1,4 @@ -package com.escodro.task.presentation.detail.alarm +package com.escodro.designsystem.components.picker import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button diff --git a/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/config/DesignSystemConfig.kt b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/config/DesignSystemConfig.kt index 9e318bba3..c5145073c 100644 --- a/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/config/DesignSystemConfig.kt +++ b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/config/DesignSystemConfig.kt @@ -12,5 +12,5 @@ object DesignSystemConfig { /** * Flag to enable the new design system. */ - const val IsNewDesignEnabled = false + var isNewDesignEnabled: Boolean = false } diff --git a/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/theme/Type.kt b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/theme/Type.kt index 016c3be29..4bc3ebc9e 100644 --- a/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/theme/Type.kt +++ b/libraries/designsystem/src/commonMain/kotlin/com/escodro/designsystem/theme/Type.kt @@ -48,13 +48,13 @@ fun alkaaTypography(): Typography { val nunitoFamily: FontFamily = nunitoFamily() val latoFamily: FontFamily = latoFamily() - val titleFontFamily = if (DesignSystemConfig.IsNewDesignEnabled) { + val titleFontFamily = if (DesignSystemConfig.isNewDesignEnabled) { nunitoFamily } else { openSansFamily } - val defaultFontFamily = if (DesignSystemConfig.IsNewDesignEnabled) { + val defaultFontFamily = if (DesignSystemConfig.isNewDesignEnabled) { nunitoFamily } else { latoFamily diff --git a/resources/src/commonMain/composeResources/values-es/strings.xml b/resources/src/commonMain/composeResources/values-es/strings.xml index 81f314dd7..7c1058ae7 100644 --- a/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/resources/src/commonMain/composeResources/values-es/strings.xml @@ -69,6 +69,15 @@ Eliminar Cancelar Eliminar + %1$d tareas Β· %2$d completadas + Sin tareas + Agrega una tarea usando la barra de abajo + Opciones de categorΓ­a + Atrasadas + Para hoy + PrΓ³ximas + Sin fecha + Completadas Buscar diff --git a/resources/src/commonMain/composeResources/values-fr/strings.xml b/resources/src/commonMain/composeResources/values-fr/strings.xml index afb1d167f..26dd11af9 100644 --- a/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -69,6 +69,15 @@ Supprimer Annuler Supprimer + %1$d tΓ’ches Β· %2$d terminΓ©es + Aucune tΓ’che + Ajoutez une tΓ’che en utilisant la barre ci-dessous + Options de catΓ©gorie + En retard + Pour aujourd\'hui + Γ€ venir + Sans date + TerminΓ©es Rechercher diff --git a/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml index a0bfe83c5..c31e2c309 100644 --- a/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -69,6 +69,15 @@ Remover Cancelar Remover + %1$d tarefas Β· %2$d concluΓ­das + Nenhuma tarefa + Adicione uma tarefa usando a barra abaixo + OpΓ§Γ΅es de categoria + Atrasadas + Para hoje + PrΓ³ximas + Sem data + ConcluΓ­das Pesquisar diff --git a/resources/src/commonMain/composeResources/values/strings.xml b/resources/src/commonMain/composeResources/values/strings.xml index ee294a845..ed78373fd 100644 --- a/resources/src/commonMain/composeResources/values/strings.xml +++ b/resources/src/commonMain/composeResources/values/strings.xml @@ -69,6 +69,15 @@ Remove Cancel Remove + %1$d tasks Β· %2$d completed + No tasks yet + Add a task using the bar below + Category options + Overdue + Due Today + Upcoming + No Due Date + Completed Search diff --git a/shared/src/commonTest/kotlin/com/escodro/alkaa/CategoryFlowTest.kt b/shared/src/commonTest/kotlin/com/escodro/alkaa/CategoryFlowTest.kt index f9d21b7ac..efd515eb1 100644 --- a/shared/src/commonTest/kotlin/com/escodro/alkaa/CategoryFlowTest.kt +++ b/shared/src/commonTest/kotlin/com/escodro/alkaa/CategoryFlowTest.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onAllNodesWithText @@ -19,10 +20,16 @@ import androidx.compose.ui.test.waitUntilDoesNotExist import com.escodro.alkaa.test.afterTest import com.escodro.alkaa.test.beforeTest import com.escodro.alkaa.test.uiTest +import com.escodro.designsystem.config.DesignSystemConfig import com.escodro.designsystem.semantics.ColorKey import com.escodro.local.dao.CategoryDao +import com.escodro.resources.Res +import com.escodro.resources.kuvio_add_button_cd +import com.escodro.resources.kuvio_add_task_bar_placeholder import com.escodro.test.AlkaaTest +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import org.jetbrains.compose.resources.getString import org.koin.test.KoinTest import org.koin.test.inject import kotlin.test.AfterTest @@ -35,6 +42,7 @@ internal class CategoryFlowTest : AlkaaTest(), KoinTest { @BeforeTest fun setup() { + DesignSystemConfig.isNewDesignEnabled = false beforeTest() runTest { // Clean all existing categories @@ -44,6 +52,7 @@ internal class CategoryFlowTest : AlkaaTest(), KoinTest { @AfterTest fun tearDown() { + DesignSystemConfig.isNewDesignEnabled = false afterTest() } @@ -122,6 +131,55 @@ internal class CategoryFlowTest : AlkaaTest(), KoinTest { onCategoryColorItem(color).assertExists() } + @Test + fun when_category_is_clicked_and_flag_enabled_then_details_screen_is_shown() = uiTest { + // Given + DesignSystemConfig.isNewDesignEnabled = true + navigateToCategory() + addCategory("Fitness") + + // When + onNodeWithText("Fitness").performClick() + + // Then β€” details screen header with category name is displayed + onNodeWithText("Fitness").assertIsDisplayed() + // Bottom sheet "Save" button is absent + onNodeWithText("Save").assertDoesNotExist() + } + + @Test + fun when_category_is_clicked_and_flag_disabled_then_bottom_sheet_is_shown() = uiTest { + // Given β€” flag is false (reset in @BeforeTest) + navigateToCategory() + addCategory("Hobbies") + + // When + onNodeWithText("Hobbies").performClick() + + // Then β€” bottom sheet is present (Save button visible) + onNodeWithText("Save").assertIsDisplayed() + } + + @Test + fun when_task_is_added_in_category_details_then_it_appears_in_the_list() = uiTest { + // Given + DesignSystemConfig.isNewDesignEnabled = true + navigateToCategory() + addCategory("Finance") + onNodeWithText("Finance").performClick() + + // When β€” type in the AddTaskBar and submit + val addBarPlaceholder = runBlocking { getString(Res.string.kuvio_add_task_bar_placeholder) } + onNodeWithText(addBarPlaceholder).performClick() + onNodeWithText(addBarPlaceholder).performTextInput("Buy groceries") + onNodeWithContentDescription( + runBlocking { getString(Res.string.kuvio_add_button_cd) }, + ).performClick() + + // Then + onNodeWithText("Buy groceries").assertIsDisplayed() + } + private fun ComposeUiTest.addCategory(name: String) { onNodeWithContentDescription("Add category", useUnmergedTree = true).performClick() onNode(hasSetTextAction()).performTextInput(name)