0. はじめに
本ドキュメントは、基本設計書に基づき、メモ編集機能の実装に必要な各コンポーネントの仕様をコードレベルで定義するものである。
1. データ層詳細設計 (Data Layer) 🏛️
設計のポイント: アプリケーションの土台となるデータ構造を最初に確定させます。ここが安定することで、上位のレイヤー(ロジック、UI)は安心してこのデータ構造を前提に実装を進められます。
1.1. Memo Entity (data/model/Memo.kt)
titleとupdatedAtを追加し、既存の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)
getMemoByIdとupdateMemoを追加し、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の変更に合わせてgetMemoByIdとupdateMemoを呼び出すメソッドを追加します。
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)
}
}
}