0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Androidアプリ】メモ編集機能 - 詳細設計書 (最終版)

Posted at

0. はじめに

本ドキュメントは、基本設計書に基づき、メモ編集機能の実装に必要な各コンポーネントの仕様をコードレベルで定義するものである。

1. データ層詳細設計 (Data Layer) 🏛️

設計のポイント: アプリケーションの土台となるデータ構造を最初に確定させます。ここが安定することで、上位のレイヤー(ロジック、UI)は安心してこのデータ構造を前提に実装を進められます。

1.1. Memo Entity (data/model/Memo.kt)

titleupdatedAtを追加し、既存のcreatedAtと合わせて日時情報を管理します。

package com.example.simplememoapp_android.data.model

import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDateTime

@Entity(tableName = "memos")
data class Memo(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String, // タイトル(Not Null)
    val content: String,
    val createdAt: LocalDateTime,
    val updatedAt: LocalDateTime // 更新日時
)

1.2. MemoDao Interface (data/local/dao/MemoDao.kt)

getMemoByIdupdateMemoを追加し、getAllMemosのソート順をupdatedAtに変更します。

package com.example.simplememoapp_android.data.local.dao

import androidx.room.*
import com.example.simplememoapp_android.data.model.Memo
import kotlinx.coroutines.flow.Flow

@Dao
interface MemoDao {
    @Query("SELECT * FROM memos ORDER BY updatedAt DESC") // ソート順を更新日時に変更
    fun getAllMemos(): Flow<List<Memo>>

    @Query("SELECT * FROM memos WHERE id = :id")
    fun getMemoById(id: Long): Flow<Memo>

    @Insert
    suspend fun insertMemo(memo: Memo)

    @Update
    suspend fun updateMemo(memo: Memo)

    @Delete
    suspend fun deleteMemo(memo: Memo)
}

1.3. AppDatabase Class (data/local/AppDatabase.kt)

データベースのバージョンを2に更新し、バージョン1から2へのマイグレーションを定義します。

設計のポイント: Migrationオブジェクトを定義し、Roomのビルド時にaddMigrations()で渡すのが定石です。これにより、Roomがバージョンアップを検知した際に、定義されたSQLを実行してくれます。

package com.example.simplememoapp_android.data.local

import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.example.simplememoapp_android.data.local.dao.MemoDao
import com.example.simplememoapp_android.data.model.Memo

@Database(entities = [Memo::class], version = 2) // ★ versionを2に更新
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {

    abstract fun memoDao(): MemoDao

    companion object {
        // ★ バージョン1から2へのMigrationを定義
        val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // memosテーブルにtitleとupdatedAtカラムを追加するSQL
                database.execSQL("ALTER TABLE memos ADD COLUMN title TEXT NOT NULL DEFAULT ''")
                database.execSQL("ALTER TABLE memos ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0")
            }
        }
    }
}
// Room.databaseBuilder()でDBを生成する際に.addMigrations(MIGRATION_1_2)を呼び出す

2. ロジック層詳細設計 (Logic Layer) 🧠

2.1. MemoRepository Class (data/repository/MemoRepository.kt)

DAOの変更に合わせてgetMemoByIdupdateMemoを呼び出すメソッドを追加します。

package com.example.simplememoapp_android.data.repository

import com.example.simplememoapp_android.data.local.dao.MemoDao
import com.example.simplememoapp_android.data.model.Memo
import kotlinx.coroutines.flow.Flow

class MemoRepository(private val memoDao: MemoDao) {
    fun getMemos(): Flow<List<Memo>> = memoDao.getAllMemos()

    fun getMemoById(id: Long): Flow<Memo> = memoDao.getMemoById(id) // ★追加

    suspend fun addMemo(memo: Memo) = memoDao.insertMemo(memo)

    suspend fun updateMemo(memo: Memo) = memoDao.updateMemo(memo) // ★追加

    suspend fun deleteMemo(memo: Memo) = memoDao.deleteMemo(memo)
}

2.2. MemoDetailViewModel (ui/viewmodel/MemoDetailViewModel.kt)

設計のポイント:

  • SavedStateHandleを使ってNavHostから渡されたmemoIdを安全に受け取ります。
  • 画面の状態はMemoDetailUiStateに集約し、StateFlowでUIに公開します。
  • ユーザー操作はonEventのような単一の関数で受け取り、whenで処理を分岐させるとコードの見通しが良くなります。
2.2.1. UI State
// ui/state/MemoDetailUiState.kt

import java.time.LocalDateTime

data class MemoDetailUiState(
    val title: String = "",
    val content: String = "",
    val createdAt: LocalDateTime? = null, // ★ createdAtを追加(初回ロード前はnull)
    val isSavable: Boolean = false, // 保存ボタンが押せるか
    val isLoading: Boolean = true, // データ読み込み中か
    val isFinished: Boolean = false // 処理が完了し画面を閉じるか
)
2.2.2. ViewModel本体
// ui/viewmodel/MemoDetailViewModel.kt

