[compose multiplatform 中級チュートリアル7]CRUD処理の実装

# はじめに
compose multiplatformチュートリアルです。
[前回](https://qiita.com/shunk0616/private/327b67fbe9e27645964c)まででsupabaseとの接続と、データの取得まで実装しました。
今回は、データの追加、更新、削除を実装していきます。
CRUDでいうところのCUDですね。

# TaskApiを編集する
## インターフェースを追加
TaskApi.ktのsealed interface MessageApiに、データの追加、更新、削除の関数を追加します。
```kotlin:TaskApi.kt
sealed interface TaskApi {
    suspend fun fetchAll(): List<Task>
    suspend fun add(task: Task): Task
    suspend fun update(task: Task): Task
    suspend fun delete(task: Task): Task
}
```

## 実装する
TaskApi.ktのTaskApiImplの部分を以下のように修正します。
```kotlin:TaskApi.kt
internal class TaskApiImpl(
    private val client: SupabaseClient // Supabaseクライアント
) : TaskApi {
    private val table = client.postgrest["tasks"] // Supabaseのテーブル参照

    override suspend fun fetchAll(): List<Task> {
        return table.select().decodeList() // 全タスクを取得して返す
    }

    override suspend fun add(task: Task) {
        table.insert(buildJsonObject {
            put("title", task.title) // タイトルをJSONオブジェクトに追加
            put("estimated-time", task.estimatedTime) // 見積もり時間を追加
            put("category", task.category) // カテゴリーを追加
        }) // タスクをデータベースに追加
    }

    override suspend fun update(task: Task) {
        table.update({
            Task::category setTo task.category // カテゴリーを更新
        }) {
            Task::id eq task.id // 対象タスクのIDに一致するものを更新
        }
    }

    override suspend fun delete(task: Task) {
        table.delete {
            Task::id eq task.id // 対象タスクのIDに一致するものを削除
        }
    }
}
```
毎度のことですがコードの解説はコメントアウトです。


# ListScreenModelを編集する
同じファイルの下の方で実装部分も修正して行きます。
```Kotlin:ListScreenModel.kt
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.company.saikyotodo.net.Task
import org.company.saikyotodo.net.TaskApi

class ListScreenModel(
    private val taskApi: TaskApi // TaskApiのインスタンスを注入。API呼び出し用
) : ScreenModel { // VoyagerのScreenModelを継承。画面固有のロジックやデータを保持

    private val _tasks = mutableStateOf<List<Task>>(emptyList()) // タスクのリストを保持する内部状態
    val tasks: State<List<Task>> = _tasks // _tasksの公開状態。UIからアクセスされる

    init {
        fetchTasks() // タスクのリストを取得
    }

    fun fetchTasks() {
        coroutineScope.launch { // コルーチンを開始。非同期処理を行う
            kotlin.runCatching {
                taskApi.fetchAll() // APIを呼び出して全タスクを取得
            }.onSuccess {
                _tasks.value = it // 取得成功時、タスクリストを更新
            }.onFailure {
                Logger.e(it) { "Error while fetching tasks" } // エラー発生時、ログに記録
            }
        }
    }

    fun addTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                taskApi.add(task) // APIを呼び出してタスクを追加
            }.onSuccess {
                fetchTasks() // 追加成功時、タスクリストを更新
            }.onFailure {
                Logger.e(it) { "Error while adding task" } // エラー発生時、ログに記録
            }
        }
    }

    fun updateTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                taskApi.update(task) // APIを呼び出してタスクを更新
            }.onSuccess {
                fetchTasks() // 更新成功時、タスクリストを更新
            }.onFailure {
                Logger.e(it) { "Error while updating task" } // エラー発生時、ログに記録
            }
        }
    }

    fun deleteTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                taskApi.delete(task) // APIを呼び出してタスクを削除
            }.onSuccess {
                fetchTasks() // 削除成功時、タスクリストを更新
            }.onFailure {
                Logger.e(it) { "Error while deleting task" } // エラー発生時、ログに記録
            }
        }
    }
}
```
ここでちょっと注意して欲しいのですが、今回の章ではsupabaseとのリアルタイム同期は実装していません。
追加、変更、削除を行ったら、タスクのリストを再取得しています。
なので、別の端末で行った変更がリアルタイムで反映されるわけではありません。
ここら辺はちょっとめんどくさそうなので次の章に回します。

# タスク追加フォームを復活させる
以前の章で削除してしまっていた、タスク追加フォームを復活させます。
TaskForm.ktを以下のように修正します。
```kotlin:TaskForm.kt
package org.company.saikyotodo.ui.Common.Composable

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.company.saikyotodo.net.Task

@Composable
fun TaskForm(
    onTaskAdded: (Task) -> Unit,
    categories: List<String>
) {
    // 最初にrememberを使ってステートを定義
    var newTitle by remember { mutableStateOf("") }
    var newEstimatedTime by remember { mutableStateOf(1) }
    var newCategory by remember { mutableStateOf(categories.firstOrNull() ?: "バックログ") }
    Text("タスク追加", style = TextStyle(fontSize = 16.sp))
    // タイトル入力フィールド
    TextField(
        value = newTitle,
        onValueChange = { newTitle = it },
        placeholder = { Text("タスク名") },
        singleLine = true,
        modifier = Modifier.fillMaxWidth(),
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
    )
    // 予定工数選択
    Row(verticalAlignment = Alignment.CenterVertically) {
        Button(onClick = { if(newEstimatedTime > 0) newEstimatedTime-- }) {
            Text("-")
        }
        Text(newEstimatedTime.toString(), Modifier.widthIn(min = 20.dp))
        Button(onClick = { newEstimatedTime++ }) {
            Text("+")
        }
    }
    // 保存ボタン
    Button(
        onClick = {
            onTaskAdded(Task(title = newTitle, estimatedTime = newEstimatedTime, category = newCategory ))
            // 保存したらフォームはリセット
            newTitle = ""
            newEstimatedTime = 1
            newCategory = categories.firstOrNull() ?: ""
        }
    ) {
        Text("保存")
    }
}
```
これでタスク追加フォームが復活しました。
次に呼び出していきましょう。

ListScreen.ktを以下のように修正します。
```kotlin:ListScreen.kt
package org.company.saikyotodo.ui.List

import cafe.adriel.voyager.core.screen.Screen
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.company.saikyotodo.di.getScreenModel
import org.company.saikyotodo.ui.Common.Composable.Category
import org.company.saikyotodo.ui.Common.Composable.TaskForm

class ListScreen(): Screen { // VoyagerのScreenインターフェースを実装。画面のコンポーザブルを提供
    @Composable
    override fun Content() {
        val screenModel = getScreenModel<ListScreenModel>() // ListScreenModelを取得
        var tasks = screenModel.tasks.value // タスクのリストをStateから取得
        val categories = listOf("バックログ", "対応中", "完了") // カテゴリのリストを定義

        Column(modifier = Modifier.padding(20.dp).fillMaxSize()) { // 縦に要素を配置するColumn
            // 各カテゴリに対して、Categoryコンポーザブルを呼び出す
            categories.forEach { category ->
                Category(category, tasks)
            }
            // タスク追加フォームを復活！
            TaskForm(
                onTaskAdded = { task ->
                    screenModel.addTask(task) // タスク追加時の処理をViewModelに委譲
                },
                categories = categories
            )
        }
    }
}
```

これでタスク追加フォームが復活しました。
一度アプリを立ち上げて、確認してみてください。
画面の中央から下の方にフォームが表示されているはずです。
フォームに入力された情報は別の端末や、アプリをアンインストールしたあとでも保存されているはず。

# タスクの更新と削除を実装する
タスクの更新と削除は、タスクのリストの各要素に対して、それぞれボタンを配置して、タップされたら更新、削除を行うようにします。
まずは、タスクのリストの各要素を表示するコンポーザブルを編集しましょう

ListItem.ktを以下のように修正します。
```kotlin:ListItem.kt
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.company.saikyotodo.net.Task

@Composable
fun ListItem(
    onTaskStart: (Task) -> Unit,
    onTaskDeleted: (Task) -> Unit,
    task: Task
) {
    Row {
        // タイトルと予定工数の表示
        Text("${task.title} (${task.estimatedTime}h)", modifier = Modifier.padding(4.dp))

        // カテゴリを変更するアイコン
        Icon(
            imageVector = Icons.Default.Send,
            contentDescription = "Change Category",
            modifier = Modifier
                .padding(4.dp)
                .clickable {
                    // カテゴリを変更してコールバックを呼び出す
                    val updatedTask = task.copy(category = "対応中")
                    onTaskStart(updatedTask)
                }
        )

        // ゴミ箱アイコン
        Icon(
            imageVector = Icons.Default.Delete,
            contentDescription = "Delete Task",
            modifier = Modifier
                .padding(4.dp)
                .clickable {
                    // ゴミ箱アイコンをクリックしてコールバックを呼び出す
                    onTaskDeleted(task)
                }
        )
    }
}
```
次にそのListItem.ktを呼び出している、Category.ktを編集します。
```kotlin:Category.kt
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.company.saikyotodo.net.Task

// カテゴリ名と全タスクを渡す（ほんとは絞り込んだ後の方がパフォーマンスが良いが目を瞑ろう）
@Composable
fun Category(category: String, tasks: List<Task>, onTaskStart: (Task) -> Unit, onTaskDeleted: (Task) -> Unit) {
    // カテゴリ名を表示
    Text(text = category, style = TextStyle(fontSize = 20.sp), modifier = Modifier.padding(4.dp))
    // カテゴリ名が一致するタスクをListItemコンポーザブルを使って表示
    tasks.filter { it.category == category }.forEach { task ->
        ListItem(onTaskStart, onTaskDeleted, task)
    }

    // 適当な余白
    Spacer(modifier = Modifier.height(20.dp))
}
```

最後にリスト画面でコンポーネントを呼び出す時に、コールバックを渡すようにします。
ListScreen.ktを以下のように修正します。
```kotlin:ListScreen.kt
import cafe.adriel.voyager.core.screen.Screen
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.company.saikyotodo.di.getScreenModel
import org.company.saikyotodo.ui.Common.Composable.Category
import org.company.saikyotodo.ui.Common.Composable.TaskForm

class ListScreen(): Screen { // VoyagerのScreenインターフェースを実装。画面のコンポーザブルを提供
    @Composable
    override fun Content() {
        val screenModel = getScreenModel<ListScreenModel>() // ListScreenModelを取得
        var tasks = screenModel.tasks.value // タスクのリストをStateから取得
        val categories = listOf("バックログ", "対応中", "完了") // カテゴリのリストを定義

        Column(modifier = Modifier.padding(20.dp).fillMaxSize()) { // 縦に要素を配置するColumn
            // 各カテゴリに対して、Categoryコンポーザブルを呼び出す
            categories.forEach { category ->
                Category(
                    category, tasks,
                    onTaskStart = {
                        screenModel.updateTask(it) // カテゴリ変更時の処理をViewModelに委譲
                    },
                    onTaskDeleted = {
                        screenModel.deleteTask(it) // タスク削除時の処理をViewModelに委譲
                    }
                )
            }
            TaskForm(
                onTaskAdded = { task ->
                    screenModel.addTask(task) // タスク追加時の処理をViewModelに委譲
                },
                categories = categories
            )
        }
    }
}
```

これで削除も修正も一応実装できたはず、色々動かして遊んでください〜
ちょっと実験的な試みとして、課題を出してみることにします。

# 課題
現状だと、対応中に入っているタスクを完了に移動することができません。
これを実装してみてください。
次回のチュートリアルの最初に、自分の実装を載せておくので、めんどくさいという方や、わからないという方はそっちをみに行ってみてください。

# 次
[次の記事](https://qiita.com/shunk0616/private/19a76c7d61d386b71b4e)ではリアルタイム通信を実装します。