feat: 增加列表的空项目提示 & 优化转场动画 & 提升待办列表性能

This commit is contained in:
Super12138 2026-02-09 14:00:25 +08:00
parent d3cb35a631
commit ca32744cdc
19 changed files with 382 additions and 189 deletions

View file

@ -38,7 +38,7 @@ android {
applicationId = "cn.super12138.todo"
minSdk = 24
targetSdk = 36
versionCode = 991
versionCode = 992
versionName = "2.3.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -0,0 +1,53 @@
package cn.super12138.todo.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import cn.super12138.todo.R
import cn.super12138.todo.ui.TodoDefaults
enum class EmptyTipType {
Search,
List,
TaskCompleted
}
@Composable
fun EmptyTip(
modifier: Modifier = Modifier,
size: Dp = 48.dp,
type: EmptyTipType
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.padding(TodoDefaults.screenHorizontalPadding)
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer)
) {
Icon(
painter = painterResource(
id = when (type) {
EmptyTipType.List -> R.drawable.ic_list_no_item
EmptyTipType.Search -> R.drawable.ic_search_not_found
EmptyTipType.TaskCompleted -> R.drawable.ic_thumb_up
}
),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(size / 2)
)
}
}

View file

@ -2,7 +2,6 @@ package cn.super12138.todo.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View file

