diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt b/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt index 0c591dd..034a5c2 100644 --- a/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt +++ b/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt @@ -19,6 +19,14 @@ object Repository { } suspend fun deleteTodo(toDo: TodoEntity) { - toDoDao.delete(toDo.id) + toDoDao.delete(toDo) } + + suspend fun deleteTodoFromIds(toDoItems: List) { + toDoDao.deleteFromIds(toDoItems.toSet()) + } + + /*suspend fun deleteAllTodo() { + toDoDao.deleteAllTodo() + }*/ } \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDao.kt b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDao.kt index a0a8c91..239a664 100644 --- a/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDao.kt +++ b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDao.kt @@ -1,6 +1,7 @@ package cn.super12138.todo.logic.database import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -18,6 +19,12 @@ interface TodoDao { @Update suspend fun update(toDo: TodoEntity) - @Query("DELETE FROM todo WHERE id = :toDoId") - suspend fun delete(toDoId: Int) + @Delete + suspend fun delete(toDo: TodoEntity) + + @Query("DELETE FROM todo WHERE id in (:toDoIds)") + suspend fun deleteFromIds(toDoIds: Set) + + /*@Query("DELETE FROM todo") + suspend fun deleteAllTodo()*/ } \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/components/FloatingActionButton.kt b/app/src/main/kotlin/cn/super12138/todo/ui/components/FloatingActionButton.kt index cea5297..c3b7b27 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/components/FloatingActionButton.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/components/FloatingActionButton.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cn.super12138.todo.ui.TodoDefaults /** * 带动画且比 Material 3 内置组件动画好看的的可扩展 FAB @@ -36,7 +35,7 @@ fun AnimatedExtendedFloatingActionButton( modifier = modifier ) { Row( - modifier = Modifier.padding(horizontal = TodoDefaults.screenPadding), + modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { icon() diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/MainPage.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/MainPage.kt index 504795a..3e81df6 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/MainPage.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/MainPage.kt @@ -1,18 +1,31 @@ package cn.super12138.todo.ui.pages.main +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -44,36 +57,100 @@ fun MainPage(viewModel: MainViewModel, modifier: Modifier = Modifier) { } } - val selectedEditTodoItem = viewModel.selectedEditTodoItem + val selectedEditTodoItem = viewModel.selectedEditTodo + val selectedTodoIds = viewModel.selectedTodoIds.collectAsState() var openBottomSheet by rememberSaveable { mutableStateOf(false) } val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isSelectedIdsEmpty by remember { + derivedStateOf { + selectedTodoIds.value.isEmpty() + } + } + + val animatedTopAppBarColors by animateColorAsState( + targetValue = if (isSelectedIdsEmpty) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceContainerHighest + ) + + BackHandler(enabled = !isSelectedIdsEmpty) { + // 当按下返回键(或进行返回操作)时清空选择 + viewModel.clearAllTodoSelection() + } + Scaffold( topBar = { TopAppBar( + navigationIcon = { + AnimatedVisibility(!isSelectedIdsEmpty) { + IconButton(onClick = { viewModel.clearAllTodoSelection() }) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.tip_clear_selected_items) + ) + } + } + }, title = { - Text(stringResource(R.string.app_name)) + Text( + text = if (isSelectedIdsEmpty) { + stringResource(R.string.app_name) + } else { + stringResource( + R.string.title_selected_count, + selectedTodoIds.value.size + ) + } + ) }, actions = { - IconButton( - onClick = {} - ) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.page_settings) - ) + AnimatedContent(isSelectedIdsEmpty) { isEmpty -> + if (isEmpty) { + IconButton( + onClick = {} + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.page_settings) + ) + } + } else { + Row { + IconButton(onClick = { viewModel.toggleAllSelected() }) { + Icon( + imageVector = Icons.Outlined.SelectAll, + contentDescription = stringResource(R.string.tip_select_all) + ) + } + IconButton(onClick = { viewModel.deleteSelectedTodo() }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.action_delete) + ) + } + } + } } - } + + }, + colors = TopAppBarDefaults.largeTopAppBarColors().copy( + containerColor = animatedTopAppBarColors + ) ) }, floatingActionButton = { - TodoFAB( - expanded = isExpanded, - onClick = { openBottomSheet = true } - ) + AnimatedVisibility( + visible = isSelectedIdsEmpty, + enter = fadeIn() + expandIn(), + exit = shrinkOut() + fadeOut() + ) { + TodoFAB( + expanded = isExpanded, + onClick = { openBottomSheet = true } + ) + } }, modifier = modifier ) { innerPadding -> @@ -91,8 +168,15 @@ fun MainPage(viewModel: MainViewModel, modifier: Modifier = Modifier) { state = listState, list = toDoList.value.filter { item -> !item.isCompleted }, onItemClick = { item -> - openBottomSheet = true - viewModel.setEditTodoItem(item) + if (isSelectedIdsEmpty) { + openBottomSheet = true + viewModel.setEditTodoItem(item) + } else { + viewModel.toggleTodoSelection(item) + } + }, + onItemLongClick = { item -> + viewModel.toggleTodoSelection(item) }, onItemChecked = { item -> item.apply { @@ -107,6 +191,7 @@ fun MainPage(viewModel: MainViewModel, modifier: Modifier = Modifier) { viewModel.playConfetti() } }, + selectedTodoIds = selectedTodoIds.value, modifier = Modifier .weight(3f) .fillMaxSize() @@ -126,8 +211,15 @@ fun MainPage(viewModel: MainViewModel, modifier: Modifier = Modifier) { state = listState, list = toDoList.value.filter { item -> !item.isCompleted }, onItemClick = { item -> - openBottomSheet = true - viewModel.setEditTodoItem(item) + if (isSelectedIdsEmpty) { + openBottomSheet = true + viewModel.setEditTodoItem(item) + } else { + viewModel.toggleTodoSelection(item) + } + }, + onItemLongClick = { item -> + viewModel.toggleTodoSelection(item) }, onItemChecked = { item -> item.apply { @@ -142,6 +234,7 @@ fun MainPage(viewModel: MainViewModel, modifier: Modifier = Modifier) { viewModel.playConfetti() } }, + selectedTodoIds = selectedTodoIds.value, modifier = Modifier .weight(3f) .fillMaxSize() diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ManagerFragment.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ManagerFragment.kt index 1a3b0ab..e2096e7 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ManagerFragment.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ManagerFragment.kt @@ -29,7 +29,9 @@ fun ManagerFragment( state: LazyListState, list: List, onItemClick: (TodoEntity) -> Unit = {}, + onItemLongClick: (TodoEntity) -> Unit = {}, onItemChecked: (TodoEntity) -> Unit = {}, + selectedTodoIds: List, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -67,10 +69,12 @@ fun ManagerFragment( content = item.content, subject = Subjects.fromId(item.subject).getDisplayName(context), onCardClick = { onItemClick(item) }, + onCardLongClick = { onItemLongClick(item) }, onChecked = { onItemChecked(item) }, + selected = selectedTodoIds.contains(item.id), modifier = Modifier .padding(vertical = 5.dp) - .animateItem() + .animateItem() // TODO: 设置动画时间 ) } } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoCard.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoCard.kt index 18c121b..3e60353 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoCard.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoCard.kt @@ -1,13 +1,19 @@ package cn.super12138.todo.ui.pages.main.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check import androidx.compose.material3.ElevatedCard @@ -18,31 +24,53 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import cn.super12138.todo.R +@OptIn(ExperimentalFoundationApi::class) @Composable fun TodoCard( content: String, subject: String, onCardClick: () -> Unit = {}, + onCardLongClick: () -> Unit = {}, onChecked: () -> Unit = {}, + selected: Boolean, modifier: Modifier = Modifier ) { ElevatedCard( modifier = modifier .fillMaxWidth() - .height(80.dp), - onClick = onCardClick + .height(80.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxSize() + .combinedClickable( + onClick = onCardClick, + onLongClick = onCardLongClick + ) .padding(horizontal = 15.dp) ) { + AnimatedVisibility(selected) { + Box( + Modifier + .padding(end = 15.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSecondaryContainer) + .padding(5.dp) + ) { + Icon( + imageVector = Icons.Outlined.Check, + tint = MaterialTheme.colorScheme.onSecondary, + contentDescription = stringResource(R.string.tip_select_this) + ) + } + } Column( verticalArrangement = Arrangement.Center, modifier = Modifier @@ -63,12 +91,14 @@ fun TodoCard( ) } - IconButton(onClick = onChecked) { - Icon( - imageVector = Icons.Outlined.Check, - tint = MaterialTheme.colorScheme.primary, - contentDescription = stringResource(R.string.tip_mark_completed) - ) + AnimatedVisibility(!selected) { + IconButton(onClick = onChecked) { + Icon( + imageVector = Icons.Outlined.Check, + tint = MaterialTheme.colorScheme.primary, + contentDescription = stringResource(R.string.tip_mark_completed) + ) + } } /*Box( diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/viewmodels/MainViewModel.kt b/app/src/main/kotlin/cn/super12138/todo/ui/viewmodels/MainViewModel.kt index 3392abd..2ee1ce4 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/viewmodels/MainViewModel.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/viewmodels/MainViewModel.kt @@ -8,14 +8,22 @@ import androidx.lifecycle.viewModelScope import cn.super12138.todo.logic.Repository import cn.super12138.todo.logic.database.TodoEntity import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class MainViewModel : ViewModel() { val toDos: Flow> = Repository.getAllTodos() - var selectedEditTodoItem by mutableStateOf(null) + var selectedEditTodo by mutableStateOf(null) private set val showConfetti = mutableStateOf(false) + // 多选逻辑参考:https://github.com/X1nto/Mauth + private val _selectedTodoIds = MutableStateFlow(listOf()) + val selectedTodoIds = _selectedTodoIds.asStateFlow() + fun addTodo(toDo: TodoEntity) { viewModelScope.launch { Repository.insertTodo(toDo) @@ -28,17 +36,75 @@ class MainViewModel : ViewModel() { } } - fun playConfetti() { - showConfetti.value = true - } - - fun setEditTodoItem(toDo: TodoEntity?) { - selectedEditTodoItem = toDo - } - fun deleteTodo(toDo: TodoEntity) { viewModelScope.launch { Repository.deleteTodo(toDo) } } + + /*fun deleteAllTodo() { + viewModelScope.launch { + Repository.deleteAllTodo() + } + }*/ + + fun setEditTodoItem(toDo: TodoEntity?) { + selectedEditTodo = toDo + } + + /** + * 切换待办的选择状态 + */ + fun toggleTodoSelection(toDo: TodoEntity) { + _selectedTodoIds.update { idList -> + if (idList.contains(toDo.id)) { + // 若已经选择取消选择 + idList - toDo.id + } else { + // 若未选择添加到列表中,立即选中 + idList + toDo.id + } + } + } + + /** + * 切换是否全选 + */ + fun toggleAllSelected() { + viewModelScope.launch { + toDos.firstOrNull()?.let { todos -> + val allIds = todos.map { it.id } + _selectedTodoIds.update { currentSelectedIds -> + if (currentSelectedIds.containsAll(allIds)) { + // 如果当前是全选状态,取消所有选择(切换为全不选) + emptyList() + } else { + // 如果当前不是全选状态,选中所有项 + allIds + } + } + } + } + } + + /** + * 清除全部已选择的待办 + */ + fun clearAllTodoSelection() { + _selectedTodoIds.update { emptyList() } + } + + /** + * 删除选择的待办 + */ + fun deleteSelectedTodo() { + viewModelScope.launch { + Repository.deleteTodoFromIds(selectedTodoIds.value) + clearAllTodoSelection() + } + } + + fun playConfetti() { + showConfetti.value = true + } } \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 5468504..3b8d68f 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,5 +1,6 @@ +