diff --git a/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt b/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt index 183e3266..9238d8dd 100644 --- a/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt +++ b/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt @@ -24,9 +24,11 @@ package net.opatry.tasks.app.di import android.content.Context import androidx.room.Room +import androidx.room.withTransaction import net.opatry.tasks.CredentialsStorage import net.opatry.tasks.FileCredentialsStorage import net.opatry.tasks.data.TasksAppDatabase +import net.opatry.tasks.data.TransactionRunner import org.koin.core.module.Module import org.koin.dsl.module import java.io.File @@ -52,4 +54,15 @@ actual fun platformModule(target: String): Module = module { val credentialsFile = File(context.cacheDir, "google_auth_token_cache.json") FileCredentialsStorage(credentialsFile.absolutePath) } + + single { + object : TransactionRunner { + override suspend fun runInTransaction(logic: suspend () -> R): R { + val db = get() + return db.withTransaction { + logic() + } + } + } + } } \ No newline at end of file diff --git a/tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/app/di/AndroidDITest.kt b/tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/app/di/AndroidDITest.kt index 90aa69d7..932790f4 100644 --- a/tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/app/di/AndroidDITest.kt +++ b/tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/app/di/AndroidDITest.kt @@ -36,6 +36,7 @@ import net.opatry.tasks.app.presentation.UserViewModel import net.opatry.tasks.data.TaskDao import net.opatry.tasks.data.TaskListDao import net.opatry.tasks.data.TaskRepository +import net.opatry.tasks.data.TransactionRunner import net.opatry.tasks.data.UserDao import org.koin.core.annotation.KoinExperimentalAPI import org.koin.dsl.module @@ -103,7 +104,14 @@ class AndroidDITest { fun `verify app module`() { tasksAppModule.verify( injections = injectedParameters( - definition(TaskListDao::class, TaskDao::class, TaskListsApi::class, TasksApi::class, NowProvider::class), + definition( + TransactionRunner::class, + TaskListDao::class, + TaskDao::class, + TaskListsApi::class, + TasksApi::class, + NowProvider::class + ), definition(Duration::class, Logger::class), definition(Logger::class, UserDao::class, CredentialsStorage::class, UserInfoApi::class, NowProvider::class), ) diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt index 89b74e9a..f5256104 100644 --- a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt @@ -32,6 +32,7 @@ import net.opatry.google.tasks.TasksApi import net.opatry.tasks.InMemoryTasksApi import net.opatry.tasks.NowProvider import net.opatry.tasks.data.TaskRepository +import net.opatry.tasks.data.TransactionRunner internal suspend fun TaskRepository.printTaskTree() { getTaskLists().firstOrNull()?.let { taskLists -> @@ -49,6 +50,10 @@ internal suspend fun TaskRepository.printTaskTree() { } ?: println("Task lists not ready.") } +private val TestTransactionRunner = object : TransactionRunner { + override suspend fun runInTransaction(logic: suspend () -> R): R = logic() +} + internal fun runTaskRepositoryTest( taskListsApi: TaskListsApi = InMemoryTaskListsApi(), tasksApi: TasksApi = InMemoryTasksApi(), @@ -59,7 +64,14 @@ internal fun runTaskRepositoryTest( .setQueryCoroutineContext(backgroundScope.coroutineContext) .build() - val repository = TaskRepository(db.getTaskListDao(), db.getTaskDao(), taskListsApi, tasksApi, NowProvider(Clock.System::now)) + val repository = TaskRepository( + transactionRunner = TestTransactionRunner, + taskListDao = db.getTaskListDao(), + taskDao = db.getTaskDao(), + taskListsApi = taskListsApi, + tasksApi = tasksApi, + nowProvider = NowProvider(Clock.System::now) + ) try { test(repository) } catch (e: AssertionError) { diff --git a/tasks-app-shared/src/jvmMain/kotlin/net/opatry/tasks/app/di/platformModule.jvm.kt b/tasks-app-shared/src/jvmMain/kotlin/net/opatry/tasks/app/di/platformModule.jvm.kt index fc76e744..392f5a7e 100644 --- a/tasks-app-shared/src/jvmMain/kotlin/net/opatry/tasks/app/di/platformModule.jvm.kt +++ b/tasks-app-shared/src/jvmMain/kotlin/net/opatry/tasks/app/di/platformModule.jvm.kt @@ -23,9 +23,12 @@ package net.opatry.tasks.app.di import androidx.room.Room +import androidx.room.Transactor +import androidx.room.useWriterConnection import net.opatry.tasks.CredentialsStorage import net.opatry.tasks.FileCredentialsStorage import net.opatry.tasks.data.TasksAppDatabase +import net.opatry.tasks.data.TransactionRunner import org.koin.core.module.Module import org.koin.core.qualifier.named import org.koin.dsl.module @@ -52,4 +55,17 @@ actual fun platformModule(target: String): Module = module { val credentialsFile = File(get(named("app_root_dir")), "google_auth_token_cache.json") FileCredentialsStorage(credentialsFile.absolutePath) } -} \ No newline at end of file + + single { + object : TransactionRunner { + override suspend fun runInTransaction(logic: suspend () -> R): R { + val db = get() + return db.useWriterConnection { transactor -> + transactor.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + logic() + } + } + } + } + } +} diff --git a/tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/app/di/DesktopDITest.kt b/tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/app/di/DesktopDITest.kt index 665771c5..0bb61b25 100644 --- a/tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/app/di/DesktopDITest.kt +++ b/tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/app/di/DesktopDITest.kt @@ -36,6 +36,7 @@ import net.opatry.tasks.app.presentation.UserViewModel import net.opatry.tasks.data.TaskDao import net.opatry.tasks.data.TaskListDao import net.opatry.tasks.data.TaskRepository +import net.opatry.tasks.data.TransactionRunner import net.opatry.tasks.data.UserDao import org.koin.core.annotation.KoinExperimentalAPI import org.koin.dsl.module @@ -109,7 +110,14 @@ class DesktopDITest { fun `verify app module`() { tasksAppModule.verify( injections = injectedParameters( - definition(TaskListDao::class, TaskDao::class, TaskListsApi::class, TasksApi::class, NowProvider::class), + definition( + TransactionRunner::class, + TaskListDao::class, + TaskDao::class, + TaskListsApi::class, + TasksApi::class, + NowProvider::class + ), definition(Duration::class, Logger::class), definition(Logger::class, UserDao::class, CredentialsStorage::class, UserInfoApi::class, NowProvider::class), ) diff --git a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt index 321f8a3e..80b8b40d 100644 --- a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt +++ b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt @@ -257,6 +257,7 @@ fun Instant.asCompletedTaskPosition(): String { } class TaskRepository( + private val transactionRunner: TransactionRunner, private val taskListDao: TaskListDao, private val taskDao: TaskDao, private val taskListsApi: TaskListsApi, @@ -358,8 +359,11 @@ class TaskRepository( suspend fun deleteTaskList(taskListId: Long) { // TODO deal with deleted locally but not remotely yet (no internet) - val taskListEntity = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } - taskListDao.deleteTaskList(taskListId) + val taskListEntity = transactionRunner.runInTransaction { + val taskListEntity = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } + taskListDao.deleteTaskList(taskListId) + taskListEntity + } if (taskListEntity.remoteId != null) { withContext(Dispatchers.IO) { try { @@ -373,12 +377,15 @@ class TaskRepository( suspend fun renameTaskList(taskListId: Long, newTitle: String) { val now = nowProvider.now() - val taskListEntity = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } - .copy( - title = newTitle, - lastUpdateDate = now - ) - taskListDao.upsert(taskListEntity) + val taskListEntity = transactionRunner.runInTransaction { + requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } + .copy( + title = newTitle, + lastUpdateDate = now + ).also { taskListEntity -> + taskListDao.upsert(taskListEntity) + } + } if (taskListEntity.remoteId != null) { val taskList = withContext(Dispatchers.IO) { try { @@ -401,10 +408,13 @@ class TaskRepository( } suspend fun clearTaskListCompletedTasks(taskListId: Long) { - val taskList = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } - // TODO local update date task list - val completedTasks = taskDao.getCompletedTasks(taskListId) - taskDao.deleteTasks(completedTasks.map(TaskEntity::id)) + val (taskList, completedTasks) = transactionRunner.runInTransaction { + val taskList = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } + // TODO local update date task list + val completedTasks = taskDao.getCompletedTasks(taskListId) + taskDao.deleteTasks(completedTasks.map(TaskEntity::id)) + taskList to completedTasks + } if (taskList.remoteId != null) { coroutineScope { completedTasks.mapNotNull { task -> @@ -433,27 +443,36 @@ class TaskRepository( } suspend fun createTask(taskListId: Long, parentTaskId: Long? = null, title: String, notes: String = "", dueDate: Instant? = null): Long { - val taskListEntity = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } - val parentTaskEntity = parentTaskId?.let { requireNotNull(taskDao.getById(it)) { "Invalid parent task id $parentTaskId" } } - val now = nowProvider.now() - val firstPosition = 0.toTaskPosition() - val currentTasks = taskDao.getTasksFromPositionOnward(taskListId, parentTaskId, firstPosition) - .toMutableList() - val taskEntity = TaskEntity( - parentListLocalId = taskListId, - parentTaskLocalId = parentTaskId, - title = title, - notes = notes, - lastUpdateDate = now, - dueDate = dueDate, - position = firstPosition, - ) - val taskId = taskDao.insert(taskEntity) - if (currentTasks.isNotEmpty()) { - val updatedTasks = computeTaskPositions(currentTasks, newPositionStart = 1) - taskDao.upsertAll(updatedTasks) + var taskId: Long = -1 + val (taskListEntity, parentTaskEntity, taskEntity) = transactionRunner.runInTransaction { + val taskListEntity = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } + val parentTaskEntity = parentTaskId?.let { requireNotNull(taskDao.getById(it)) { "Invalid parent task id $parentTaskId" } } + val now = nowProvider.now() + val firstPosition = 0.toTaskPosition() + val currentTasks = taskDao.getTasksFromPositionOnward(taskListId, parentTaskId, firstPosition) + .toMutableList() + val taskEntity = TaskEntity( + parentListLocalId = taskListId, + parentTaskLocalId = parentTaskId, + title = title, + notes = notes, + lastUpdateDate = now, + dueDate = dueDate, + position = firstPosition, + ) + // FIXME dedicated data structure for all returned data to avoid var & Triple + val taskId_ = taskDao.insert(taskEntity) + taskId = taskId_ + if (currentTasks.isNotEmpty()) { + val updatedTasks = computeTaskPositions(currentTasks, newPositionStart = 1) + taskDao.upsertAll(updatedTasks) + } + Triple(taskListEntity, parentTaskEntity, taskEntity) } + // FIXME to be removed + require(taskId >= 0) { "Invalid task id $taskId" } + // FIXME in transaction val parentTaskRemoteId = parentTaskEntity?.remoteId ?: parentTaskId?.let { taskListDao.getById(it) }?.remoteId if (taskListEntity.remoteId != null) { @@ -472,17 +491,19 @@ class TaskRepository( } suspend fun deleteTask(taskId: Long) { - val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } - // TODO pending deletion? - taskDao.deleteTask(taskId) + val (taskListRemoteId, taskEntity) = transactionRunner.runInTransaction { + val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } + // TODO pending deletion? + taskDao.deleteTask(taskId) + + val tasksToUpdated = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, taskEntity.parentTaskLocalId, taskEntity.position) + if (tasksToUpdated.isNotEmpty()) { + val updatedTasks = computeTaskPositions(tasksToUpdated, newPositionStart = taskEntity.position.toInt()) + taskDao.upsertAll(updatedTasks) + } - val tasksToUpdated = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, taskEntity.parentTaskLocalId, taskEntity.position) - if (tasksToUpdated.isNotEmpty()) { - val updatedTasks = computeTaskPositions(tasksToUpdated, newPositionStart = taskEntity.position.toInt()) - taskDao.upsertAll(updatedTasks) + taskListDao.getById(taskEntity.parentListLocalId)?.remoteId to taskEntity } - - val taskListRemoteId = taskListDao.getById(taskEntity.parentListLocalId)?.remoteId if (taskListRemoteId != null && taskEntity.remoteId != null) { withContext(Dispatchers.IO) { try { @@ -500,12 +521,16 @@ class TaskRepository( private suspend fun applyTaskUpdate(taskId: Long, updateLogic: suspend (TaskEntity, Instant) -> TaskEntity?) { val now = nowProvider.now() - val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } - val updatedTaskEntity = updateLogic(taskEntity, now) ?: return + val (taskListRemoteId, updatedTaskEntity) = transactionRunner.runInTransaction { + val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } + // FIXME early exit, how? +// val updatedTaskEntity = updateLogic(taskEntity, now) ?: return + val updatedTaskEntity = updateLogic(taskEntity, now) ?: error("TODO") - taskDao.upsert(updatedTaskEntity) + taskDao.upsert(updatedTaskEntity) - val taskListRemoteId = taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId + taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId to updatedTaskEntity + } if (taskListRemoteId != null && updatedTaskEntity.remoteId != null) { val task = withContext(Dispatchers.IO) { try { @@ -575,40 +600,47 @@ class TaskRepository( } suspend fun indentTask(taskId: Long) { - val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } - require(taskEntity.parentTaskLocalId == null) { "Cannot indent subtask" } - val parentTaskEntity = requireNotNull(taskDao.getPreviousSiblingTask(taskEntity)) { - "Cannot indent top level task at first position" - } - require(parentTaskEntity.parentTaskLocalId == null) { "Parent task must be a top level task" } + var previousSubtaskRemoteId: String? = null + val (taskListRemoteId, parentTaskEntity, updatedTaskEntity) = transactionRunner.runInTransaction { + val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } + require(taskEntity.parentTaskLocalId == null) { "Cannot indent subtask" } + val parentTaskEntity = requireNotNull(taskDao.getPreviousSiblingTask(taskEntity)) { + "Cannot indent top level task at first position" + } + require(parentTaskEntity.parentTaskLocalId == null) { "Parent task must be a top level task" } - val subTasks = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, taskEntity.id, 0.toTaskPosition()) - require(subTasks.isEmpty()) { "Cannot indent task with subtasks" } + val subTasks = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, taskEntity.id, 0.toTaskPosition()) + require(subTasks.isEmpty()) { "Cannot indent task with subtasks" } - val now = nowProvider.now() - val targetPosition = Int.MAX_VALUE.toTaskPosition() - val updatedTaskEntity = taskEntity.copy( - parentTaskLocalId = parentTaskEntity.id, - lastUpdateDate = now, - position = targetPosition, - ) - - // compute final subtasks position and find previous sibling subtask - val subtasksToUpdate = taskDao.getTasksUpToPosition(taskEntity.parentListLocalId, parentTaskEntity.id, targetPosition) - .toMutableList() - val previousSubtaskRemoteId = subtasksToUpdate.lastOrNull()?.remoteId - subtasksToUpdate += updatedTaskEntity - val updatedSubtaskEntities = computeTaskPositions(subtasksToUpdate) - taskDao.upsertAll(updatedSubtaskEntities) - - // indent tasks position after the indented task - val tasksToUpdate = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, null, taskEntity.position) - if (tasksToUpdate.isNotEmpty()) { - val updatedTaskEntities = computeTaskPositions(tasksToUpdate, taskEntity.position.toInt()) - taskDao.upsertAll(updatedTaskEntities) - } + val now = nowProvider.now() + val targetPosition = Int.MAX_VALUE.toTaskPosition() + val updatedTaskEntity = taskEntity.copy( + parentTaskLocalId = parentTaskEntity.id, + lastUpdateDate = now, + position = targetPosition, + ) + + // compute final subtasks position and find previous sibling subtask + val subtasksToUpdate = taskDao.getTasksUpToPosition(taskEntity.parentListLocalId, parentTaskEntity.id, targetPosition) + .toMutableList() + val previousSubtaskRemoteId_ = subtasksToUpdate.lastOrNull()?.remoteId + subtasksToUpdate += updatedTaskEntity + val updatedSubtaskEntities = computeTaskPositions(subtasksToUpdate) + taskDao.upsertAll(updatedSubtaskEntities) + + // indent tasks position after the indented task + val tasksToUpdate = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, null, taskEntity.position) + if (tasksToUpdate.isNotEmpty()) { + val updatedTaskEntities = computeTaskPositions(tasksToUpdate, taskEntity.position.toInt()) + taskDao.upsertAll(updatedTaskEntities) + } + + val taskListRemoteId = taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId - val taskListRemoteId = taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId + // FIXME + previousSubtaskRemoteId = previousSubtaskRemoteId_ + Triple(taskListRemoteId, parentTaskEntity, updatedTaskEntity) + } if (taskListRemoteId != null && updatedTaskEntity.remoteId != null) { val task = withContext(Dispatchers.IO) { try { @@ -631,36 +663,40 @@ class TaskRepository( } suspend fun unindentTask(taskId: Long) { - val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } - val parentTaskId = requireNotNull(taskEntity.parentTaskLocalId) { "Cannot unindent top level task" } - val parentTaskEntity = requireNotNull(taskDao.getById(parentTaskId)) { "Invalid parent task id ${taskEntity.parentTaskLocalId}" } + val (taskListRemoteId, parentTaskEntity, updatedTaskEntity) = transactionRunner.runInTransaction { + val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } + val parentTaskId = requireNotNull(taskEntity.parentTaskLocalId) { "Cannot unindent top level task" } + val parentTaskEntity = requireNotNull(taskDao.getById(parentTaskId)) { "Invalid parent task id ${taskEntity.parentTaskLocalId}" } + + val now = nowProvider.now() + val parentTaskPosition = parentTaskEntity.position + val newPosition = parentTaskPosition.toInt() + 1 + + val updatedTaskEntity = taskEntity.copy( + parentTaskLocalId = null, + lastUpdateDate = now, + position = newPosition.toTaskPosition(), + ) - val now = nowProvider.now() - val parentTaskPosition = parentTaskEntity.position - val newPosition = parentTaskPosition.toInt() + 1 - - val updatedTaskEntity = taskEntity.copy( - parentTaskLocalId = null, - lastUpdateDate = now, - position = newPosition.toTaskPosition(), - ) - - // compute final subtasks position - val subtasksToUpdate = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, parentTaskEntity.id, taskEntity.position) - .toMutableList() - subtasksToUpdate.removeIf { it.id == taskEntity.id } - val updatedSubtaskEntities = computeTaskPositions(subtasksToUpdate, taskEntity.position.toInt()) - taskDao.upsertAll(updatedSubtaskEntities) - - // compute final tasks position - val tasksToUpdate = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, null, updatedTaskEntity.position) - .toMutableList() - // put the updated task at the beginning of the list to enforce proper ordering - tasksToUpdate.add(0, updatedTaskEntity) - val updatedTaskEntities = computeTaskPositions(tasksToUpdate, newPositionStart = newPosition) - taskDao.upsertAll(updatedTaskEntities) - - val taskListRemoteId = taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId + // compute final subtasks position + val subtasksToUpdate = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, parentTaskEntity.id, taskEntity.position) + .toMutableList() + subtasksToUpdate.removeIf { it.id == taskEntity.id } + val updatedSubtaskEntities = computeTaskPositions(subtasksToUpdate, taskEntity.position.toInt()) + taskDao.upsertAll(updatedSubtaskEntities) + + // compute final tasks position + val tasksToUpdate = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, null, updatedTaskEntity.position) + .toMutableList() + // put the updated task at the beginning of the list to enforce proper ordering + tasksToUpdate.add(0, updatedTaskEntity) + val updatedTaskEntities = computeTaskPositions(tasksToUpdate, newPositionStart = newPosition) + taskDao.upsertAll(updatedTaskEntities) + + val taskListRemoteId = taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId + + Triple(taskListRemoteId, parentTaskEntity, updatedTaskEntity) + } if (taskListRemoteId != null && updatedTaskEntity.remoteId != null) { val task = withContext(Dispatchers.IO) { try { @@ -683,22 +719,26 @@ class TaskRepository( } suspend fun moveToTop(taskId: Long) { - val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } - require(!taskEntity.isCompleted) { "Can't move completed tasks" } - val now = nowProvider.now() - val updatedTaskEntity = taskEntity.copy( - position = 0.toTaskPosition(), - lastUpdateDate = now, - ) - val tasksToUpdate = taskDao.getTasksUpToPosition(taskEntity.parentListLocalId, null, taskEntity.position) - .toMutableList() - tasksToUpdate.removeIf { it.id == taskEntity.id } - // put the updated task at the beginning of the list to enforce proper ordering - tasksToUpdate.add(0, updatedTaskEntity) - val updatedTaskEntities = computeTaskPositions(tasksToUpdate) - taskDao.upsertAll(updatedTaskEntities) - - val taskListRemoteId = taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId + val (taskListRemoteId, updatedTaskEntity) = transactionRunner.runInTransaction { + val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } + require(!taskEntity.isCompleted) { "Can't move completed tasks" } + val now = nowProvider.now() + val updatedTaskEntity = taskEntity.copy( + position = 0.toTaskPosition(), + lastUpdateDate = now, + ) + val tasksToUpdate = taskDao.getTasksUpToPosition(taskEntity.parentListLocalId, null, taskEntity.position) + .toMutableList() + tasksToUpdate.removeIf { it.id == taskEntity.id } + // put the updated task at the beginning of the list to enforce proper ordering + tasksToUpdate.add(0, updatedTaskEntity) + val updatedTaskEntities = computeTaskPositions(tasksToUpdate) + taskDao.upsertAll(updatedTaskEntities) + + val taskListRemoteId = taskListDao.getById(updatedTaskEntity.parentListLocalId)?.remoteId + + taskListRemoteId to updatedTaskEntity + } if (taskListRemoteId != null && updatedTaskEntity.remoteId != null) { val task = withContext(Dispatchers.IO) { try { @@ -721,43 +761,47 @@ class TaskRepository( } suspend fun moveToList(taskId: Long, destinationListId: Long) { - val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } - val destinationTaskListEntity = requireNotNull(taskListDao.getById(destinationListId)) { "Invalid task list id $destinationListId" } + val (taskListRemoteId, newTaskListRemoteId, updatedTaskEntity) = transactionRunner.runInTransaction { + val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } + val destinationTaskListEntity = requireNotNull(taskListDao.getById(destinationListId)) { "Invalid task list id $destinationListId" } + + val now = nowProvider.now() + val updatedTaskEntity = taskEntity.copy( + parentListLocalId = destinationListId, + lastUpdateDate = now, + position = 0.toTaskPosition(), + ) + taskDao.upsert(updatedTaskEntity) + + // update positions of source list + val initialTasksAfter = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, null, taskEntity.position) + .toMutableList() + if (initialTasksAfter.isNotEmpty()) { + val updatedTasks = computeTaskPositions(initialTasksAfter, taskEntity.position.toInt()) + taskDao.upsertAll(updatedTasks) + } - val now = nowProvider.now() - val updatedTaskEntity = taskEntity.copy( - parentListLocalId = destinationListId, - lastUpdateDate = now, - position = 0.toTaskPosition(), - ) - taskDao.upsert(updatedTaskEntity) - - // update positions of source list - val initialTasksAfter = taskDao.getTasksFromPositionOnward(taskEntity.parentListLocalId, null, taskEntity.position) - .toMutableList() - if (initialTasksAfter.isNotEmpty()) { - val updatedTasks = computeTaskPositions(initialTasksAfter, taskEntity.position.toInt()) - taskDao.upsertAll(updatedTasks) - } - - // update positions of destination list - val destinationTasksAfter = taskDao.getTasksUpToPosition(updatedTaskEntity.id, null, updatedTaskEntity.position) - .toMutableList() - destinationTasksAfter.removeIf { it.id == updatedTaskEntity.id } - if (destinationTasksAfter.isNotEmpty()) { - val updatedTaskEntities = computeTaskPositions(destinationTasksAfter, newPositionStart = 1) - taskDao.upsertAll(updatedTaskEntities) - } + // update positions of destination list + val destinationTasksAfter = taskDao.getTasksUpToPosition(updatedTaskEntity.id, null, updatedTaskEntity.position) + .toMutableList() + destinationTasksAfter.removeIf { it.id == updatedTaskEntity.id } + if (destinationTasksAfter.isNotEmpty()) { + val updatedTaskEntities = computeTaskPositions(destinationTasksAfter, newPositionStart = 1) + taskDao.upsertAll(updatedTaskEntities) + } + + // FIXME should already be available in entity, quick & dirty workaround + val newTaskListRemoteId = destinationTaskListEntity.remoteId + val taskListRemoteId = taskListDao.getById(taskEntity.parentListLocalId)?.remoteId - // FIXME should already be available in entity, quick & dirty workaround - val newTaskListRemoteId = destinationTaskListEntity.remoteId - val taskListRemoteId = taskListDao.getById(taskEntity.parentListLocalId)?.remoteId - if (taskListRemoteId != null && taskEntity.remoteId != null && newTaskListRemoteId != null) { + Triple(taskListRemoteId, newTaskListRemoteId, updatedTaskEntity) + } + if (taskListRemoteId != null && updatedTaskEntity.remoteId != null && newTaskListRemoteId != null) { val task = withContext(Dispatchers.IO) { try { tasksApi.move( taskListId = taskListRemoteId, - taskId = taskEntity.remoteId, + taskId = updatedTaskEntity.remoteId, parentTaskId = null, previousTaskId = null, destinationTaskListId = newTaskListRemoteId, diff --git a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TransactionRunner.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TransactionRunner.kt new file mode 100644 index 00000000..546bb2ec --- /dev/null +++ b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TransactionRunner.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data + + +interface TransactionRunner { + suspend fun runInTransaction(logic: suspend () -> R): R +}