@ -2,6 +2,7 @@ package cn.super12138.todo.ui.navigation
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -21,12 +22,12 @@ import cn.super12138.todo.ui.pages.settings.SettingsDeveloperOptionsPadding
import cn.super12138.todo.ui.pages.settings.SettingsInterface
import cn.super12138.todo.ui.pages.settings.SettingsMain
import cn.super12138.todo.ui.pages.tasks.TasksPage
import cn.super12138.todo.ui.theme.fadeThrough
import cn.super12138.todo.ui.theme.fadeScale
import cn.super12138.todo.ui.theme.materialSharedAxisX
import cn.super12138.todo.ui.theme.veilFade
import cn.super12138.todo.ui.viewmodels.MainViewModel
@OptIn(ExperimentalAnimationApi::class)
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TopNavigation(
backStack: TopLevelBackStack<NavKey>,
@ -64,25 +65,18 @@ fun TopNavigation(
)
}
val defaultTransition = fadeScale(
effectSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
spatialSpec = MaterialTheme.motionScheme.defaultSpatialSpec()
)
SharedTransitionLayout {
NavDisplay(
backStack = backStack.backStack,
onBack = ::onBack,
/*transitionSpec = {
fadeIn() togetherWith veilOut(targetColor = veilColor)
},
popTransitionSpec = {
unveilIn(initialColor = veilColor) togetherWith fadeOut()
},
predictivePopTransitionSpec = {
unveilIn(initialColor = veilColor) togetherWith fadeOut()
},*/
/**
* 来自https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/transition/MaterialFadeThrough.java#L33
*/
transitionSpec = { fadeThrough() },
popTransitionSpec = { fadeThrough() },
predictivePopTransitionSpec = { fadeThrough() },
transitionSpec = { defaultTransition },
popTransitionSpec = { defaultTransition },
predictivePopTransitionSpec = { defaultTransition },
entryProvider = entryProvider {
entry<TodoScreen.Overview> {
OverviewPage(viewModel = viewModel)

View file

@ -1,16 +1,19 @@
package cn.super12138.todo.ui.pages.overview.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@ -23,12 +26,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cn.super12138.todo.R
import cn.super12138.todo.logic.database.TodoEntity
import cn.super12138.todo.logic.model.Priority
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.ui.components.EmptyTip
import cn.super12138.todo.ui.components.EmptyTipType
import cn.super12138.todo.ui.theme.fadeScale
import cn.super12138.todo.utils.containerColor
import cn.super12138.todo.utils.toRelativeTimeString
@ -54,20 +61,43 @@ fun UpcomingTaskCard(
text = stringResource(R.string.title_upcoming_task),
style = MaterialTheme.typography.titleLarge
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
val transitionSpec = fadeScale()
AnimatedContent(
targetState = nextWeekTodo.isEmpty(),
transitionSpec = { transitionSpec }
) {
items(
items = nextWeekTodo,
key = { it.id }
) {
UpcomingTaskItem(
content = it.content,
category = it.category,
priority = Priority.fromFloat(it.priority),
dueDate = it.dueDate
)
if (it) {
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f) // 占满剩余空间
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
EmptyTip(type = EmptyTipType.List)
Text(
text = stringResource(R.string.tip_no_task_brief),
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(
items = nextWeekTodo,
key = { task -> task.id }
) { task ->
UpcomingTaskItem(
content = task.content,
category = task.category,
priority = Priority.fromFloat(task.priority),
dueDate = task.dueDate
)
}
}
}
}
}

View file

@ -1,10 +1,15 @@
package cn.super12138.todo.ui.pages.settings
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
@ -23,19 +28,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cn.super12138.todo.R
import cn.super12138.todo.logic.datastore.DataStoreManager
import cn.super12138.todo.ui.components.EmptyTip
import cn.super12138.todo.ui.components.EmptyTipType
import cn.super12138.todo.ui.components.TodoFloatingActionButton
import cn.super12138.todo.ui.components.TopAppBarScaffold
import cn.super12138.todo.ui.pages.settings.components.SettingsContainer
import cn.super12138.todo.ui.pages.settings.components.SettingsItem
import cn.super12138.todo.ui.pages.settings.components.category.CategoryPromptDialog
import cn.super12138.todo.ui.theme.fadeScale
import cn.super12138.todo.utils.VibrationUtils
import kotlinx.coroutines.launch
@ -58,7 +68,8 @@ fun SettingsDataCategory(
val isExpanded by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
// TODO: 取消5字分类限制
val transitionSpec = fadeScale()
TopAppBarScaffold(
title = stringResource(R.string.pref_category_category_management),
onBack = onNavigateUp,
@ -76,56 +87,71 @@ fun SettingsDataCategory(
},
modifier = modifier,
) {
SettingsContainer(Modifier.fillMaxSize()) {
if (categories.isEmpty()) {
item {
AnimatedContent(
targetState = categories.isEmpty(),
transitionSpec = { transitionSpec }
) {
if (it) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
EmptyTip(
type = EmptyTipType.List,
size = 96.dp
)
Text(
text = stringResource(R.string.tip_no_category_page),
style = MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth()
)
}
} else {
// Keep stable content key (category) for animations, but compute rounding based on content
items(
items = categories,
key = { it }
) { category ->
SettingsItem(
headlineContent = {
Text(
text = category,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.basicMarquee()
)
},
trailingContent = {
FilledTonalIconButton(
shapes = IconButtonDefaults.shapes(),
onClick = {
VibrationUtils.performHapticFeedback(view)
scope.launch { DataStoreManager.setCategories(categories - category) }
}
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.action_delete)
SettingsContainer(Modifier.fillMaxSize()) {
items(
items = categories,
key = { category -> category }
) { category ->
SettingsItem(
headlineContent = {
Text(
text = category,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.basicMarquee()
)
}
},
onClick = {
initialCategory = category
showDialog = true
},
modifier = Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.defaultEffectsSpec()
},
trailingContent = {
FilledTonalIconButton(
shapes = IconButtonDefaults.shapes(),
onClick = {
VibrationUtils.performHapticFeedback(view)
scope.launch { DataStoreManager.setCategories(categories - category) }
}
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.action_delete)
)
}
},
onClick = {
initialCategory = category
showDialog = true
},
modifier = Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.defaultEffectsSpec()
)
)
)
}
}
}
}

View file

@ -1,6 +1,7 @@
package cn.super12138.todo.ui.pages.tasks
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.expandVertically
@ -16,8 +17,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -35,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import cn.super12138.todo.R
import cn.super12138.todo.constants.Constants
@ -42,11 +44,14 @@ import cn.super12138.todo.logic.database.TodoEntity
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.EmptyTip
import cn.super12138.todo.ui.components.EmptyTipType
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.theme.fadeScale
import cn.super12138.todo.ui.viewmodels.MainViewModel
import cn.super12138.todo.utils.toLocalDateString
@ -64,30 +69,27 @@ fun SharedTransitionScope.TasksPage(
val selectedTodos = viewModel.selectedTodoIds.collectAsState()
// 状态持久化
val searchFieldState = rememberTextFieldState()
val searchMode = rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val searchFieldState = viewModel.searchFieldState
var showDeleteConfirmDialog by rememberSaveable { mutableStateOf(false) }
val selectedTodoIds by remember { derivedStateOf { selectedTodos.value } }
val inSelectedMode by remember { derivedStateOf { !selectedTodoIds.isEmpty() } }
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
val expandedFab by remember { derivedStateOf { viewModel.toDoListState.firstVisibleItemIndex == 0 } }
val filteredTodoList = if (searchFieldState.text.isBlank()) toDoList else toDoList.filter {
val filteredTodoList = if (viewModel.searchMode.value) 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 }
} else toDoList
val transitionSpec = fadeScale()
// 当按下返回键(或进行返回操作)时清空选择,仅在非选择模式下生效
BackHandler(inSelectedMode) { viewModel.clearAllTodoSelection() }
// 选择时自动退出搜索模式
LaunchedEffect(inSelectedMode) { searchMode.value = false }
LaunchedEffect(inSelectedMode) { if (inSelectedMode) viewModel.searchMode.value = false }
TopAppBarScaffold(
topBar = {
@ -97,8 +99,8 @@ fun SharedTransitionScope.TasksPage(
onCancelSelect = { viewModel.clearAllTodoSelection() },
onSelectAll = { viewModel.selectAllTodos() },
onDeleteSelectedTodo = { showDeleteConfirmDialog = true },
onSearchModeChange = { searchMode.value = it },
searchMode = searchMode.value,
onSearchModeChange = { viewModel.searchMode.value = it },
searchMode = viewModel.searchMode.value,
)
},
floatingActionButton = {
@ -124,8 +126,7 @@ fun SharedTransitionScope.TasksPage(
) {
Column {
AnimatedVisibility(
visible = searchMode.value,
visible = viewModel.searchMode.value,
enter = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + expandVertically(
MaterialTheme.motionScheme.fastSpatialSpec()
),
@ -134,80 +135,97 @@ fun SharedTransitionScope.TasksPage(
),
) {
TodoSearchTextField(
searchMode = searchMode.value,
onSearchModeChange = { searchMode.value = it },
searchMode = viewModel.searchMode.value,
onSearchModeChange = { viewModel.searchMode.value = it },
textFieldState = searchFieldState
)
}
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(TodoDefaults.settingsItemPadding),
modifier = Modifier.fillMaxSize()
AnimatedContent(
targetState = filteredTodoList.isEmpty(),
transitionSpec = { transitionSpec }
) {
item {
Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding))
}
if (it) {
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f) // 占满剩余空间
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
EmptyTip(
type = if (viewModel.searchMode.value) EmptyTipType.Search else EmptyTipType.TaskCompleted,
size = 96.dp
)
if (filteredTodoList.isEmpty()) {
item {
Text(
text = stringResource(R.string.tip_no_task),
text = stringResource(if (viewModel.searchMode.value) R.string.tip_search_task_not_found else 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))
LazyColumn(
state = viewModel.toDoListState,
verticalArrangement = Arrangement.spacedBy(TodoDefaults.settingsItemPadding),
modifier = Modifier.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding))
}
items(
items = filteredTodoList,
key = { task -> task.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))
}
}
}
}
}
ConfirmDialog(
visible = showDeleteConfirmDialog,
iconRes = R.drawable.ic_delete,
text = stringResource(R.string.tip_delete_task, selectedTodoIds.size),
onConfirm = { viewModel.deleteSelectedTodo() },
onDismiss = { showDeleteConfirmDialog = false }
)
}
ConfirmDialog(
visible = showDeleteConfirmDialog,
iconRes = R.drawable.ic_delete,
text = stringResource(R.string.tip_delete_task, selectedTodoIds.size),
onConfirm = { viewModel.deleteSelectedTodo() },
onDismiss = { showDeleteConfirmDialog = false }
)
}

