feat: 搜索待办

This commit is contained in:
Super12138 2026-02-09 10:35:26 +08:00
parent e5601df0b5
commit 87cccfefc1
7 changed files with 292 additions and 90 deletions

View file

@ -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"

View file

@ -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))
}
}
}
}
}

View file

@ -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)
)
}
}
}
)
}

View file

@ -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<Int>,
searchMode: Boolean,
selectedMode: Boolean,
selectedTodoIds: List<Int>,
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 },

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L812,756Q823,767 823,784Q823,801 812,812Q801,823 784,823Q767,823 756,812L532,588Q502,612 463,626Q424,640 380,640ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" />
</vector>

View file

@ -129,4 +129,7 @@
<string name="title_all_task">总任务</string>
<string name="title_completed_task">已完成</string>
<string name="title_pending_task">未完成</string>
<string name="action_search">搜索</string>
<string name="action_close">关闭</string>
<string name="sorting_due_date">截止日期</string>
</resources>

View file

@ -130,4 +130,7 @@
<string name="title_all_task">All</string>
<string name="title_completed_task">Completed</string>
<string name="title_pending_task">Pending</string>
<string name="action_search">Search</string>
<string name="action_close">Close</string>
<string name="sorting_due_date">Due date</string>
</resources>