diff --git a/data/local/src/commonMain/kotlin/com/escodro/local/dao/ChecklistDao.kt b/data/local/src/commonMain/kotlin/com/escodro/local/dao/ChecklistDao.kt new file mode 100644 index 000000000..e1d84ba43 --- /dev/null +++ b/data/local/src/commonMain/kotlin/com/escodro/local/dao/ChecklistDao.kt @@ -0,0 +1,40 @@ +package com.escodro.local.dao + +import com.escodro.local.ChecklistItem +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object to access Checklist data. + */ +interface ChecklistDao { + + /** + * Inserts a new checklist item. + * + * @param item checklist item to be added + */ + suspend fun insertChecklistItem(item: ChecklistItem) + + /** + * Updates a checklist item. + * + * @param item checklist item to be updated + */ + suspend fun updateChecklistItem(item: ChecklistItem) + + /** + * Deletes a checklist item. + * + * @param item checklist item to be deleted + */ + suspend fun deleteChecklistItem(item: ChecklistItem) + + /** + * Gets the checklist items from a task. + * + * @param taskId the task id + * + * @return the checklist items from the task + */ + fun getChecklistItems(taskId: Long): Flow> +} diff --git a/data/local/src/commonMain/kotlin/com/escodro/local/dao/impl/ChecklistDaoImpl.kt b/data/local/src/commonMain/kotlin/com/escodro/local/dao/impl/ChecklistDaoImpl.kt new file mode 100644 index 000000000..4d7955ec2 --- /dev/null +++ b/data/local/src/commonMain/kotlin/com/escodro/local/dao/impl/ChecklistDaoImpl.kt @@ -0,0 +1,44 @@ +package com.escodro.local.dao.impl + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import com.escodro.coroutines.CoroutineDispatcherProvider +import com.escodro.local.ChecklistItem +import com.escodro.local.ChecklistItemQueries +import com.escodro.local.dao.ChecklistDao +import com.escodro.local.provider.DatabaseProvider +import kotlinx.coroutines.flow.Flow + +internal class ChecklistDaoImpl( + private val databaseProvider: DatabaseProvider, + private val dispatcherProvider: CoroutineDispatcherProvider, +) : ChecklistDao { + + private val checklistItemQueries: ChecklistItemQueries + get() = databaseProvider.getInstance().checklistItemQueries + + override suspend fun insertChecklistItem(item: ChecklistItem) { + checklistItemQueries.insertItem( + item_task_id = item.item_task_id, + item_title = item.item_title, + item_is_completed = item.item_is_completed, + ) + } + + override suspend fun updateChecklistItem(item: ChecklistItem) { + checklistItemQueries.updateItem( + item_title = item.item_title, + item_is_completed = item.item_is_completed, + item_id = item.item_id, + ) + } + + override suspend fun deleteChecklistItem(item: ChecklistItem) { + checklistItemQueries.deleteItem(item.item_id) + } + + override fun getChecklistItems(taskId: Long): Flow> = + checklistItemQueries.selectByTaskId(taskId) + .asFlow() + .mapToList(dispatcherProvider.io) +} diff --git a/data/local/src/commonMain/kotlin/com/escodro/local/datasource/ChecklistLocalDataSource.kt b/data/local/src/commonMain/kotlin/com/escodro/local/datasource/ChecklistLocalDataSource.kt new file mode 100644 index 000000000..4ce687994 --- /dev/null +++ b/data/local/src/commonMain/kotlin/com/escodro/local/datasource/ChecklistLocalDataSource.kt @@ -0,0 +1,30 @@ +package com.escodro.local.datasource + +import com.escodro.local.dao.ChecklistDao +import com.escodro.local.mapper.ChecklistItemMapper +import com.escodro.repository.datasource.ChecklistDataSource +import com.escodro.repository.model.ChecklistItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class ChecklistLocalDataSource( + private val checklistDao: ChecklistDao, + private val checklistMapper: ChecklistItemMapper, +) : ChecklistDataSource { + + override suspend fun insertChecklistItem(item: ChecklistItem) = + checklistDao.insertChecklistItem(checklistMapper.toLocal(item)) + + override suspend fun updateChecklistItem(item: ChecklistItem) { + checklistDao.updateChecklistItem(checklistMapper.toLocal(item)) + } + + override suspend fun deleteChecklistItem(item: ChecklistItem) { + checklistDao.deleteChecklistItem(checklistMapper.toLocal(item)) + } + + override fun getChecklistItems(taskId: Long): Flow> = + checklistDao.getChecklistItems(taskId).map { list -> + list.map { checklistMapper.toRepo(it) } + } +} diff --git a/data/local/src/commonMain/kotlin/com/escodro/local/di/LocalModule.kt b/data/local/src/commonMain/kotlin/com/escodro/local/di/LocalModule.kt index 9d3a0cdd6..a43e106b5 100644 --- a/data/local/src/commonMain/kotlin/com/escodro/local/di/LocalModule.kt +++ b/data/local/src/commonMain/kotlin/com/escodro/local/di/LocalModule.kt @@ -1,22 +1,27 @@ package com.escodro.local.di import com.escodro.local.dao.CategoryDao +import com.escodro.local.dao.ChecklistDao import com.escodro.local.dao.TaskDao import com.escodro.local.dao.TaskWithCategoryDao import com.escodro.local.dao.impl.CategoryDaoImpl +import com.escodro.local.dao.impl.ChecklistDaoImpl import com.escodro.local.dao.impl.TaskDaoImpl import com.escodro.local.dao.impl.TaskWithCategoryDaoImpl import com.escodro.local.datasource.CategoryLocalDataSource +import com.escodro.local.datasource.ChecklistLocalDataSource import com.escodro.local.datasource.SearchLocalDataSource import com.escodro.local.datasource.TaskLocalDataSource import com.escodro.local.datasource.TaskWithCategoryLocalDataSource import com.escodro.local.mapper.AlarmIntervalMapper import com.escodro.local.mapper.CategoryMapper +import com.escodro.local.mapper.ChecklistItemMapper import com.escodro.local.mapper.SelectMapper import com.escodro.local.mapper.TaskMapper import com.escodro.local.mapper.TaskWithCategoryMapper import com.escodro.local.provider.DatabaseProvider import com.escodro.repository.datasource.CategoryDataSource +import com.escodro.repository.datasource.ChecklistDataSource import com.escodro.repository.datasource.SearchDataSource import com.escodro.repository.datasource.TaskDataSource import com.escodro.repository.datasource.TaskWithCategoryDataSource @@ -36,6 +41,7 @@ val localModule = module { singleOf(::CategoryLocalDataSource) bind CategoryDataSource::class singleOf(::TaskWithCategoryLocalDataSource) bind TaskWithCategoryDataSource::class singleOf(::SearchLocalDataSource) bind SearchDataSource::class + singleOf(::ChecklistLocalDataSource) bind ChecklistDataSource::class // Mappers factoryOf(::AlarmIntervalMapper) @@ -43,10 +49,12 @@ val localModule = module { factoryOf(::CategoryMapper) factoryOf(::TaskWithCategoryMapper) factoryOf(::SelectMapper) + factoryOf(::ChecklistItemMapper) // DAOs singleOf(::CategoryDaoImpl) bind CategoryDao::class singleOf(::TaskDaoImpl) bind TaskDao::class + singleOf(::ChecklistDaoImpl) bind ChecklistDao::class singleOf(::TaskWithCategoryDaoImpl) bind TaskWithCategoryDao::class // Providers diff --git a/data/local/src/commonMain/kotlin/com/escodro/local/mapper/ChecklistItemMapper.kt b/data/local/src/commonMain/kotlin/com/escodro/local/mapper/ChecklistItemMapper.kt new file mode 100644 index 000000000..d18415121 --- /dev/null +++ b/data/local/src/commonMain/kotlin/com/escodro/local/mapper/ChecklistItemMapper.kt @@ -0,0 +1,23 @@ +package com.escodro.local.mapper + +import com.escodro.local.ChecklistItem as LocalChecklistItem +import com.escodro.repository.model.ChecklistItem as RepoChecklistItem + +internal class ChecklistItemMapper { + + fun toRepo(local: LocalChecklistItem): RepoChecklistItem = + RepoChecklistItem( + id = local.item_id, + taskId = local.item_task_id, + title = local.item_title, + isCompleted = local.item_is_completed, + ) + + fun toLocal(repo: RepoChecklistItem): LocalChecklistItem = + LocalChecklistItem( + item_id = repo.id, + item_task_id = repo.taskId, + item_title = repo.title, + item_is_completed = repo.isCompleted, + ) +} diff --git a/data/local/src/commonMain/sqldelight/com/escodro/local/ChecklistItem.sq b/data/local/src/commonMain/sqldelight/com/escodro/local/ChecklistItem.sq new file mode 100644 index 000000000..b21c0d806 --- /dev/null +++ b/data/local/src/commonMain/sqldelight/com/escodro/local/ChecklistItem.sq @@ -0,0 +1,30 @@ +import kotlin.Boolean; + +CREATE TABLE IF NOT EXISTS ChecklistItem ( + item_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + item_task_id INTEGER NOT NULL, + item_title TEXT NOT NULL, + item_is_completed INTEGER AS Boolean NOT NULL DEFAULT 0, + FOREIGN KEY(item_task_id) REFERENCES Task(task_id) ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS index_ChecklistItem_item_task_id ON ChecklistItem (item_task_id); + +insertItem: +INSERT INTO ChecklistItem(item_task_id, item_title, item_is_completed) +VALUES (?, ?, ?); + +updateItem: +UPDATE ChecklistItem +SET item_title = ?, + item_is_completed = ? +WHERE item_id = ?; + +deleteItem: +DELETE FROM ChecklistItem +WHERE item_id = ?; + +selectByTaskId: +SELECT * +FROM ChecklistItem +WHERE item_task_id = ?; diff --git a/data/repository/src/commonMain/kotlin/com/escodro/repository/ChecklistRepositoryImpl.kt b/data/repository/src/commonMain/kotlin/com/escodro/repository/ChecklistRepositoryImpl.kt new file mode 100644 index 000000000..db863f938 --- /dev/null +++ b/data/repository/src/commonMain/kotlin/com/escodro/repository/ChecklistRepositoryImpl.kt @@ -0,0 +1,32 @@ +package com.escodro.repository + +import com.escodro.domain.model.ChecklistItem +import com.escodro.domain.repository.ChecklistRepository +import com.escodro.repository.datasource.ChecklistDataSource +import com.escodro.repository.mapper.ChecklistItemMapper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class ChecklistRepositoryImpl( + private val checklistDataSource: ChecklistDataSource, + private val checklistMapper: ChecklistItemMapper, +) : ChecklistRepository { + + override suspend fun insertChecklistItem(item: ChecklistItem) { + checklistDataSource.insertChecklistItem(checklistMapper.toRepo(item)) + } + + override suspend fun updateChecklistItem(item: ChecklistItem) { + checklistDataSource.updateChecklistItem(checklistMapper.toRepo(item)) + } + + override suspend fun deleteChecklistItem(item: ChecklistItem) { + checklistDataSource.deleteChecklistItem(checklistMapper.toRepo(item)) + } + + override fun getChecklistItems(taskId: Long): Flow> { + return checklistDataSource.getChecklistItems(taskId).map { list -> + list.map { checklistMapper.toDomain(it) } + } + } +} diff --git a/data/repository/src/commonMain/kotlin/com/escodro/repository/datasource/ChecklistDataSource.kt b/data/repository/src/commonMain/kotlin/com/escodro/repository/datasource/ChecklistDataSource.kt new file mode 100644 index 000000000..294fb1a11 --- /dev/null +++ b/data/repository/src/commonMain/kotlin/com/escodro/repository/datasource/ChecklistDataSource.kt @@ -0,0 +1,11 @@ +package com.escodro.repository.datasource + +import com.escodro.repository.model.ChecklistItem +import kotlinx.coroutines.flow.Flow + +interface ChecklistDataSource { + suspend fun insertChecklistItem(item: ChecklistItem) + suspend fun updateChecklistItem(item: ChecklistItem) + suspend fun deleteChecklistItem(item: ChecklistItem) + fun getChecklistItems(taskId: Long): Flow> +} diff --git a/data/repository/src/commonMain/kotlin/com/escodro/repository/di/RepositoryModule.kt b/data/repository/src/commonMain/kotlin/com/escodro/repository/di/RepositoryModule.kt index fea85db81..1d06a1b69 100644 --- a/data/repository/src/commonMain/kotlin/com/escodro/repository/di/RepositoryModule.kt +++ b/data/repository/src/commonMain/kotlin/com/escodro/repository/di/RepositoryModule.kt @@ -4,6 +4,8 @@ import com.escodro.domain.repository.CategoryRepository import com.escodro.domain.repository.SearchRepository import com.escodro.domain.repository.TaskRepository import com.escodro.domain.repository.TaskWithCategoryRepository +import com.escodro.domain.repository.ChecklistRepository +import com.escodro.repository.ChecklistRepositoryImpl import com.escodro.domain.usecase.preferences.PreferencesRepository import com.escodro.repository.CategoryRepositoryImpl import com.escodro.repository.PreferencesRepositoryImpl @@ -13,6 +15,7 @@ import com.escodro.repository.TaskWithCategoryRepositoryImpl import com.escodro.repository.mapper.AlarmIntervalMapper import com.escodro.repository.mapper.AppThemeOptionsMapper import com.escodro.repository.mapper.CategoryMapper +import com.escodro.repository.mapper.ChecklistItemMapper import com.escodro.repository.mapper.TaskMapper import com.escodro.repository.mapper.TaskWithCategoryMapper import org.koin.core.module.dsl.factoryOf @@ -28,6 +31,7 @@ val repositoryModule = module { // Repositories singleOf(::TaskRepositoryImpl) bind TaskRepository::class singleOf(::CategoryRepositoryImpl) bind CategoryRepository::class + singleOf(::ChecklistRepositoryImpl) bind ChecklistRepository::class singleOf(::TaskWithCategoryRepositoryImpl) bind TaskWithCategoryRepository::class singleOf(::SearchRepositoryImpl) bind SearchRepository::class singleOf(::PreferencesRepositoryImpl) bind PreferencesRepository::class @@ -35,6 +39,7 @@ val repositoryModule = module { // Mappers factoryOf(::AlarmIntervalMapper) factoryOf(::TaskMapper) + factoryOf(::ChecklistItemMapper) factoryOf(::CategoryMapper) factoryOf(::TaskWithCategoryMapper) factoryOf(::AppThemeOptionsMapper) diff --git a/data/repository/src/commonMain/kotlin/com/escodro/repository/mapper/ChecklistItemMapper.kt b/data/repository/src/commonMain/kotlin/com/escodro/repository/mapper/ChecklistItemMapper.kt new file mode 100644 index 000000000..d4aaa182b --- /dev/null +++ b/data/repository/src/commonMain/kotlin/com/escodro/repository/mapper/ChecklistItemMapper.kt @@ -0,0 +1,23 @@ +package com.escodro.repository.mapper + +import com.escodro.domain.model.ChecklistItem as DomainChecklistItem +import com.escodro.repository.model.ChecklistItem as RepoChecklistItem + +internal class ChecklistItemMapper { + + fun toRepo(domain: DomainChecklistItem): RepoChecklistItem = + RepoChecklistItem( + id = domain.id, + taskId = domain.taskId, + title = domain.title, + isCompleted = domain.isCompleted, + ) + + fun toDomain(repo: RepoChecklistItem): DomainChecklistItem = + DomainChecklistItem( + id = repo.id, + taskId = repo.taskId, + title = repo.title, + isCompleted = repo.isCompleted, + ) +} diff --git a/data/repository/src/commonMain/kotlin/com/escodro/repository/model/ChecklistItem.kt b/data/repository/src/commonMain/kotlin/com/escodro/repository/model/ChecklistItem.kt new file mode 100644 index 000000000..16b0bd36f --- /dev/null +++ b/data/repository/src/commonMain/kotlin/com/escodro/repository/model/ChecklistItem.kt @@ -0,0 +1,16 @@ +package com.escodro.repository.model + +/** + * Data class to represent a Checklist Item. + * + * @property id unique checklist item id + * @property taskId the associated task id + * @property title the checklist item title + * @property isCompleted indicates if the checklist item is completed + */ +data class ChecklistItem( + val id: Long = 0, + val taskId: Long, + val title: String, + val isCompleted: Boolean = false, +) 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..ee532b861 100644 --- a/domain/src/commonMain/kotlin/com/escodro/domain/di/DomainModule.kt +++ b/domain/src/commonMain/kotlin/com/escodro/domain/di/DomainModule.kt @@ -38,6 +38,10 @@ import com.escodro.domain.usecase.task.UpdateTaskCategory import com.escodro.domain.usecase.task.UpdateTaskDescription import com.escodro.domain.usecase.task.UpdateTaskStatus import com.escodro.domain.usecase.task.UpdateTaskTitle +import com.escodro.domain.usecase.checklist.AddChecklistItem +import com.escodro.domain.usecase.checklist.DeleteChecklistItem +import com.escodro.domain.usecase.checklist.LoadChecklistItems +import com.escodro.domain.usecase.checklist.UpdateChecklistItem import com.escodro.domain.usecase.task.implementation.AddTaskImpl import com.escodro.domain.usecase.task.implementation.LoadTaskImpl import com.escodro.domain.usecase.task.implementation.UpdateTaskCategoryImpl @@ -122,6 +126,12 @@ val domainModule = module { factoryOf(::UpdateAppTheme) factoryOf(::LoadAppTheme) + // Checklist Use Cases + factoryOf(::AddChecklistItem) + factoryOf(::DeleteChecklistItem) + factoryOf(::LoadChecklistItems) + factoryOf(::UpdateChecklistItem) + // Providers factoryOf(::DateTimeProviderImpl) bind DateTimeProvider::class } diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/model/ChecklistItem.kt b/domain/src/commonMain/kotlin/com/escodro/domain/model/ChecklistItem.kt new file mode 100644 index 000000000..76f88cefe --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/model/ChecklistItem.kt @@ -0,0 +1,16 @@ +package com.escodro.domain.model + +/** + * Data class to represent a Checklist Item. + * + * @property id unique checklist item id + * @property taskId the associated task id + * @property title the checklist item title + * @property isCompleted indicates if the checklist item is completed + */ +data class ChecklistItem( + val id: Long = 0, + val taskId: Long, + val title: String, + val isCompleted: Boolean = false, +) diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/repository/ChecklistRepository.kt b/domain/src/commonMain/kotlin/com/escodro/domain/repository/ChecklistRepository.kt new file mode 100644 index 000000000..374a544a8 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/repository/ChecklistRepository.kt @@ -0,0 +1,40 @@ +package com.escodro.domain.repository + +import com.escodro.domain.model.ChecklistItem +import kotlinx.coroutines.flow.Flow + +/** + * Interface to represent the implementation of Checklist repository. + */ +interface ChecklistRepository { + + /** + * Inserts a new checklist item. + * + * @param item checklist item to be added + */ + suspend fun insertChecklistItem(item: ChecklistItem) + + /** + * Updates a checklist item. + * + * @param item checklist item to be updated + */ + suspend fun updateChecklistItem(item: ChecklistItem) + + /** + * Deletes a checklist item. + * + * @param item checklist item to be deleted + */ + suspend fun deleteChecklistItem(item: ChecklistItem) + + /** + * Gets the checklist items from a task. + * + * @param taskId the task id + * + * @return the checklist items from the task + */ + fun getChecklistItems(taskId: Long): Flow> +} diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/AddChecklistItem.kt b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/AddChecklistItem.kt new file mode 100644 index 000000000..230e84fa1 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/AddChecklistItem.kt @@ -0,0 +1,12 @@ +package com.escodro.domain.usecase.checklist + +import com.escodro.domain.model.ChecklistItem +import com.escodro.domain.repository.ChecklistRepository + +class AddChecklistItem(private val checklistRepository: ChecklistRepository) { + suspend operator fun invoke(item: ChecklistItem) { + if (item.title.isNotBlank()) { + checklistRepository.insertChecklistItem(item) + } + } +} diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/DeleteChecklistItem.kt b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/DeleteChecklistItem.kt new file mode 100644 index 000000000..fb64d332b --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/DeleteChecklistItem.kt @@ -0,0 +1,9 @@ +package com.escodro.domain.usecase.checklist + +import com.escodro.domain.model.ChecklistItem +import com.escodro.domain.repository.ChecklistRepository + +class DeleteChecklistItem(private val checklistRepository: ChecklistRepository) { + suspend operator fun invoke(item: ChecklistItem) = + checklistRepository.deleteChecklistItem(item) +} diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/LoadChecklistItems.kt b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/LoadChecklistItems.kt new file mode 100644 index 000000000..81e51f32d --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/LoadChecklistItems.kt @@ -0,0 +1,10 @@ +package com.escodro.domain.usecase.checklist + +import com.escodro.domain.model.ChecklistItem +import com.escodro.domain.repository.ChecklistRepository +import kotlinx.coroutines.flow.Flow + +class LoadChecklistItems(private val checklistRepository: ChecklistRepository) { + operator fun invoke(taskId: Long): Flow> = + checklistRepository.getChecklistItems(taskId) +} diff --git a/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/UpdateChecklistItem.kt b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/UpdateChecklistItem.kt new file mode 100644 index 000000000..4d05b2ae2 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/escodro/domain/usecase/checklist/UpdateChecklistItem.kt @@ -0,0 +1,9 @@ +package com.escodro.domain.usecase.checklist + +import com.escodro.domain.model.ChecklistItem +import com.escodro.domain.repository.ChecklistRepository + +class UpdateChecklistItem(private val checklistRepository: ChecklistRepository) { + suspend operator fun invoke(item: ChecklistItem) = + checklistRepository.updateChecklistItem(item) +} diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/di/TaskModule.kt b/features/task/src/commonMain/kotlin/com/escodro/task/di/TaskModule.kt index e36053abf..7f6804d52 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/di/TaskModule.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/di/TaskModule.kt @@ -3,6 +3,7 @@ package com.escodro.task.di import com.escodro.navigationapi.provider.NavGraph import com.escodro.task.mapper.AlarmIntervalMapper import com.escodro.task.mapper.CategoryMapper +import com.escodro.task.mapper.ChecklistItemMapper import com.escodro.task.mapper.TaskMapper import com.escodro.task.mapper.TaskWithCategoryMapper import com.escodro.task.navigation.TaskNavGraph @@ -40,6 +41,7 @@ val taskModule = module { factoryOf(::TaskMapper) factoryOf(::TaskWithCategoryMapper) factoryOf(::CategoryMapper) + factoryOf(::ChecklistItemMapper) // Navigation factoryOf(::TaskNavGraph) bind NavGraph::class diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/mapper/ChecklistItemMapper.kt b/features/task/src/commonMain/kotlin/com/escodro/task/mapper/ChecklistItemMapper.kt new file mode 100644 index 000000000..456b93cc7 --- /dev/null +++ b/features/task/src/commonMain/kotlin/com/escodro/task/mapper/ChecklistItemMapper.kt @@ -0,0 +1,23 @@ +package com.escodro.task.mapper + +import com.escodro.domain.model.ChecklistItem as DomainChecklistItem +import com.escodro.task.model.ChecklistItem as ViewChecklistItem + +internal class ChecklistItemMapper { + + fun toView(domain: DomainChecklistItem): ViewChecklistItem = + ViewChecklistItem( + id = domain.id, + taskId = domain.taskId, + title = domain.title, + isCompleted = domain.isCompleted, + ) + + fun toDomain(view: ViewChecklistItem): DomainChecklistItem = + DomainChecklistItem( + id = view.id, + taskId = view.taskId, + title = view.title, + isCompleted = view.isCompleted, + ) +} diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/model/ChecklistItem.kt b/features/task/src/commonMain/kotlin/com/escodro/task/model/ChecklistItem.kt new file mode 100644 index 000000000..52e37e41c --- /dev/null +++ b/features/task/src/commonMain/kotlin/com/escodro/task/model/ChecklistItem.kt @@ -0,0 +1,8 @@ +package com.escodro.task.model + +data class ChecklistItem( + val id: Long = 0, + val taskId: Long, + val title: String, + val isCompleted: Boolean = false, +) diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/add/TaskBottomSheet.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/add/TaskBottomSheet.kt index 54e527277..6b11f4e05 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/add/TaskBottomSheet.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/add/TaskBottomSheet.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.escodro.alarmapi.AlarmPermission import com.escodro.categoryapi.presentation.CategoryListViewModel @@ -104,7 +105,8 @@ internal fun AddTaskBottomSheetContent( onTextChange = { text -> taskInputText = text }, modifier = Modifier .fillMaxWidth() - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .testTag("add_task_title"), ) CategorySelection( @@ -128,7 +130,8 @@ internal fun AddTaskBottomSheetContent( modifier = Modifier .padding(top = 8.dp, bottom = 16.dp) .fillMaxWidth() - .height(48.dp), + .height(48.dp) + .testTag("add_task_button"), onClick = { addTaskViewModel.addTask( title = taskInputText, diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailActions.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailActions.kt index e940c70da..3e3024e46 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailActions.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailActions.kt @@ -1,6 +1,7 @@ package com.escodro.task.presentation.detail import com.escodro.task.model.AlarmInterval +import com.escodro.task.model.ChecklistItem import com.escodro.task.presentation.detail.main.CategoryId import kotlinx.datetime.LocalDateTime @@ -13,6 +14,9 @@ internal data class TaskDetailActions( val onCategoryChange: (CategoryId) -> Unit = {}, val onAlarmChange: (LocalDateTime?) -> Unit = {}, val onIntervalChange: (AlarmInterval) -> Unit = {}, + val onChecklistAdd: (String) -> Unit = {}, + val onChecklistUpdate: (ChecklistItem) -> Unit = {}, + val onChecklistDelete: (ChecklistItem) -> Unit = {}, val hasExactAlarmPermission: () -> Boolean = { false }, val openExactAlarmPermissionScreen: () -> Unit = {}, val openAppSettingsScreen: () -> Unit = {}, diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailSectionContent.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailSectionContent.kt index b332d0738..47c62789f 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailSectionContent.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/TaskDetailSectionContent.kt @@ -2,9 +2,12 @@ package com.escodro.task.presentation.detail import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,3 +37,22 @@ internal fun TaskDetailSectionContent( } } } + +@Composable +internal fun TaskDetailSectionContent( + title: String, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp) + ) + Box(modifier = Modifier.padding(start = 16.dp)) { + content() + } + } +} diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/checklist/Checklist.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/checklist/Checklist.kt new file mode 100644 index 000000000..520da6644 --- /dev/null +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/checklist/Checklist.kt @@ -0,0 +1,164 @@ +package com.escodro.task.presentation.detail.checklist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.List +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.escodro.resources.Res +import com.escodro.resources.task_detail_checklist_add_item +import com.escodro.resources.task_detail_checklist_cd_delete_item +import com.escodro.resources.task_detail_checklist_label +import com.escodro.task.model.ChecklistItem +import com.escodro.task.presentation.detail.TaskDetailSectionContent +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun Checklist( + checklistItems: List, + onAdd: (String) -> Unit, + onUpdate: (ChecklistItem) -> Unit, + onDelete: (ChecklistItem) -> Unit, +) { + TaskDetailSectionContent( + title = stringResource(Res.string.task_detail_checklist_label), + modifier = Modifier.testTag("checklist_section"), + ) { + Column { + checklistItems.forEach { item -> + ChecklistItemRow(item = item, onUpdate = onUpdate, onDelete = onDelete) + } + ChecklistAddItem(onAdd = onAdd) + } + } +} + +@Composable +private fun ChecklistItemRow( + item: ChecklistItem, + onUpdate: (ChecklistItem) -> Unit, + onDelete: (ChecklistItem) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = item.isCompleted, + onCheckedChange = { isChecked -> onUpdate(item.copy(isCompleted = isChecked)) }, + modifier = Modifier.testTag("checkbox_${item.title}"), + ) + + var text by remember(item.title) { mutableStateOf(item.title) } + + TextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .weight(1f) + .testTag("item_text_${item.title}") + .onFocusChanged { focusState -> + if (!focusState.isFocused && text != item.title && text.isNotBlank()) { + onUpdate(item.copy(title = text)) + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + if (text.isNotBlank()) { + onUpdate(item.copy(title = text)) + } + }) + ) + + IconButton( + modifier = Modifier.testTag("delete_button_${item.title}"), + onClick = { onDelete(item) } + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(Res.string.task_detail_checklist_cd_delete_item), + ) + } + } +} + +@Composable +private fun ChecklistAddItem(onAdd: (String) -> Unit) { + var newItemText by remember { mutableStateOf("") } + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = newItemText, + onValueChange = { newItemText = it }, + placeholder = { Text(stringResource(Res.string.task_detail_checklist_add_item)) }, + modifier = Modifier + .weight(1f) + .testTag("checklist_add_item_text"), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + if (newItemText.isNotBlank()) { + onAdd(newItemText) + newItemText = "" + } + }) + ) + + IconButton( + modifier = Modifier.testTag("checklist_add_item_button"), + onClick = { + if (newItemText.isNotBlank()) { + onAdd(newItemText) + newItemText = "" + } + } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.task_detail_checklist_add_item), + ) + } + } +} diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetail.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetail.kt index 55014198c..e8b5f418c 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetail.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetail.kt @@ -42,11 +42,13 @@ import com.escodro.resources.task_detail_cd_error import com.escodro.resources.task_detail_cd_icon_category import com.escodro.resources.task_detail_cd_icon_description import com.escodro.resources.task_detail_header_error +import com.escodro.task.model.ChecklistItem import com.escodro.task.model.Task import com.escodro.task.presentation.category.CategorySelection import com.escodro.task.presentation.detail.TaskDetailActions import com.escodro.task.presentation.detail.TaskDetailSectionContent import com.escodro.task.presentation.detail.alarm.AlarmSelection +import com.escodro.task.presentation.detail.checklist.Checklist import com.escodro.task.presentation.detail.alarm.TaskAlarmViewModel import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject @@ -80,6 +82,9 @@ internal fun TaskDetailScreen( onCategoryChange = { categoryId -> detailViewModel.updateCategory(id, categoryId) }, onAlarmChange = { calendar -> alarmViewModel.updateAlarm(id, calendar) }, onIntervalChange = { interval -> alarmViewModel.setRepeating(id, interval) }, + onChecklistAdd = { title -> detailViewModel.addChecklistItem(id, title) }, + onChecklistUpdate = { item -> detailViewModel.updateChecklistItem(item) }, + onChecklistDelete = { item -> detailViewModel.deleteChecklistItem(item) }, hasExactAlarmPermission = { alarmPermission.hasExactAlarmPermission() }, openExactAlarmPermissionScreen = { alarmPermission.openExactAlarmPermissionScreen() }, openAppSettingsScreen = { alarmPermission.openAppSettings() }, @@ -123,6 +128,7 @@ internal fun TaskDetailRouter( is TaskDetailState.Loaded -> { TaskDetailContent( task = state.task, + checklistItems = state.checklistItems, categoryViewState = categoryViewState, actions = actions, ) @@ -135,6 +141,7 @@ internal fun TaskDetailRouter( @Composable private fun TaskDetailContent( task: Task, + checklistItems: List, categoryViewState: CategoryState, actions: TaskDetailActions, ) { @@ -156,6 +163,12 @@ private fun TaskDetailContent( text = task.description, onDescriptionChange = actions.onDescriptionChange, ) + Checklist( + checklistItems = checklistItems, + onAdd = actions.onChecklistAdd, + onUpdate = actions.onChecklistUpdate, + onDelete = actions.onChecklistDelete, + ) AlarmSelection( calendar = task.dueDate, interval = task.alarmInterval, diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailState.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailState.kt index 77b51cb56..10ae29b96 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailState.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailState.kt @@ -1,5 +1,6 @@ package com.escodro.task.presentation.detail.main +import com.escodro.task.model.ChecklistItem import com.escodro.task.model.Task internal sealed class TaskDetailState { @@ -8,5 +9,8 @@ internal sealed class TaskDetailState { data object Error : TaskDetailState() - data class Loaded(val task: Task) : TaskDetailState() + data class Loaded( + val task: Task, + val checklistItems: List = emptyList(), + ) : TaskDetailState() } diff --git a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailViewModel.kt b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailViewModel.kt index a964a73ca..786e0ad42 100644 --- a/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailViewModel.kt +++ b/features/task/src/commonMain/kotlin/com/escodro/task/presentation/detail/main/TaskDetailViewModel.kt @@ -4,13 +4,20 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.escodro.coroutines.AppCoroutineScope import com.escodro.coroutines.CoroutineDebouncer +import com.escodro.domain.usecase.checklist.AddChecklistItem +import com.escodro.domain.usecase.checklist.DeleteChecklistItem +import com.escodro.domain.usecase.checklist.LoadChecklistItems +import com.escodro.domain.usecase.checklist.UpdateChecklistItem import com.escodro.domain.usecase.task.LoadTask +import com.escodro.task.mapper.ChecklistItemMapper +import com.escodro.task.model.ChecklistItem import com.escodro.domain.usecase.task.UpdateTaskCategory import com.escodro.domain.usecase.task.UpdateTaskDescription import com.escodro.domain.usecase.task.UpdateTaskTitle import com.escodro.task.mapper.TaskMapper import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class TaskDetailViewModel( @@ -18,9 +25,14 @@ internal class TaskDetailViewModel( private val updateTaskTitle: UpdateTaskTitle, private val updateTaskDescription: UpdateTaskDescription, private val updateTaskCategory: UpdateTaskCategory, + private val loadChecklistItemsUseCase: LoadChecklistItems, + private val addChecklistItemUseCase: AddChecklistItem, + private val updateChecklistItemUseCase: UpdateChecklistItem, + private val deleteChecklistItemUseCase: DeleteChecklistItem, private val coroutineDebouncer: CoroutineDebouncer, private val applicationScope: AppCoroutineScope, private val taskMapper: TaskMapper, + private val checklistItemMapper: ChecklistItemMapper, ) : ViewModel() { fun loadTaskInfo(taskId: TaskId): Flow = flow { @@ -28,7 +40,10 @@ internal class TaskDetailViewModel( if (task != null) { val viewTask = taskMapper.toView(task) - emit(TaskDetailState.Loaded(viewTask)) + loadChecklistItemsUseCase(taskId.value).collect { checklist -> + val viewChecklist = checklist.map { checklistItemMapper.toView(it) } + emit(TaskDetailState.Loaded(viewTask, viewChecklist)) + } } else { emit(TaskDetailState.Error) } @@ -50,4 +65,23 @@ internal class TaskDetailViewModel( applicationScope.launch { updateTaskCategory(taskId = taskId.value, categoryId = categoryId.value) } + + fun addChecklistItem(taskId: TaskId, title: String) { + applicationScope.launch { + val item = ChecklistItem(taskId = taskId.value, title = title) + addChecklistItemUseCase(checklistItemMapper.toDomain(item)) + } + } + + fun updateChecklistItem(item: ChecklistItem) { + applicationScope.launch { + updateChecklistItemUseCase(checklistItemMapper.toDomain(item)) + } + } + + fun deleteChecklistItem(item: ChecklistItem) { + applicationScope.launch { + deleteChecklistItemUseCase(checklistItemMapper.toDomain(item)) + } + } } diff --git a/features/task/src/commonTest/kotlin/com/escodro/task/presentation/detail/TaskDetailViewModelTest.kt b/features/task/src/commonTest/kotlin/com/escodro/task/presentation/detail/TaskDetailViewModelTest.kt index 84e5b095e..a62e81668 100644 --- a/features/task/src/commonTest/kotlin/com/escodro/task/presentation/detail/TaskDetailViewModelTest.kt +++ b/features/task/src/commonTest/kotlin/com/escodro/task/presentation/detail/TaskDetailViewModelTest.kt @@ -1,7 +1,14 @@ package com.escodro.task.presentation.detail import com.escodro.coroutines.AppCoroutineScope +import com.escodro.domain.model.ChecklistItem +import com.escodro.domain.repository.ChecklistRepository +import com.escodro.domain.usecase.checklist.AddChecklistItem +import com.escodro.domain.usecase.checklist.DeleteChecklistItem +import com.escodro.domain.usecase.checklist.LoadChecklistItems +import com.escodro.domain.usecase.checklist.UpdateChecklistItem import com.escodro.task.mapper.AlarmIntervalMapper +import com.escodro.task.mapper.ChecklistItemMapper import com.escodro.task.mapper.TaskMapper import com.escodro.task.presentation.detail.main.CategoryId import com.escodro.task.presentation.detail.main.TaskDetailState @@ -15,7 +22,9 @@ import com.escodro.task.presentation.fake.UpdateTaskDescriptionFake import com.escodro.task.presentation.fake.UpdateTaskTitleFake import com.escodro.test.rule.CoroutinesTestDispatcher import com.escodro.test.rule.CoroutinesTestDispatcherImpl +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -35,12 +44,24 @@ internal class TaskDetailViewModelTest : private val taskMapper = TaskMapper(AlarmIntervalMapper()) + private val fakeChecklistRepo = object : ChecklistRepository { + override suspend fun insertChecklistItem(item: ChecklistItem) = Unit + override suspend fun updateChecklistItem(item: ChecklistItem) = Unit + override suspend fun deleteChecklistItem(item: ChecklistItem) = Unit + override fun getChecklistItems(taskId: Long): Flow> = flowOf(emptyList()) + } + private val viewModel = TaskDetailViewModel( loadTaskUseCase = loadTask, updateTaskTitle = updateTaskTitle, updateTaskDescription = updateDescription, updateTaskCategory = updateTaskCategory, taskMapper = taskMapper, + loadChecklistItemsUseCase = LoadChecklistItems(fakeChecklistRepo), + addChecklistItemUseCase = AddChecklistItem(fakeChecklistRepo), + updateChecklistItemUseCase = UpdateChecklistItem(fakeChecklistRepo), + deleteChecklistItemUseCase = DeleteChecklistItem(fakeChecklistRepo), + checklistItemMapper = ChecklistItemMapper(), coroutineDebouncer = CoroutinesDebouncerFake(), applicationScope = AppCoroutineScope(context = testDispatcher()), ) diff --git a/resources/src/commonMain/composeResources/values/strings.xml b/resources/src/commonMain/composeResources/values/strings.xml index f7b0fbc18..bb60f2231 100644 --- a/resources/src/commonMain/composeResources/values/strings.xml +++ b/resources/src/commonMain/composeResources/values/strings.xml @@ -21,6 +21,9 @@ Alarm Remove alarm Repeating alarm + Checklist + Add Item + Delete item No categories