Files
TaskTTL/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt
2025-10-15 10:15:55 +08:00

408 lines
16 KiB
Kotlin

package com.taskttl.presentation.task
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FloatingActionButton
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.taskttl.core.routes.Routes
import com.taskttl.core.ui.ActionButtonListItem
import com.taskttl.core.ui.ErrorDialog
import com.taskttl.core.ui.LoadingOverlay
import com.taskttl.core.utils.ToastUtils
import com.taskttl.data.local.model.Task
import com.taskttl.data.state.TaskEffect
import com.taskttl.data.state.TaskIntent
import com.taskttl.data.viewmodel.TaskViewModel
import com.taskttl.ui.components.AppHeader
import com.taskttl.ui.components.CategoryFilter
import com.taskttl.ui.components.SearchBar
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.desc_completed
import taskttl.composeapp.generated.resources.desc_incomplete
import taskttl.composeapp.generated.resources.label_show_completed
import taskttl.composeapp.generated.resources.label_task_list
import taskttl.composeapp.generated.resources.text_add_task_hint
import taskttl.composeapp.generated.resources.text_no_tasks
import taskttl.composeapp.generated.resources.title_add_task
import taskttl.composeapp.generated.resources.title_task
@Composable
@Preview
fun TaskScreen(
navController: NavHostController,
viewModel: TaskViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
var isOpenIndex by remember { mutableStateOf<Int?>(null) }
fun closeExpandedItem() {
isOpenIndex = null
}
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is TaskEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
is TaskEffect.NavigateToTaskDetail -> {
navController.navigate(Routes.Main.Task.TaskDetail(effect.taskId))
}
else -> {}
}
}
}
state.error?.let { error ->
ErrorDialog(
errorMessage = state.error,
onDismiss = { viewModel.handleIntent(TaskIntent.ClearError) }
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_task,
trailingIcon = Icons.Default.Search,
onTrailingClick = { viewModel.handleIntent(TaskIntent.SearchView) }
)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(16.dp)
) {
if (state.isSearch) {
SearchBar(
query = state.searchQuery,
onQueryChange = { viewModel.handleIntent(TaskIntent.SearchTasks(it)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
}
CategoryFilter(
categories = state.categories,
selectedCategory = state.selectedCategory,
onCategorySelected = { viewModel.handleIntent(TaskIntent.FilterByCategory(it)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${stringResource(Res.string.label_task_list)} (${state.filteredTasks.size})",
style = MaterialTheme.typography.titleMedium
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.showCompleted,
onCheckedChange = {
viewModel.handleIntent(TaskIntent.ToggleShowCompleted(it))
}
)
Text(
stringResource(Res.string.label_show_completed),
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
state.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.filteredTasks.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(Res.string.text_no_tasks),
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.text_add_task_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize().clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { if (isOpenIndex != null) closeExpandedItem() },
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(Modifier) }
itemsIndexed(
state.filteredTasks,
key = { index, item -> item.id }) { index, task ->
var isOpen by remember { mutableStateOf(false) }
TaskCardItem(
task = task,
isOpen = isOpenIndex == index && isOpen,
onOpenChange = {
isOpenIndex = if (it) index else null
isOpen = it
},
onClick = {
navController.navigate(Routes.Main.Task.TaskDetail(task.id))
},
onToggleComplete = {
viewModel.handleIntent(TaskIntent.ToggleTaskCompletion(task.id))
},
onDeleteTask = {
viewModel.handleIntent(TaskIntent.DeleteTask(task.id))
}
)
}
}
}
}
}
}
// 悬浮按钮
FloatingActionButton(
onClick = { navController.navigate(Routes.Main.Task.AddTask) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
containerColor = Color(0xFF667EEA)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(Res.string.title_add_task)
)
}
LoadingOverlay(state.isLoading)
}
}
/**
* 任务卡项目
* @param [task] 任务
* @param [isOpen] 是开放
* @param [alignment] 对齐
* @param [onOpenChange] 论开放式变革
* @param [onClick] 单击
* @param [onToggleComplete] 切换完成
* @param [onDeleteTask] 关于删除任务
* @param [modifier] 修饰符
*/
@Composable
fun TaskCardItem(
task: Task,
isOpen: Boolean,
alignment: Alignment.Horizontal = Alignment.End,
onOpenChange: (Boolean) -> Unit,
onClick: () -> Unit,
onToggleComplete: () -> Unit,
onDeleteTask: () -> Unit,
modifier: Modifier = Modifier
) {
ActionButtonListItem(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
isOpen = isOpen,
actionAlignment = alignment,
onOpenChange = onOpenChange,
onClick = onClick
) {
Card(
modifier = modifier
.fillMaxWidth()
.background(Color.Transparent, shape = RoundedCornerShape(12.dp))
.clickable { onClick.invoke() },
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 完成状态按钮
IconButton(
onClick = onToggleComplete,
modifier = Modifier.size(24.dp)
) {
val isCompleted =
if (task.isCompleted) Res.string.desc_completed else Res.string.desc_incomplete
Icon(
imageVector = if (task.isCompleted) Icons.Filled.CheckCircle else Icons.Outlined.Circle,
contentDescription = stringResource(isCompleted),
tint = if (task.isCompleted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(12.dp))
// 任务内容
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
color = if (task.isCompleted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (task.description.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = task.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
// 优先级标签
Box(
modifier = Modifier
.background(
color = task.priority.color.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = stringResource(task.priority.displayNameRes),
style = MaterialTheme.typography.labelSmall,
color = task.priority.color
)
}
Spacer(modifier = Modifier.width(8.dp))
// 分类标签
Box(
modifier = Modifier
.background(
color = task.category.color.backgroundColor,
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = task.category.name,
style = MaterialTheme.typography.labelSmall,
color = task.category.color.textColor
)
}
}
}
}
FilledIconButton(
onClick = { onDeleteTask.invoke() },
shape = CircleShape,
modifier = Modifier
.size(32.dp)
.aspectRatio(1f),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = Color(0xffff1111),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = Icons.Rounded.Delete.name
)
}
}
}