@HiltViewModel
class MemoDetailViewModel @Inject constructor(
    private val repository: MemoRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _uiState = MutableStateFlow(MemoDetailUiState())
    val uiState: StateFlow<MemoDetailUiState> = _uiState.asStateFlow()

    private val memoId: Long = savedStateHandle.get("memoId") ?: -1L

    // ... EventFlowの定義(既存と同様) ...

    init {
        if (memoId != -1L) {
            // 編集モード:DBからメモを取得
            viewModelScope.launch {
                repository.getMemoById(memoId).collect { memo ->
                    _uiState.value = _uiState.value.copy(
                        title = memo.title,
                        content = memo.content,
                        createdAt = memo.createdAt, // ★ createdAtをセット
                        isLoading = false
                    )
                }
            }
        } else {
            // 新規作成モード
            _uiState.value = MemoDetailUiState(isLoading = false)
        }
    }

    fun onTitleChange(newTitle: String) {
        _uiState.value = _uiState.value.copy(title = newTitle, isSavable = true)
    }

    fun onContentChange(newContent: String) {
        _uiState.value = _uiState.value.copy(content = newContent, isSavable = true)
    }

    fun saveMemo() {
        viewModelScope.launch {
            val now = LocalDateTime.now()

            // ★ stateからcreatedAtを取得。nullの場合は新規作成なので now を使う
            val createdAt = uiState.value.createdAt ?: now
            val memoToSave = Memo(
                id = if (memoId != -1L) memoId else 0,
                title = uiState.value.title,
                content = uiState.value.content,
                createdAt = createdAt, // ★ 保持していたcreatedAtを使用
                updatedAt = now
            )

            try {
                if (memoId != -1L) {
                    repository.updateMemo(memoToSave)
                } else {
                    repository.addMemo(memoToSave)
                }
                _uiState.value = _uiState.value.copy(isFinished = true)
            } catch (e: Exception) {
                // _eventFlow.emit(...) でSnackbar表示イベントを通知
            }
        }
    }
}

3. UI層詳細設計 (UI Layer) 🎨

設計のポイント: 画面(Screen)はViewModelと接続する役割に徹し、実際のUI部品は状態を持たないStatelessなComposableとして小さく分割します。これにより、各部品を個別にプレビューしたり、再利用したりするのが容易になります。

3.1. MemoDetailScreen (ui/screen/MemoDetailScreen.kt)

ViewModelからStateを受け取り、Statelessな子コンポーネントに渡します。

@Composable
fun MemoDetailScreen(
    navController: NavController,
    viewModel: MemoDetailViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()

    // isFinishedがtrueになったら一覧画面に戻る
    LaunchedEffect(uiState.isFinished) {
        if (uiState.isFinished) {
            navController.popBackStack()
        }
    }

    Scaffold(
        topBar = {
            MemoDetailTopAppBar(
                isSavable = uiState.isSavable,
                onNavigateBack = { navController.popBackStack() },
                onSaveClick = { viewModel.saveMemo() }
            )
        }
    ) { paddingValues ->
        MemoDetailContent(
            title = uiState.title,
            content = uiState.content,
            onTitleChange = { viewModel.onTitleChange(it) },
            onContentChange = { viewModel.onContentChange(it) },
            modifier = Modifier.padding(paddingValues)
        )
    }
}

3.2. MemoDetailTopAppBar (新規作成)

状態に応じて保存ボタン(Doneアイコン)の有効/無効を切り替えます。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemoDetailTopAppBar(
    isSavable: Boolean,
    onNavigateBack: () -> Unit,
    onSaveClick: () -> Unit
) {
    TopAppBar(
        title = { Text("メモ") },
        navigationIcon = {
            IconButton(onClick = onNavigateBack) {
                Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "戻る")
            }
        },
        actions = {
            IconButton(onClick = onSaveClick, enabled = isSavable) {
                Icon(Icons.Default.Done, contentDescription = "保存")
            }
        }
    )
}

3.3. MemoDetailContent (新規作成)

タイトルと内容のTextFieldを表示し、入力イベントを親に通知します。

@Composable
fun MemoDetailContent(
    title: String,
    content: String,
    onTitleChange: (String) -> Unit,
    onContentChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
        TextField(
            value = title,
            onValueChange = onTitleChange,
            label = { Text("タイトル") },
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(modifier = Modifier.height(8.dp))
        TextField(
            value = content,
            onValueChange = onContentChange,
            label = { Text("内容") },
            modifier = Modifier.fillMaxWidth().weight(1f)
        )
    }
}

4. ナビゲーション層詳細設計 (Navigation Layer) 🔌

設計のポイント: Navigation Composeを使い、各画面へのルートと必要な引数を一元管理します。これにより、アプリ全体の画面遷移の見通しが良くなります。

// 例:AppNavHost.kt

@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(navController = navController, startDestination = "memo_list") {
        composable("memo_list") {
            MemoListScreen(navController = navController)
        }
        composable(
            route = "memo_detail/{memoId}",
            arguments = listOf(navArgument("memoId") { type = NavType.LongType })
        ) { backStackEntry ->
            // 引数の取得はViewModel内でSavedStateHandleが行うので、ここでは何もしなくて良い
            MemoDetailScreen(navController = navController)
        }
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?