From 87cccfefc1b16aab4394f345eccac662e592a7a8 Mon Sep 17 00:00:00 2001 From: Super12138 <70494801+Super12138@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:35:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=9C=E7=B4=A2=E5=BE=85=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 +- .../todo/ui/pages/tasks/TasksPage.kt | 163 +++++++++++------- .../tasks/components/TodoSearchTextField.kt | 79 +++++++++ .../pages/tasks/components/TodoTopAppBar.kt | 123 +++++++++---- app/src/main/res/drawable/ic_search.xml | 9 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 7 files changed, 292 insertions(+), 90 deletions(-) create mode 100644 app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoSearchTextField.kt create mode 100644 app/src/main/res/drawable/ic_search.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1da6ee0..3a78738 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,7 @@ android { applicationId = "cn.super12138.todo" minSdk = 24 targetSdk = 36 - versionCode = 989 + versionCode = 990 versionName = "2.3.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt index e80859d..5eebec6 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt @@ -1,8 +1,14 @@ package cn.super12138.todo.ui.pages.tasks import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -11,11 +17,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.animateFloatingActionButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -31,15 +39,16 @@ import androidx.navigation3.ui.LocalNavAnimatedContentScope import cn.super12138.todo.R import cn.super12138.todo.constants.Constants import cn.super12138.todo.logic.database.TodoEntity -import cn.super12138.todo.logic.datastore.DataStoreManager import cn.super12138.todo.logic.model.Priority import cn.super12138.todo.ui.TodoDefaults import cn.super12138.todo.ui.components.ConfirmDialog import cn.super12138.todo.ui.components.TodoFloatingActionButton import cn.super12138.todo.ui.components.TopAppBarScaffold import cn.super12138.todo.ui.pages.tasks.components.TodoCard +import cn.super12138.todo.ui.pages.tasks.components.TodoSearchTextField import cn.super12138.todo.ui.pages.tasks.components.TodoTopAppBar import cn.super12138.todo.ui.viewmodels.MainViewModel +import cn.super12138.todo.utils.toLocalDateString @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -51,22 +60,35 @@ fun SharedTransitionScope.TasksPage( ) { val animatedVisibilityScope = LocalNavAnimatedContentScope.current + val toDoList by viewModel.sortedTodos.collectAsState(initial = emptyList()) val selectedTodos = viewModel.selectedTodoIds.collectAsState() - val showCompleted by DataStoreManager.showCompletedFlow.collectAsState(initial = Constants.PREF_SHOW_COMPLETED_DEFAULT) + + // 状态持久化 + val searchFieldState = rememberTextFieldState() + val searchMode = rememberSaveable { mutableStateOf(false) } val listState = rememberLazyListState() var showDeleteConfirmDialog by rememberSaveable { mutableStateOf(false) } val selectedTodoIds by remember { derivedStateOf { selectedTodos.value } } val inSelectedMode by remember { derivedStateOf { !selectedTodoIds.isEmpty() } } - val toDoList by viewModel.sortedTodos.collectAsState(initial = emptyList()) - val filteredTodoList = - if (showCompleted) toDoList else toDoList.filter { item -> !item.isCompleted } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } + val filteredTodoList = if (searchFieldState.text.isBlank()) toDoList else toDoList.filter { + it.content.contains(searchFieldState.text, ignoreCase = true) || + it.category.contains(searchFieldState.text, ignoreCase = true) || + it.dueDate?.toLocalDateString() + ?.contains(searchFieldState.text, ignoreCase = true) == true + } + // TODO: 移除已完成任务的过滤及其设置项 + // if (showCompleted) toDoList else toDoList.filter { item -> !item.isCompleted } + // 当按下返回键(或进行返回操作)时清空选择,仅在非选择模式下生效 BackHandler(inSelectedMode) { viewModel.clearAllTodoSelection() } + // 选择时自动退出搜索模式 + LaunchedEffect(inSelectedMode) { searchMode.value = false } + TopAppBarScaffold( topBar = { TodoTopAppBar( @@ -74,7 +96,9 @@ fun SharedTransitionScope.TasksPage( selectedMode = inSelectedMode, onCancelSelect = { viewModel.clearAllTodoSelection() }, onSelectAll = { viewModel.selectAllTodos() }, - onDeleteSelectedTodo = { showDeleteConfirmDialog = true } + onDeleteSelectedTodo = { showDeleteConfirmDialog = true }, + onSearchModeChange = { searchMode.value = it }, + searchMode = searchMode.value, ) }, floatingActionButton = { @@ -98,65 +122,84 @@ fun SharedTransitionScope.TasksPage( contentWindowInsets = WindowInsets(0, 0, 0, 0), modifier = modifier ) { - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(TodoDefaults.settingsItemPadding), - modifier = Modifier.fillMaxSize() - ) { - item { - Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding)) + Column { + AnimatedVisibility( + visible = searchMode.value, + + enter = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + expandVertically( + MaterialTheme.motionScheme.fastSpatialSpec() + ), + exit = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + shrinkVertically( + MaterialTheme.motionScheme.fastSpatialSpec() + ), + ) { + TodoSearchTextField( + searchMode = searchMode.value, + onSearchModeChange = { searchMode.value = it }, + textFieldState = searchFieldState + ) } - if (filteredTodoList.isEmpty()) { - item { - Text( - text = stringResource(R.string.tip_no_task), - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - } - } else { - items( - items = filteredTodoList, - key = { it.id } - ) { task -> - TodoCard( - // id = item.id, - content = task.content, - category = task.category, - completed = task.isCompleted, - dueDate = task.dueDate, - priority = Priority.fromFloat(task.priority), - selected = selectedTodoIds.contains(task.id), - onCardClick = { - if (inSelectedMode) { - viewModel.toggleTodoSelection(task) - } else { - toTodoEditPage(task) - } - }, - onCardLongClick = { viewModel.toggleTodoSelection(task) }, - onChecked = { - viewModel.updateTodo(task.copy(isCompleted = true)) - viewModel.playConfetti() - }, - modifier = Modifier - .sharedBounds( - sharedContentState = rememberSharedContentState(key = "${Constants.KEY_TODO_ITEM_TRANSITION}_${task.id}"), - animatedVisibilityScope = LocalNavAnimatedContentScope.current, - resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds - ) - .animateItem( - fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), - placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), - fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec() - ) - ) - } + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(TodoDefaults.settingsItemPadding), + modifier = Modifier.fillMaxSize() + ) { item { Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding)) } + + if (filteredTodoList.isEmpty()) { + item { + Text( + text = stringResource(R.string.tip_no_task), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } else { + items( + items = filteredTodoList, + key = { it.id } + ) { task -> + TodoCard( + // id = item.id, + content = task.content, + category = task.category, + completed = task.isCompleted, + dueDate = task.dueDate, + priority = Priority.fromFloat(task.priority), + selected = selectedTodoIds.contains(task.id), + onCardClick = { + if (inSelectedMode) { + viewModel.toggleTodoSelection(task) + } else { + toTodoEditPage(task) + } + }, + onCardLongClick = { viewModel.toggleTodoSelection(task) }, + onChecked = { + viewModel.updateTodo(task.copy(isCompleted = true)) + viewModel.playConfetti() + }, + modifier = Modifier + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "${Constants.KEY_TODO_ITEM_TRANSITION}_${task.id}"), + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds + ) + .animateItem( + fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), + placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), + fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec() + ) + ) + } + item { + Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding)) + } + } } } } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoSearchTextField.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoSearchTextField.kt new file mode 100644 index 0000000..ac96231 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoSearchTextField.kt @@ -0,0 +1,79 @@ +package cn.super12138.todo.ui.pages.tasks.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.utils.VibrationUtils + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun TodoSearchTextField( + searchMode: Boolean, + onSearchModeChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + textFieldState: TextFieldState, +) { + val view = LocalView.current + TextField( + modifier = modifier.fillMaxWidth(), + state = textFieldState, + shape = CircleShape, + placeholder = { Text(stringResource(R.string.action_search)) }, + lineLimits = TextFieldLineLimits.SingleLine, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + leadingIcon = { + IconButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onSearchModeChange(!searchMode) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.action_back) + ) + } + }, + trailingIcon = { + AnimatedVisibility( + visible = textFieldState.text.isNotBlank(), + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + ) { + IconButton(onClick = { textFieldState.setTextAndPlaceCursorAtEnd("") }) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.action_clear) + ) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoTopAppBar.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoTopAppBar.kt index e93e770..19915e3 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoTopAppBar.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoTopAppBar.kt @@ -1,6 +1,8 @@ package cn.super12138.todo.ui.pages.tasks.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.animateColorAsState @@ -8,12 +10,14 @@ import androidx.compose.animation.expandIn import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkOut import androidx.compose.foundation.layout.Row import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -38,8 +42,10 @@ import cn.super12138.todo.utils.VibrationUtils @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun TodoTopAppBar( - selectedTodoIds: List, + searchMode: Boolean, selectedMode: Boolean, + selectedTodoIds: List, + onSearchModeChange: (Boolean) -> Unit, onCancelSelect: () -> Unit, onSelectAll: () -> Unit, onDeleteSelectedTodo: () -> Unit, @@ -86,6 +92,7 @@ fun TodoTopAppBar( exit = navIconExitTransition ) { IconButton( + shapes = IconButtonDefaults.shapes(), onClick = { VibrationUtils.performHapticFeedback(view) onCancelSelect() @@ -122,37 +129,22 @@ fun TodoTopAppBar( } }, actions = { - AnimatedVisibility( - visible = selectedMode, - enter = enterTransition, - exit = exitTransition + AnimatedContent( + targetState = selectedMode, + transitionSpec = { titleTransition } ) { - Row { - IconButton( - onClick = { - VibrationUtils.performHapticFeedback(view) - onSelectAll() - } - ) { - Icon( - painter = painterResource(R.drawable.ic_select_all), - contentDescription = stringResource(R.string.tip_select_all) - ) - } - IconButton( - onClick = { - VibrationUtils.performHapticFeedback(view) - onDeleteSelectedTodo() - } - ) { - Icon( - painter = painterResource(R.drawable.ic_delete), - contentDescription = stringResource(R.string.action_delete) - ) - } + if (it) { + ActionMultipleSelection( + onSelectAll = onSelectAll, + onDeleteSelectedTodo = onDeleteSelectedTodo + ) + } else { + ActionSearch( + searchMode = searchMode, + onSearchModeChange = onSearchModeChange, + ) } } - }, colors = TopAppBarDefaults.topAppBarColors().copy(containerColor = Color.Transparent), modifier = modifier.drawBehind { @@ -161,11 +153,84 @@ fun TodoTopAppBar( ) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun AnimatedContentScope.ActionSearch( + searchMode: Boolean, + onSearchModeChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + + BackHandler(enabled = searchMode) { onSearchModeChange(false) } + + AnimatedVisibility( + visible = !searchMode, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + ) { + IconButton( + shapes = IconButtonDefaults.shapes(), + onClick = { + VibrationUtils.performHapticFeedback(view) + onSearchModeChange(!searchMode) + }, + modifier = modifier + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = stringResource(R.string.action_search) + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AnimatedContentScope.ActionMultipleSelection( + onSelectAll: () -> Unit, + onDeleteSelectedTodo: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + Row( + verticalAlignment = Alignment.Bottom, + modifier = modifier + ) { + IconButton( + shapes = IconButtonDefaults.shapes(), + onClick = { + VibrationUtils.performHapticFeedback(view) + onSelectAll() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_select_all), + contentDescription = stringResource(R.string.tip_select_all) + ) + } + IconButton( + shapes = IconButtonDefaults.shapes(), + onClick = { + VibrationUtils.performHapticFeedback(view) + onDeleteSelectedTodo() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = stringResource(R.string.action_delete) + ) + } + } +} + @Preview(locale = "zh-rCN", showBackground = true) @Composable private fun TodoTopAppBarPreview() { val selectedMode = remember { mutableStateOf(false) } TodoTopAppBar( + searchMode = true, + onSearchModeChange = {}, selectedTodoIds = (1..10).toList(), selectedMode = selectedMode.value, onCancelSelect = { selectedMode.value = !selectedMode.value }, diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..005ccd5 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 98764e8..7cbd006 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -129,4 +129,7 @@ 总任务 已完成 未完成 + 搜索 + 关闭 + 截止日期 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9aecf2c..aa6eddd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -130,4 +130,7 @@ All Completed Pending + Search + Close + Due date \ No newline at end of file