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" applicationId = "cn.super12138.todo"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 989 versionCode = 990
versionName = "2.3.3" versionName = "2.3.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -1,8 +1,14 @@
package cn.super12138.todo.ui.pages.tasks package cn.super12138.todo.ui.pages.tasks
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionScope 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -31,15 +39,16 @@ import androidx.navigation3.ui.LocalNavAnimatedContentScope
import cn.super12138.todo.R import cn.super12138.todo.R
import cn.super12138.todo.constants.Constants import cn.super12138.todo.constants.Constants
import cn.super12138.todo.logic.database.TodoEntity 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.logic.model.Priority
import cn.super12138.todo.ui.TodoDefaults import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.ui.components.ConfirmDialog import cn.super12138.todo.ui.components.ConfirmDialog
import cn.super12138.todo.ui.components.TodoFloatingActionButton import cn.super12138.todo.ui.components.TodoFloatingActionButton
import cn.super12138.todo.ui.components.TopAppBarScaffold import cn.super12138.todo.ui.components.TopAppBarScaffold
import cn.super12138.todo.ui.pages.tasks.components.TodoCard 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.pages.tasks.components.TodoTopAppBar
import cn.super12138.todo.ui.viewmodels.MainViewModel import cn.super12138.todo.ui.viewmodels.MainViewModel
import cn.super12138.todo.utils.toLocalDateString
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@ -51,22 +60,35 @@ fun SharedTransitionScope.TasksPage(
) { ) {
val animatedVisibilityScope = LocalNavAnimatedContentScope.current val animatedVisibilityScope = LocalNavAnimatedContentScope.current
val toDoList by viewModel.sortedTodos.collectAsState(initial = emptyList())
val selectedTodos = viewModel.selectedTodoIds.collectAsState() 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() val listState = rememberLazyListState()
var showDeleteConfirmDialog by rememberSaveable { mutableStateOf(false) } var showDeleteConfirmDialog by rememberSaveable { mutableStateOf(false) }
val selectedTodoIds by remember { derivedStateOf { selectedTodos.value } } val selectedTodoIds by remember { derivedStateOf { selectedTodos.value } }
val inSelectedMode by remember { derivedStateOf { !selectedTodoIds.isEmpty() } } 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 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() } BackHandler(inSelectedMode) { viewModel.clearAllTodoSelection() }
// 选择时自动退出搜索模式
LaunchedEffect(inSelectedMode) { searchMode.value = false }
TopAppBarScaffold( TopAppBarScaffold(
topBar = { topBar = {
TodoTopAppBar( TodoTopAppBar(
@ -74,7 +96,9 @@ fun SharedTransitionScope.TasksPage(
selectedMode = inSelectedMode, selectedMode = inSelectedMode,
onCancelSelect = { viewModel.clearAllTodoSelection() }, onCancelSelect = { viewModel.clearAllTodoSelection() },
onSelectAll = { viewModel.selectAllTodos() }, onSelectAll = { viewModel.selectAllTodos() },
onDeleteSelectedTodo = { showDeleteConfirmDialog = true } onDeleteSelectedTodo = { showDeleteConfirmDialog = true },
onSearchModeChange = { searchMode.value = it },
searchMode = searchMode.value,
) )
}, },
floatingActionButton = { floatingActionButton = {
@ -98,65 +122,84 @@ fun SharedTransitionScope.TasksPage(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
modifier = modifier modifier = modifier
) { ) {
LazyColumn( Column {
state = listState, AnimatedVisibility(
verticalArrangement = Arrangement.spacedBy(TodoDefaults.settingsItemPadding), visible = searchMode.value,
modifier = Modifier.fillMaxSize()
) { enter = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + expandVertically(
item { MaterialTheme.motionScheme.fastSpatialSpec()
Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding)) ),
exit = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + shrinkVertically(
MaterialTheme.motionScheme.fastSpatialSpec()
),
) {
TodoSearchTextField(
searchMode = searchMode.value,
onSearchModeChange = { searchMode.value = it },
textFieldState = searchFieldState
)
} }
if (filteredTodoList.isEmpty()) { LazyColumn(
item { state = listState,
Text( verticalArrangement = Arrangement.spacedBy(TodoDefaults.settingsItemPadding),
text = stringResource(R.string.tip_no_task), modifier = Modifier.fillMaxSize()
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 { item {
Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding)) 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 package cn.super12138.todo.ui.pages.tasks.components
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@ -8,12 +10,14 @@ import androidx.compose.animation.expandIn
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkOut import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -38,8 +42,10 @@ import cn.super12138.todo.utils.VibrationUtils
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun TodoTopAppBar( fun TodoTopAppBar(
selectedTodoIds: List<Int>, searchMode: Boolean,
selectedMode: Boolean, selectedMode: Boolean,
selectedTodoIds: List<Int>,
onSearchModeChange: (Boolean) -> Unit,
onCancelSelect: () -> Unit, onCancelSelect: () -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onDeleteSelectedTodo: () -> Unit, onDeleteSelectedTodo: () -> Unit,
@ -86,6 +92,7 @@ fun TodoTopAppBar(
exit = navIconExitTransition exit = navIconExitTransition
) { ) {
IconButton( IconButton(
shapes = IconButtonDefaults.shapes(),
onClick = { onClick = {
VibrationUtils.performHapticFeedback(view) VibrationUtils.performHapticFeedback(view)
onCancelSelect() onCancelSelect()
@ -122,37 +129,22 @@ fun TodoTopAppBar(
} }
}, },
actions = { actions = {
AnimatedVisibility( AnimatedContent(
visible = selectedMode, targetState = selectedMode,
enter = enterTransition, transitionSpec = { titleTransition }
exit = exitTransition
) { ) {
Row { if (it) {
IconButton( ActionMultipleSelection(
onClick = { onSelectAll = onSelectAll,
VibrationUtils.performHapticFeedback(view) onDeleteSelectedTodo = onDeleteSelectedTodo
onSelectAll() )
} } else {
) { ActionSearch(
Icon( searchMode = searchMode,
painter = painterResource(R.drawable.ic_select_all), onSearchModeChange = onSearchModeChange,
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)
)
}
} }
} }
}, },
colors = TopAppBarDefaults.topAppBarColors().copy(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors().copy(containerColor = Color.Transparent),
modifier = modifier.drawBehind { 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) @Preview(locale = "zh-rCN", showBackground = true)
@Composable @Composable
private fun TodoTopAppBarPreview() { private fun TodoTopAppBarPreview() {
val selectedMode = remember { mutableStateOf(false) } val selectedMode = remember { mutableStateOf(false) }
TodoTopAppBar( TodoTopAppBar(
searchMode = true,
onSearchModeChange = {},
selectedTodoIds = (1..10).toList(), selectedTodoIds = (1..10).toList(),
selectedMode = selectedMode.value, selectedMode = selectedMode.value,
onCancelSelect = { selectedMode.value = !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_all_task">总任务</string>
<string name="title_completed_task">已完成</string> <string name="title_completed_task">已完成</string>
<string name="title_pending_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> </resources>

View file

@ -130,4 +130,7 @@
<string name="title_all_task">All</string> <string name="title_all_task">All</string>
<string name="title_completed_task">Completed</string> <string name="title_completed_task">Completed</string>
<string name="title_pending_task">Pending</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> </resources>