View file

@ -75,7 +75,6 @@ fun TodoCard(
) {
val view = LocalView.current
val context = LocalContext.current
// TODO: 滑动删除
val cardColors = CardDefaults.cardColors(containerColor = TodoDefaults.Colors.Container)
val animatedContainerColor by animateColorAsState(targetValue = if (selected) MaterialTheme.colorScheme.secondaryContainer else if (completed) cardColors.disabledContainerColor else cardColors.containerColor)

View file

@ -6,7 +6,6 @@ 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
@ -24,7 +23,6 @@ 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
@ -51,9 +49,9 @@ fun TodoSearchTextField(
leadingIcon = {
IconButton(
onClick = {
VibrationUtils.performHapticFeedback(view)
onSearchModeChange(!searchMode)
}
VibrationUtils.performHapticFeedback(view)
onSearchModeChange(!searchMode)
}
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),

View file

@ -2,9 +2,7 @@ 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
import androidx.compose.animation.expandIn
import androidx.compose.animation.fadeIn
@ -37,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import cn.super12138.todo.R
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.ui.theme.fadeScale
import cn.super12138.todo.utils.VibrationUtils
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@ -57,22 +56,15 @@ fun TodoTopAppBar(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
expandFrom = Alignment.CenterStart
)
val navIconExitTransition = fadeOut(
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec()
) + shrinkOut(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
shrinkTowards = Alignment.CenterStart
)
val enterTransition = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) +
scaleIn(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
initialScale = 0.92f
)
val exitTransition = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
val titleTransition = ContentTransform(
targetContentEnter = enterTransition,
initialContentExit = exitTransition
)
val defaultTransitionSpec = fadeScale()
val view = LocalView.current
val animatedContainerColor by animateColorAsState(
@ -108,7 +100,7 @@ fun TodoTopAppBar(
title = {
AnimatedContent(
targetState = !selectedMode,
transitionSpec = { titleTransition }
transitionSpec = { defaultTransitionSpec }
) {
if (it) {
Text(
@ -131,7 +123,7 @@ fun TodoTopAppBar(
actions = {
AnimatedContent(
targetState = selectedMode,
transitionSpec = { titleTransition }
transitionSpec = { defaultTransitionSpec }
) {
if (it) {
ActionMultipleSelection(
@ -155,7 +147,7 @@ fun TodoTopAppBar(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun AnimatedContentScope.ActionSearch(
private fun ActionSearch(
searchMode: Boolean,
onSearchModeChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
@ -166,8 +158,8 @@ private fun AnimatedContentScope.ActionSearch(
AnimatedVisibility(
visible = !searchMode,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
enter = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
IconButton(
shapes = IconButtonDefaults.shapes(),
@ -187,7 +179,7 @@ private fun AnimatedContentScope.ActionSearch(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AnimatedContentScope.ActionMultipleSelection(
fun ActionMultipleSelection(
onSelectAll: () -> Unit,
onDeleteSelectedTodo: () -> Unit,
modifier: Modifier = Modifier

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@ -15,6 +16,9 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.unveilIn
import androidx.compose.animation.veilOut
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
@ -93,7 +97,7 @@ fun materialSharedAxisXOut(
)
)
fun fadeThrough(
/*fun fadeThrough(
durationMillis: Int = 200,
): ContentTransform = ContentTransform(
fadeThroughIn(durationMillis = durationMillis),
@ -118,7 +122,7 @@ fun fadeThroughOut(
durationMillis = durationMillis,
easing = FastOutLinearInEasing
)
)
)*/
fun veilFade(
initialColor: Color,
@ -151,4 +155,34 @@ fun veilFadeOut(
): ExitTransition =
fadeOut() + veilOut(
targetColor = initialColor
)
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun fadeScaleIn(
effectSpec: FiniteAnimationSpec<Float> = MaterialTheme.motionScheme.fastEffectsSpec(),
spatialSpec: FiniteAnimationSpec<Float> = MaterialTheme.motionScheme.fastSpatialSpec()
): EnterTransition = fadeIn(effectSpec) +
scaleIn(
animationSpec = spatialSpec,
initialScale = 0.92f
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun fadeScaleOut(
effectSpec: FiniteAnimationSpec<Float> = MaterialTheme.motionScheme.fastEffectsSpec()
): ExitTransition = fadeOut(effectSpec)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun fadeScale(
effectSpec: FiniteAnimationSpec<Float> = MaterialTheme.motionScheme.fastEffectsSpec(),
spatialSpec: FiniteAnimationSpec<Float> = MaterialTheme.motionScheme.fastSpatialSpec()
): ContentTransform = ContentTransform(
targetContentEnter = fadeScaleIn(
effectSpec = effectSpec,
spatialSpec = spatialSpec
),
initialContentExit = fadeScaleOut(effectSpec = effectSpec)
)

View file

@ -2,6 +2,8 @@ package cn.super12138.todo.ui.viewmodels
import android.content.Context
import android.net.Uri
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -19,10 +21,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -39,8 +44,8 @@ class MainViewModel : ViewModel() {
// 待办
private val toDos: Flow<List<TodoEntity>> = Repository.getAllTodos()
val sortedTodos: Flow<List<TodoEntity>> =
DataStoreManager.sortingMethodFlow.flatMapLatest { sortingMethod ->
val sortedTodos: StateFlow<List<TodoEntity>> = DataStoreManager.sortingMethodFlow
.flatMapLatest { sortingMethod ->
toDos.map { list ->
when (SortingMethod.fromId(sortingMethod)) {
SortingMethod.Sequential -> list.sortedWith(
@ -88,9 +93,18 @@ class MainViewModel : ViewModel() {
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val showConfetti = mutableStateOf(false)
val toDoListState = LazyListState()
val searchMode = mutableStateOf(false)
val searchFieldState = TextFieldState()
// 多选逻辑参考https://github.com/X1nto/Mauth
private val _selectedTodoIds = MutableStateFlow(listOf<Int>())
val selectedTodoIds = _selectedTodoIds.asStateFlow()

View file

@ -2,11 +2,8 @@ package cn.super12138.todo.utils
import android.content.Context
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@ -14,7 +11,6 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import cn.super12138.todo.R
import cn.super12138.todo.logic.model.Priority
@ -139,7 +135,9 @@ fun Long?.toRelativeTimeString(context: Context): String {
}
@Composable
fun disabledContentColor(alpha: Float = 0.38f): Color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha)
fun disabledContentColor(alpha: Float = 0.38f): Color =
MaterialTheme.colorScheme.onSurface.copy(alpha = alpha)
@Composable
fun disabledContainerColor(alpha: Float = 0.12f): Color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha)
fun disabledContainerColor(alpha: Float = 0.12f): Color =
MaterialTheme.colorScheme.onSurface.copy(alpha = alpha)

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M18,13C17.433,13 16.958,12.808 16.575,12.425C16.192,12.042 16,11.567 16,11C16,10.433 16.192,9.958 16.575,9.575C16.958,9.192 17.433,9 18,9H38C38.567,9 39.042,9.192 39.425,9.575C39.808,9.958 40,10.433 40,11C40,11.567 39.808,12.042 39.425,12.425C39.042,12.808 38.567,13 38,13H18ZM8,39C6.9,39 5.958,38.608 5.175,37.825C4.392,37.042 4,36.1 4,35C4,33.9 4.392,32.958 5.175,32.175C5.958,31.392 6.9,31 8,31C9.1,31 10.042,31.392 10.825,32.175C11.608,32.958 12,33.9 12,35C12,36.1 11.608,37.042 10.825,37.825C10.042,38.608 9.1,39 8,39ZM8,27C6.9,27 5.958,26.608 5.175,25.825C4.392,25.042 4,24.1 4,23C4,21.9 4.392,20.958 5.175,20.175C5.958,19.392 6.9,19 8,19C9.1,19 10.042,19.392 10.825,20.175C11.608,20.958 12,21.9 12,23C12,24.1 11.608,25.042 10.825,25.825C10.042,26.608 9.1,27 8,27ZM5.175,13.825C4.392,13.042 4,12.1 4,11C4,9.9 4.392,8.958 5.175,8.175C5.958,7.392 6.9,7 8,7C9.1,7 10.042,7.392 10.825,8.175C11.608,8.958 12,9.9 12,11C12,12.1 11.608,13.042 10.825,13.825C10.042,14.608 9.1,15 8,15C6.9,15 5.958,14.608 5.175,13.825ZM18,25C17.433,25 16.958,24.808 16.575,24.425C16.192,24.042 16,23.567 16,23C16,22.433 16.192,21.958 16.575,21.575C16.958,21.192 17.433,21 18,21H22C22.567,21 23.042,21.192 23.425,21.575C23.808,21.958 24,22.433 24,23C24,23.567 23.808,24.042 23.425,24.425C23.042,24.808 22.567,25 22,25H18ZM16.575,36.425C16.192,36.042 16,35.567 16,35C16,34.433 16.192,33.958 16.575,33.575C16.958,33.192 17.433,33 18,33C18.567,33 19.042,33.192 19.425,33.575C19.808,33.958 20,34.433 20,35C20,35.567 19.808,36.042 19.425,36.425C19.042,36.808 18.567,37 18,37C17.433,37 16.958,36.808 16.575,36.425Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M35,23C39.971,23 44,27.029 44,32C44,36.971 39.971,41 35,41C30.029,41 26,36.971 26,32C26,27.029 30.029,23 35,23ZM34.778,35.5C34.465,35.5 34.2,35.621 33.983,35.862C33.767,36.104 33.659,36.4 33.659,36.75C33.659,37.1 33.767,37.396 33.983,37.638C34.2,37.879 34.465,38 34.778,38C35.091,38 35.356,37.879 35.572,37.638C35.789,37.396 35.896,37.1 35.896,36.75C35.896,36.4 35.789,36.104 35.572,35.862C35.356,35.621 35.091,35.5 34.778,35.5ZM34.912,26C34.345,26 33.805,26.134 33.29,26.4C32.776,26.667 32.384,27.075 32.115,27.625C32.011,27.825 31.977,28.038 32.015,28.263C32.052,28.488 32.152,28.659 32.316,28.775C32.525,28.909 32.742,28.95 32.966,28.9C33.189,28.85 33.376,28.708 33.525,28.475C33.689,28.225 33.895,28.034 34.141,27.9C34.387,27.767 34.644,27.7 34.912,27.7C35.285,27.7 35.61,27.833 35.886,28.1C36.162,28.366 36.3,28.7 36.3,29.1C36.3,29.466 36.198,29.792 35.997,30.075C35.796,30.358 35.569,30.625 35.315,30.875C34.972,31.208 34.67,31.575 34.409,31.975C34.148,32.375 34.018,32.825 34.018,33.325C34.018,33.558 34.096,33.754 34.252,33.912C34.409,34.07 34.592,34.15 34.801,34.15C35.024,34.15 35.215,34.067 35.371,33.9C35.528,33.734 35.628,33.525 35.673,33.275C35.732,32.925 35.867,32.613 36.076,32.338C36.285,32.063 36.508,31.8 36.747,31.55C37.09,31.183 37.385,30.783 37.631,30.35C37.877,29.917 38,29.433 38,28.9C38,28.05 37.69,27.354 37.071,26.813C36.452,26.271 35.732,26 34.912,26Z" />
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M18.549,28.335C18.946,27.892 19.144,27.35 19.144,26.708C19.144,26.067 18.946,25.524 18.549,25.081C18.153,24.638 17.667,24.417 17.093,24.417C16.519,24.417 16.033,24.638 15.637,25.081C15.24,25.524 15.042,26.067 15.042,26.708C15.042,27.35 15.24,27.892 15.637,28.335C16.033,28.778 16.519,29 17.093,29C17.667,29 18.153,28.778 18.549,28.335ZM17.339,10.117C18.023,10.117 18.618,10.361 19.124,10.85C19.629,11.339 19.882,11.95 19.882,12.683C19.882,13.356 19.698,13.951 19.329,14.471C18.959,14.99 18.542,15.479 18.077,15.938C17.448,16.549 16.895,17.221 16.416,17.954C15.938,18.688 15.698,19.513 15.698,20.429C15.698,20.857 15.842,21.216 16.129,21.506C16.416,21.796 16.751,21.942 17.134,21.942C17.544,21.942 17.893,21.789 18.18,21.483C18.467,21.178 18.652,20.796 18.734,20.337C18.843,19.696 19.089,19.123 19.472,18.619C19.855,18.115 20.265,17.633 20.703,17.175C21.332,16.503 21.872,15.769 22.323,14.975C22.774,14.181 23,13.294 23,12.317C23,10.758 22.433,9.483 21.298,8.49C20.163,7.497 18.843,7 17.339,7C16.3,7 15.309,7.244 14.365,7.733C13.422,8.222 12.704,8.971 12.212,9.979C12.02,10.346 11.959,10.735 12.027,11.148C12.095,11.56 12.28,11.874 12.581,12.087C12.964,12.332 13.36,12.408 13.77,12.317C14.181,12.225 14.522,11.965 14.796,11.538C15.097,11.079 15.473,10.728 15.924,10.483C16.375,10.239 16.847,10.117 17.339,10.117Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M17.516,34.761C12.621,34.761 8.478,33.064 5.087,29.669C1.696,26.274 0,22.178 0,17.381C0,12.583 1.697,8.487 5.092,5.092C8.487,1.697 12.594,0 17.414,0C22.235,0 26.331,1.697 29.703,5.092C33.075,8.487 34.761,12.586 34.761,17.391C34.761,19.33 34.444,21.205 33.811,23.016C33.177,24.826 32.226,26.523 30.959,28.108L47.389,44.402C47.796,44.789 48,45.282 48,45.88C48,46.479 47.796,46.982 47.389,47.389C46.982,47.796 46.479,48 45.88,48C45.282,48 44.789,47.796 44.402,47.389L28.04,31.027C26.682,32.204 25.098,33.12 23.29,33.777C21.481,34.433 19.557,34.761 17.516,34.761ZM17.448,30.687C21.126,30.687 24.252,29.386 26.826,26.784C29.4,24.181 30.687,21.047 30.687,17.381C30.687,13.714 29.4,10.58 26.826,7.977C24.252,5.375 21.126,4.074 17.448,4.074C13.733,4.074 10.575,5.375 7.975,7.977C5.374,10.58 4.074,13.714 4.074,17.381C4.074,21.047 5.374,24.181 7.975,26.784C10.575,29.386 13.733,30.687 17.448,30.687Z" />
</vector>

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="M840,320Q872,320 896,344Q920,368 920,400L920,480Q920,487 918,495Q916,503 914,510L794,792Q785,812 764,826Q743,840 720,840L280,840Q280,840 280,840Q280,840 280,840L280,320Q280,320 280,320Q280,320 280,320L520,82Q535,67 555.5,64.5Q576,62 595,72Q614,82 623,100Q632,118 627,137L582,320L840,320ZM360,354L360,760Q360,760 360,760Q360,760 360,760L720,760Q720,760 720,760Q720,760 720,760L840,480L840,400Q840,400 840,400Q840,400 840,400L480,400L534,180L360,354ZM160,840Q127,840 103.5,816.5Q80,793 80,760L80,400Q80,367 103.5,343.5Q127,320 160,320L280,320Q280,320 280,320Q280,320 280,320L280,400L160,400Q160,400 160,400Q160,400 160,400L160,760Q160,760 160,760Q160,760 160,760L280,760L280,840Q280,840 280,840Q280,840 280,840L160,840ZM360,760L360,354L360,354L360,400L360,400Q360,400 360,400Q360,400 360,400L360,480L360,760Q360,760 360,760Q360,760 360,760L360,760Q360,760 360,760Q360,760 360,760Z" />
</vector>

View file

@ -108,7 +108,6 @@
<string name="tip_enter_category">أدخل الفئة التي تريد إضافتها</string>
<!--<string name="error_category_duplicate">الفئة مكررة</string>-->
<string name="tip_no_category_chip">لا توجد فئات مخصصة حاليًا. يمكنك إضافتها من الإعدادات.</string>
<string name="tip_no_category_page">لا توجد فئات مخصصة في الوقت الحالي.</string>
<string name="tip_default_category">الفئة الافتراضية، يرجى تعديلها</string>
<string name="label_customization">تخصيص</string>
<string name="pref_developer_options">خيارات المطور</string>

View file

@ -107,7 +107,7 @@
<string name="tip_enter_category">输入你想添加的分类名称</string>
<!--<string name="error_category_duplicate">该分类已经存在</string>-->
<string name="tip_no_category_chip">当前暂无自定义分类,你可以在设置中添加分类</string>
<string name="tip_no_category_page">当前暂无自定义分类</string>
<string name="tip_no_category_page">暂无自定义分类</string>
<string name="tip_default_category">默认分类,请修改</string>
<string name="label_customization">自定义</string>
<string name="pref_developer_options">开发者选项</string>
@ -132,4 +132,6 @@
<string name="action_search">搜索</string>
<string name="action_close">关闭</string>
<string name="sorting_due_date">截止日期</string>
<string name="tip_search_task_not_found">暂未搜索到内容</string>
<string name="tip_no_task_brief">暂无任务</string>
</resources>

View file

@ -108,7 +108,7 @@
<string name="tip_enter_category">Enter the category you want to add</string>
<!--<string name="error_category_duplicate">The category is duplicate</string>-->
<string name="tip_no_category_chip">There are currently no custom categories. You can add categories in the settings.</string>
<string name="tip_no_category_page">There are no custom categories at the moment.</string>
<string name="tip_no_category_page">No custom categories</string>
<string name="tip_default_category">Default Category, please modify</string>
<string name="label_customization">Customization</string>
<string name="pref_developer_options">Developer Options</string>
@ -133,4 +133,6 @@
<string name="action_search">Search</string>
<string name="action_close">Close</string>
<string name="sorting_due_date">Due date</string>
<string name="tip_search_task_not_found">No tasks found</string>
<string name="tip_no_task_brief">No tasks</string>
</resources>