Android实战项目③ Room+Clean Architecture开发待办事项App 完整源码详解

@TOC(Android实战项目③ Room+Clean Architecture开发待办事项App 完整源码详解)# Android实战项目③ Room+Clean Architecture开发待办事项App 完整源码详解> 系列第3篇。学习Room数据库(Entity+DAO+Database)、DataStore偏好存储、Clean Architecture分层架构和Hilt接口绑定。---## 项目结构03-todo/app/src/main/java/├── com/example/todo/MainActivity.kt├── com/example/todo/MainApplication.kt├── com/example/todo/data/local/TodoDao.kt├── com/example/todo/data/local/TodoDatabase.kt├── com/example/todo/data/local/entity/TodoEntity.kt├── com/example/todo/data/repository/TodoRepositoryImpl.kt├── com/example/todo/di/DatabaseModule.kt├── com/example/todo/domain/model/Todo.kt├── com/example/todo/domain/model/TodoCategory.kt├── com/example/todo/domain/model/TodoPriority.kt├── com/example/todo/domain/repository/TodoRepository.kt├── com/example/todo/navigation/NavGraph.kt├── com/example/todo/ui/component/TodoItem.kt├── com/example/todo/ui/screen/AddEditTodoScreen.kt├── com/example/todo/ui/screen/TodoListScreen.kt├── com/example/todo/ui/theme/Theme.kt├── com/example/todo/viewmodel/AddEditTodoViewModel.kt├── com/example/todo/viewmodel/TodoListViewModel.kt文件数 : 18个Kotlin文件---## 完整源码 + 详解### MainActivity.ktkotlinpackage com.example.todoimport android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.navigation.compose.rememberNavControllerimport com.example.todo.navigation.TodoNavGraphimport com.example.todo.ui.theme.TodoThemeimport dagger.hilt.android.AndroidEntryPoint@AndroidEntryPointclass MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { TodoTheme { TodoNavGraph(rememberNavController()) } } }}---### MainApplication.ktkotlinpackage com.example.todoimport android.app.Applicationimport dagger.hilt.android.HiltAndroidApp@HiltAndroidAppclass MainApplication : Application()---### NavGraph.ktkotlinpackage com.example.todo.navigationimport androidx.compose.runtime.Composableimport androidx.navigation.NavHostControllerimport androidx.navigation.NavTypeimport androidx.navigation.compose.NavHostimport androidx.navigation.compose.composableimport androidx.navigation.navArgumentimport com.example.todo.ui.screen.AddEditTodoScreenimport com.example.todo.ui.screen.TodoListScreen@Composablefun TodoNavGraph(navController: NavHostController) { NavHost(navController, startDestination = "list") { composable("list") { TodoListScreen(onAddClick = { navController.navigate("add") }, onTodoClick = { id -> navController.navigate("edit/$id") }) } composable("add") { AddEditTodoScreen(onBack = { navController.popBackStack() }) } composable("edit/{todoId}", arguments = listOf(navArgument("todoId") { type = NavType.LongType })) { AddEditTodoScreen(onBack = { navController.popBackStack() }) } }}---### AddEditTodoViewModel.ktkotlinpackage com.example.todo.viewmodelimport androidx.lifecycle.SavedStateHandleimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.example.todo.domain.model.Todoimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.model.TodoPriorityimport com.example.todo.domain.repository.TodoRepositoryimport dagger.hilt.android.lifecycle.HiltViewModelimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.launchimport javax.inject.Inject@HiltViewModelclass AddEditTodoViewModel @Inject constructor( private val repository: TodoRepository, savedStateHandle: SavedStateHandle) : ViewModel() { private val todoId: Long = savedStateHandle.get<Long>("todoId") ?: -1L val title = MutableStateFlow("") val description = MutableStateFlow("") val category = MutableStateFlow(TodoCategory.OTHER) val priority = MutableStateFlow(TodoPriority.MEDIUM) private val _saved = MutableStateFlow(false) val saved = _saved.asStateFlow() init { if (todoId > 0) { viewModelScope.launch { repository.getTodoById(todoId)?.let { todo -> title.value = todo.title description.value = todo.description category.value = todo.category priority.value = todo.priority } } } } fun save() { if (title.value.isBlank()) return viewModelScope.launch { val todo = Todo( id = if (todoId > 0) todoId else 0, title = title.value, description = description.value, category = category.value, priority = priority.value ) if (todoId > 0) repository.updateTodo(todo) else repository.insertTodo(todo) _saved.value = true } }}---### TodoListViewModel.ktkotlinpackage com.example.todo.viewmodelimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.example.todo.domain.model.Todoimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.repository.TodoRepositoryimport dagger.hilt.android.lifecycle.HiltViewModelimport kotlinx.coroutines.ExperimentalCoroutinesApiimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launchimport javax.inject.Inject@HiltViewModelclass TodoListViewModel @Inject constructor( private val repository: TodoRepository) : ViewModel() { private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() private val _selectedCategory = MutableStateFlow<TodoCategory?>(null) val selectedCategory: StateFlow<TodoCategory?> = _selectedCategory.asStateFlow() @OptIn(ExperimentalCoroutinesApi::class) val todos: StateFlow<List<Todo>> = _searchQuery.flatMapLatest { query -> if (query.isBlank()) repository.getAllTodos() else repository.searchTodos(query) }.combine(_selectedCategory) { todos, category -> if (category == null) todos else todos.filter { it.category == category } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) fun onSearchChanged(query: String) { _searchQuery.value = query } fun onCategorySelected(category: TodoCategory?) { _selectedCategory.value = category } fun toggleComplete(todo: Todo) { viewModelScope.launch { repository.toggleComplete(todo.id, !todo.isCompleted) } } fun deleteTodo(todo: Todo) { viewModelScope.launch { repository.deleteTodo(todo) } }}---### Todo.ktkotlinpackage com.example.todo.domain.model/** 待办领域模型 */data class Todo( val id: Long = 0, val title: String, val description: String = "", val category: TodoCategory = TodoCategory.OTHER, val priority: TodoPriority = TodoPriority.MEDIUM, val isCompleted: Boolean = false, val dueDate: Long? = null, val createdAt: Long = System.currentTimeMillis())---### TodoCategory.ktkotlinpackage com.example.todo.domain.model/** 待办分类 */enum class TodoCategory(val displayName: String) { WORK("工作"), PERSONAL("个人"), SHOPPING("购物"), STUDY("学习"), OTHER("其他")}---### TodoPriority.ktkotlinpackage com.example.todo.domain.model/** 优先级 */enum class TodoPriority(val displayName: String, val level: Int) { HIGH("高", 3), MEDIUM("中", 2), LOW("低", 1)}---### TodoRepository.ktkotlinpackage com.example.todo.domain.repositoryimport com.example.todo.domain.model.Todoimport kotlinx.coroutines.flow.Flowinterface TodoRepository { fun getAllTodos(): Flow<List<Todo>> fun searchTodos(query: String): Flow<List<Todo>> suspend fun getTodoById(id: Long): Todo? suspend fun insertTodo(todo: Todo): Long suspend fun updateTodo(todo: Todo) suspend fun deleteTodo(todo: Todo) suspend fun toggleComplete(id: Long, completed: Boolean)}---### TodoDao.ktkotlinpackage com.example.todo.data.localimport androidx.room.*import com.example.todo.data.local.entity.TodoEntityimport kotlinx.coroutines.flow.Flow/** DAO --- 数据访问对象 */@Daointerface TodoDao { @Query("SELECT * FROM todos ORDER BY isCompleted ASC, createdAt DESC") fun getAllTodos(): Flow<List<TodoEntity>> @Query("SELECT * FROM todos WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%'") fun searchTodos(query: String): Flow<List<TodoEntity>> @Query("SELECT * FROM todos WHERE id = :id") suspend fun getTodoById(id: Long): TodoEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTodo(todo: TodoEntity): Long @Update suspend fun updateTodo(todo: TodoEntity) @Delete suspend fun deleteTodo(todo: TodoEntity) @Query("UPDATE todos SET isCompleted = :completed WHERE id = :id") suspend fun toggleComplete(id: Long, completed: Boolean)}---### TodoDatabase.ktkotlinpackage com.example.todo.data.localimport androidx.room.Databaseimport androidx.room.RoomDatabaseimport com.example.todo.data.local.entity.TodoEntity@Database(entities = [TodoEntity::class], version = 1, exportSchema = false)abstract class TodoDatabase : RoomDatabase() { abstract fun todoDao(): TodoDao}---### TodoEntity.ktkotlinpackage com.example.todo.data.local.entityimport androidx.room.Entityimport androidx.room.PrimaryKeyimport com.example.todo.domain.model.Todoimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.model.TodoPriority/** Room 数据库实体 */@Entity(tableName = "todos")data class TodoEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val title: String, val description: String = "", val category: String = "OTHER", val priority: String = "MEDIUM", val isCompleted: Boolean = false, val dueDate: Long? = null, val createdAt: Long = System.currentTimeMillis()) { fun toDomain() = Todo(id, title, description, TodoCategory.valueOf(category), TodoPriority.valueOf(priority), isCompleted, dueDate, createdAt) companion object { fun fromDomain(todo: Todo) = TodoEntity(todo.id, todo.title, todo.description, todo.category.name, todo.priority.name, todo.isCompleted, todo.dueDate, todo.createdAt) }}---### TodoRepositoryImpl.ktkotlinpackage com.example.todo.data.repositoryimport com.example.todo.data.local.TodoDaoimport com.example.todo.data.local.entity.TodoEntityimport com.example.todo.domain.model.Todoimport com.example.todo.domain.repository.TodoRepositoryimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.mapimport javax.inject.Injectimport javax.inject.Singleton@Singletonclass TodoRepositoryImpl @Inject constructor(private val dao: TodoDao) : TodoRepository { override fun getAllTodos(): Flow<List<Todo>> = dao.getAllTodos().map { list -> list.map { it.toDomain() } } override fun searchTodos(query: String): Flow<List<Todo>> = dao.searchTodos(query).map { list -> list.map { it.toDomain() } } override suspend fun getTodoById(id: Long): Todo? = dao.getTodoById(id)?.toDomain() override suspend fun insertTodo(todo: Todo): Long = dao.insertTodo(TodoEntity.fromDomain(todo)) override suspend fun updateTodo(todo: Todo) = dao.updateTodo(TodoEntity.fromDomain(todo)) override suspend fun deleteTodo(todo: Todo) = dao.deleteTodo(TodoEntity.fromDomain(todo)) override suspend fun toggleComplete(id: Long, completed: Boolean) = dao.toggleComplete(id, completed)}---### AddEditTodoScreen.ktkotlinpackage com.example.todo.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.automirrored.filled.ArrowBackimport androidx.compose.material.icons.filled.Checkimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.hilt.navigation.compose.hiltViewModelimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.model.TodoPriorityimport com.example.todo.viewmodel.AddEditTodoViewModel@OptIn(ExperimentalMaterial3Api::class)@Composablefun AddEditTodoScreen(viewModel: AddEditTodoViewModel = hiltViewModel(), onBack: () -> Unit) { val title by viewModel.title.collectAsState() val description by viewModel.description.collectAsState() val category by viewModel.category.collectAsState() val priority by viewModel.priority.collectAsState() val saved by viewModel.saved.collectAsState() LaunchedEffect(saved) { if (saved) onBack() } Scaffold(topBar = { TopAppBar(title = { Text("编辑待办") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") } }, actions = { IconButton(onClick = { viewModel.save() }) { Icon(Icons.Default.Check, "保存") } }) }) { padding -> Column(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField(value = title, onValueChange = { viewModel.title.value = it }, label = { Text("标题") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = description, onValueChange = { viewModel.description.value = it }, label = { Text("描述") }, modifier = Modifier.fillMaxWidth(), minLines = 3) // 分类选择 Text("分类", style = MaterialTheme.typography.labelLarge) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TodoCategory.entries.forEach { cat -> FilterChip(selected = category == cat, onClick = { viewModel.category.value = cat }, label = { Text(cat.displayName) }) } } // 优先级 Text("优先级", style = MaterialTheme.typography.labelLarge) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TodoPriority.entries.forEach { p -> FilterChip(selected = priority == p, onClick = { viewModel.priority.value = p }, label = { Text(p.displayName) }) } } } }}---### TodoListScreen.ktkotlinpackage com.example.todo.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.LazyRowimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Addimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.hilt.navigation.compose.hiltViewModelimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.ui.component.TodoItemimport com.example.todo.viewmodel.TodoListViewModel@OptIn(ExperimentalMaterial3Api::class)@Composablefun TodoListScreen( viewModel: TodoListViewModel = hiltViewModel(), onAddClick: () -> Unit, onTodoClick: (Long) -> Unit) { val todos by viewModel.todos.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() val selectedCategory by viewModel.selectedCategory.collectAsState() Scaffold( topBar = { TopAppBar(title = { Text("待办事项") }) }, floatingActionButton = { FloatingActionButton(onClick = onAddClick) { Icon(Icons.Default.Add, "新增") } } ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { // 搜索 OutlinedTextField(value = searchQuery, onValueChange = { viewModel.onSearchChanged(it) }, placeholder = { Text("搜索...") }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), singleLine = true) // 分类过滤 LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { item { FilterChip(selected = selectedCategory == null, onClick = { viewModel.onCategorySelected(null) }, label = { Text("全部") }) } items(TodoCategory.entries.toList()) { cat -> FilterChip(selected = selectedCategory == cat, onClick = { viewModel.onCategorySelected(cat) }, label = { Text(cat.displayName) }) } } // 列表 LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { items(todos, key = { it.id }) { todo -> TodoItem(todo = todo, onToggle = { viewModel.toggleComplete(todo) }, onDelete = { viewModel.deleteTodo(todo) }, onClick = { onTodoClick(todo.id) }) } } } }}---### Theme.ktkotlinpackage com.example.todo.ui.themeimport android.os.Buildimport androidx.compose.foundation.isSystemInDarkThemeimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.platform.LocalContext@Composablefun TodoTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colorScheme = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val ctx = LocalContext.current if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) } darkTheme -> darkColorScheme() else -> lightColorScheme() } MaterialTheme(colorScheme = colorScheme, content = content)}---### TodoItem.ktkotlinpackage com.example.todo.ui.componentimport androidx.compose.foundation.clickableimport androidx.compose.foundation.layout.*import androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Deleteimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.text.style.TextDecorationimport androidx.compose.ui.text.style.TextOverflowimport androidx.compose.ui.unit.dpimport com.example.todo.domain.model.Todo@Composablefun TodoItem(todo: Todo, onToggle: () -> Unit, onDelete: () -> Unit, onClick: () -> Unit) { Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = todo.isCompleted, onCheckedChange = { onToggle() }) Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text( todo.title, style = MaterialTheme.typography.titleSmall, textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else null, maxLines = 1, overflow = TextOverflow.Ellipsis ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip(onClick = {}, label = { Text(todo.category.displayName) }) AssistChip(onClick = {}, label = { Text(todo.priority.displayName) }) } } IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, "删除") } } }}---### DatabaseModule.ktkotlinpackage com.example.todo.diimport android.content.Contextimport androidx.room.Roomimport com.example.todo.data.local.TodoDaoimport com.example.todo.data.local.TodoDatabaseimport com.example.todo.data.repository.TodoRepositoryImplimport com.example.todo.domain.repository.TodoRepositoryimport dagger.Bindsimport dagger.Moduleimport dagger.Providesimport dagger.hilt.InstallInimport dagger.hilt.android.qualifiers.ApplicationContextimport dagger.hilt.components.SingletonComponentimport javax.inject.Singleton@Module@InstallIn(SingletonComponent::class)object DatabaseModule { @Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): TodoDatabase = Room.databaseBuilder(context, TodoDatabase::class.java, "todo.db").build() @Provides fun provideTodoDao(db: TodoDatabase): TodoDao = db.todoDao()}@Module@InstallIn(SingletonComponent::class)abstract class RepositoryModule { @Binds @Singleton abstract fun bindTodoRepository(impl: TodoRepositoryImpl): TodoRepository}---## 运行方式1. Android Studio打开 projects/03-todo 目录2. Gradle同步完成后Run运行> 系列导航: Android实战系列 1-6 篇。

相关推荐
Dovis(誓平步青云)1 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码3 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程6 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程7 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程8 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程8 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
事圆则缓8 小时前
Android AOSP 定制常见概念:源码目录、系统镜像与刷机流程
android
传奇开心果编程8 小时前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer
supabc1238 小时前
Celium:连接 Windows、Mac、Linux 与 Android,让远程访问和设备管理更简单
android·linux·windows·macos·远程访问·网络管理·celium