This commit is contained in:
2025-10-08 18:08:15 +08:00
parent dc71bb19a9
commit 989e5be041
109 changed files with 10815 additions and 170 deletions

View File

@@ -0,0 +1,372 @@
package com.taskttl.presentation.category
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.taskttl.data.local.model.Category
import com.taskttl.data.local.model.CategoryColor
import com.taskttl.data.local.model.CategoryIcon
import com.taskttl.data.local.model.CategoryType
import com.taskttl.data.state.CategoryEffect
import com.taskttl.data.state.CategoryIntent
import com.taskttl.data.viewmodel.CategoryViewModel
import com.taskttl.ui.components.AppHeader
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.label_category_name
import taskttl.composeapp.generated.resources.label_category_type
import taskttl.composeapp.generated.resources.label_countdown_category
import taskttl.composeapp.generated.resources.label_select_color
import taskttl.composeapp.generated.resources.label_select_icon
import taskttl.composeapp.generated.resources.label_task_category
import taskttl.composeapp.generated.resources.placeholder_category_name
import taskttl.composeapp.generated.resources.title_add_category
import taskttl.composeapp.generated.resources.title_edit_category
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* 类别编辑屏幕
* @param [categoryId] 类别ID
* @param [onNavigateBack] 上导航返回
* @param [viewModel] 视图模型
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalUuidApi::class)
@Composable
fun CategoryEditScreen(
categoryId: String? = null,
onNavigateBack: () -> Unit,
viewModel: CategoryViewModel = koinViewModel()
) {
LaunchedEffect(categoryId) {
categoryId?.let { viewModel.handleIntent(CategoryIntent.GetCategoryById(it)) }
}
val state by viewModel.state.collectAsState()
val editingCategory = state.editingCategory
var name by remember { mutableStateOf("") }
var color by remember { mutableStateOf(CategoryColor.BLUE) }
var icon by remember { mutableStateOf(CategoryIcon.BRIEFCASE) }
var type by remember { mutableStateOf(CategoryType.TASK) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CategoryEffect.NavigateBack -> {
onNavigateBack.invoke()
}
else -> {}
}
}
}
LaunchedEffect(editingCategory) {
editingCategory?.let {
name = it.name
color = it.color
icon = it.icon
type = it.type
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = if (categoryId == null) Res.string.title_add_category else Res.string.title_edit_category,
showBack = true,
onBackClick = { onNavigateBack.invoke() },
trailingIcon = if (categoryId == null) Icons.Default.Add else Icons.Default.Edit,
onTrailingClick = {
if (name.isNotBlank()) {
val now = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
val category = Category(
id = editingCategory?.id ?: Uuid.random().toString(),
name = name.trim(),
color = color,
icon = icon,
type = type,
createdAt = editingCategory?.createdAt ?: now,
updatedAt = now,
taskCount = editingCategory?.taskCount ?: 0,
countdownCount = editingCategory?.countdownCount ?: 0,
)
if (categoryId == null) {
viewModel.handleIntent(CategoryIntent.AddCategory(category))
} else {
viewModel.handleIntent(CategoryIntent.UpdateCategory(category))
}
}
}
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
item {
// 分类名字
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.label_category_name)) },
placeholder = { Text(stringResource(Res.string.placeholder_category_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
item {
// 分类类型
Text(
text = stringResource(Res.string.label_category_type),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 将 weight 放在这里传入
CategoryTypeOption(
modifier = Modifier.weight(1f),
icon = Icons.AutoMirrored.Filled.List,
text = stringResource(Res.string.label_task_category),
selected = type == CategoryType.TASK,
onClick = { type = CategoryType.TASK }
)
CategoryTypeOption(
modifier = Modifier.weight(1f),
icon = Icons.Default.AccessTime,
text = stringResource(Res.string.label_countdown_category),
selected = type == CategoryType.COUNTDOWN,
onClick = { type = CategoryType.COUNTDOWN }
)
}
}
item {
Text(
text = stringResource(Res.string.label_select_color),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
CategoryColor.entries.forEach { item ->
ColorOption(
colorLong = item,
selected = color == item,
onClick = { color = item },
modifier = Modifier.size(40.dp)
)
}
}
}
item {
Text(
text = stringResource(Res.string.label_select_icon),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
CategoryIcon.entries.forEach {item ->
IconOption(
item = item,
selected = icon == item,
onClick = { icon = item }
)
}
}
// LazyVerticalGrid(
// columns = GridCells.Fixed(6),
// horizontalArrangement = Arrangement.spacedBy(8.dp),
// verticalArrangement = Arrangement.spacedBy(8.dp),
// modifier = Modifier.height(280.dp)
// ) {
// items(CategoryIcon.entries) { item ->
// IconOption(
// item = item,
// selected = icon == item,
// onClick = { icon = item }
// )
// }
// }
}
}
}
}
}
/**
* 分类类型选项(可接收 modifier由父级传 weight
*/
@Composable
private fun CategoryTypeOption(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Card(
modifier = modifier
.clickable { onClick() },
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = text, tint = MaterialTheme.colorScheme.primary)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
}
}
/**
* 图标选项
*/
@Composable
private fun IconOption(item: CategoryIcon, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.06f) else Color.White)
.border(
BorderStroke(
if (selected) 2.dp else 1.dp,
if (selected) MaterialTheme.colorScheme.primary else Color(0xFFE6E6E6)
),
RoundedCornerShape(8.dp)
)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = item.icon,
contentDescription = stringResource(item.displayNameRes),
tint = Color(0xFF666666)
)
}
}
/**
* 颜色选项
*/
@Composable
private fun ColorOption(
colorLong: CategoryColor,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier
) {
Box(
modifier = modifier
.aspectRatio(1f) // 保证宽高相等 ✅
.clip(CircleShape) // 真正的圆形 ✅
.clip(RoundedCornerShape(36.dp))
.background(Color(colorLong.hex))
.border(
BorderStroke(
if (selected) 2.dp else 0.dp,
if (selected) Color.Black else Color.Transparent
),
CircleShape
)
.clickable { onClick() }
)
}

View File

@@ -0,0 +1,364 @@
package com.taskttl.presentation.category
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Edit
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.taskttl.core.routes.Routes.Main
import com.taskttl.core.ui.ActionButtonListItem
import com.taskttl.data.local.model.Category
import com.taskttl.data.local.model.CategoryType
import com.taskttl.data.state.CategoryEffect
import com.taskttl.data.state.CategoryIntent
import com.taskttl.data.viewmodel.CategoryViewModel
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.label_add_category_hint
import taskttl.composeapp.generated.resources.label_countdown_count
import taskttl.composeapp.generated.resources.label_edit
import taskttl.composeapp.generated.resources.label_no_category
import taskttl.composeapp.generated.resources.label_task_count
import taskttl.composeapp.generated.resources.title_add_category
import taskttl.composeapp.generated.resources.title_category
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryScreen(
navController: NavHostController,
onAddCategory: () -> Unit,
onNavigateBack: () -> Unit,
viewModel: CategoryViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CategoryEffect.NavigateBack -> {
onNavigateBack.invoke()
}
else -> {}
}
}
}
// 错误处理
state.error?.let { error ->
LaunchedEffect(error) { viewModel.handleIntent(CategoryIntent.ClearError) }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_category,
showBack = true,
onBackClick = { onNavigateBack.invoke() }
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.padding(16.dp)
) {
CategoryFilterTabs(
selectedType = state.selectedType,
onSelected = { viewModel.handleIntent(CategoryIntent.SelectType(it)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// 分类列表
val categories = when (state.selectedType) {
CategoryType.TASK -> state.taskCategories
CategoryType.COUNTDOWN -> state.countdownCategories
}
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (categories.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(Res.string.label_no_category),
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.label_add_category_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(Modifier) }
itemsIndexed(
items = categories,
key = { index, item -> item.name }) { index, category ->
var isOpen by remember { mutableStateOf(false) }
CategoryCardItem(
category = category,
isOpen = isOpen,
onOpenChange = {},
onEditClick = {
navController.navigate(Main.Settings.EditCategory(category.id))
},
onDeleteClick = {
viewModel.handleIntent(CategoryIntent.DeleteCategory(category.id))
}
)
}
}
}
}
}
// 悬浮按钮
FloatingActionButton(
onClick = { onAddCategory.invoke() },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
containerColor = Color(0xFF667EEA)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(Res.string.title_add_category)
)
}
}
}
@Composable
fun CategoryCardItem(
category: Category,
isOpen: Boolean,
alignment: Alignment.Horizontal = Alignment.End,
onOpenChange: (Boolean) -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier
) {
ActionButtonListItem(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
isOpen = isOpen,
actionAlignment = alignment,
onOpenChange = onOpenChange,
onClick = {}
) {
Card(
modifier = modifier.fillMaxWidth().background(Color.Transparent),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 分类颜色指示器
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(category.color.backgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = category.icon.icon,
contentDescription = stringResource(category.icon.displayNameRes),
tint = category.color.iconColor,
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
// 分类信息
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = category.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Row {
Text(
text = when (category.type) {
CategoryType.TASK -> stringResource(
Res.string.label_task_count,
category.taskCount
)
CategoryType.COUNTDOWN -> stringResource(
Res.string.label_countdown_count,
category.countdownCount
)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 操作按钮
Row {
IconButton(onClick = onEditClick) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(Res.string.label_edit),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
FilledIconButton(
onClick = { onDeleteClick.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
)
}
}
}
@Composable
fun CategoryFilterTabs(
selectedType: CategoryType,
onSelected: (CategoryType) -> Unit,
modifier: Modifier = Modifier
) {
val containerShape = RoundedCornerShape(8.dp)
val tabShape = RoundedCornerShape(6.dp)
Row(
modifier = modifier
.fillMaxWidth()
.background(color = Color(0xFFF5F5F5), shape = containerShape)
.border(width = 1.dp, color = Color(0xFFE0E0E0), shape = containerShape)
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CategoryType.entries.forEach { type ->
val active = type == selectedType
val textColor by animateColorAsState(
if (active) Color(0xFF333333) else Color(
0xFF666666
)
)
val backgroundColor by animateColorAsState(if (active) Color.White else Color.Transparent)
val elevation = if (active) 4.dp else 0.dp
Box(
modifier = Modifier
.weight(1f)
.shadow(elevation, shape = tabShape, clip = false)
.background(backgroundColor, tabShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null // ripple 可选
) { onSelected(type) }
.height(36.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(type.displayNameRes),
color = textColor,
fontSize = 14.sp
)
}
}
}
}

View File

@@ -0,0 +1,258 @@
package com.taskttl.presentation.countdown
import androidx.compose.foundation.background
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.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.taskttl.core.ui.Chip
import com.taskttl.core.utils.DateUtils
import com.taskttl.data.state.CountdownEffect
import com.taskttl.data.viewmodel.CountdownViewModel
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.countdown_not_found
import taskttl.composeapp.generated.resources.created_at
import taskttl.composeapp.generated.resources.detail_information
import taskttl.composeapp.generated.resources.event_description
import taskttl.composeapp.generated.resources.label_days
import taskttl.composeapp.generated.resources.reminder
import taskttl.composeapp.generated.resources.title_countdown_info
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CountdownDetailScreen(
countdownId: String,
onNavigateBack: () -> Unit,
onNavigateToEdit: () -> Unit,
viewModel: CountdownViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
val countdown = state.countdowns.find { it.id == countdownId }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CountdownEffect.NavigateBack -> {
onNavigateBack()
}
else -> {}
}
}
}
if (countdown == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(stringResource(Res.string.countdown_not_found))
}
return
}
// 剩余天数
val daysRemaining = DateUtils.daysRemaining(countdown.targetDate)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_countdown_info,
showBack = true,
onBackClick = { onNavigateBack.invoke() },
trailingIcon = Icons.Default.Edit,
onTrailingClick = { onNavigateToEdit.invoke() }
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.padding(16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(
brush = androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(
countdown.category.color.backgroundColor,
Color.Transparent
)
)
)
.padding(20.dp)
) {
Column(modifier = Modifier.align(Alignment.CenterStart)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = countdown.title,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF111111)
)
Spacer(modifier = Modifier.width(6.dp))
Chip(text = countdown.category.name)
}
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = countdown.targetDate.toString(),
fontSize = 14.sp,
color = Color(0xFF444444)
)
}
}
Column(
modifier = Modifier.align(Alignment.TopEnd),
horizontalAlignment = Alignment.End
) {
Text(
text = daysRemaining.toString(),
fontSize = 44.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF111111)
)
Text(
text = stringResource(Res.string.label_days),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF666666)
)
}
}
countdown.description.let {
Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(Res.string.event_description),
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = Color(0xFF333333)
)
Spacer(modifier = Modifier.height(10.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.padding(12.dp)
) {
Column {
Text(
text = countdown.description,
color = countdown.category.color.textColor,
lineHeight = 20.sp
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(Res.string.detail_information),
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = Color(0xFF333333)
)
Spacer(modifier = Modifier.height(10.dp))
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
InfoItem(
iconTint = Color(0xFF667EEA),
text = "${stringResource(Res.string.reminder)}${
stringResource(countdown.notificationEnabled.displayNameRes)
}",
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
iconTint = Color(0xFF999999),
text = "${stringResource(Res.string.created_at)}${countdown.createdAt}",
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
}
@Composable
private fun InfoItem(iconTint: Color, text: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(Color.White)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(18.dp)
.clip(RoundedCornerShape(6.dp))
.background(iconTint)
) {}
Spacer(modifier = Modifier.width(10.dp))
Text(text = text, fontSize = 13.sp, color = Color(0xFF555555))
}
}

View File

@@ -0,0 +1,274 @@
package com.taskttl.presentation.countdown
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.taskttl.data.local.model.Category
import com.taskttl.data.local.model.Countdown
import com.taskttl.data.local.model.ReminderFrequency
import com.taskttl.data.state.CountdownEffect
import com.taskttl.data.state.CountdownIntent
import com.taskttl.data.viewmodel.CountdownViewModel
import com.taskttl.ui.components.AppHeader
import com.taskttl.ui.components.CategoryCard
import com.taskttl.ui.components.CompactDatePickerDialog
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.desc_select_date
import taskttl.composeapp.generated.resources.label_countdown_description
import taskttl.composeapp.generated.resources.label_countdown_title
import taskttl.composeapp.generated.resources.label_notification_setting
import taskttl.composeapp.generated.resources.label_select_category
import taskttl.composeapp.generated.resources.label_target_date
import taskttl.composeapp.generated.resources.title_add_countdown
import taskttl.composeapp.generated.resources.title_edit_countdown
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalUuidApi::class)
@Composable
fun CountdownEditScreen(
countdownId: String? = null,
onNavigateBack: () -> Unit,
viewModel: CountdownViewModel = koinViewModel()
) {
LaunchedEffect(countdownId) {
countdownId?.let { viewModel.handleIntent(CountdownIntent.GetCountdownById(it)) }
}
val state by viewModel.state.collectAsState()
val editingCountdown = state.editingCountdown
var showDatePicker by remember { mutableStateOf(false) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<Category?>(null) }
var targetDate by remember { mutableStateOf<LocalDateTime?>(null) }
var notificationEnabled by remember { mutableStateOf(ReminderFrequency.OFF) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CountdownEffect.NavigateBack -> {
onNavigateBack.invoke()
}
else -> {}
}
}
}
LaunchedEffect(editingCountdown) {
editingCountdown?.let {
title = it.title
description = it.description
selectedCategory = it.category
targetDate = it.targetDate
notificationEnabled = it.notificationEnabled
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = if (countdownId == null) Res.string.title_add_countdown else Res.string.title_edit_countdown,
showBack = true,
onBackClick = { onNavigateBack.invoke() },
trailingIcon = if (countdownId == null) Icons.Default.Add else Icons.Default.Edit,
onTrailingClick = {
if (title.isNotBlank() && targetDate != null && selectedCategory != null) {
val now = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
val countdown = Countdown(
id = editingCountdown?.id ?: Uuid.random().toString(),
title = title.trim(),
description = description.trim(),
category = selectedCategory!!,
targetDate = targetDate!!,
createdAt = now,
updatedAt = now,
notificationEnabled = notificationEnabled
)
if (countdownId == null) {
viewModel.handleIntent(CountdownIntent.AddCountdown(countdown))
} else {
editingCountdown?.let { countdown.isActive = editingCountdown.isActive }
viewModel.handleIntent(CountdownIntent.UpdateCountdown(countdown))
}
}
}
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
// 倒数日标题
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(Res.string.label_countdown_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
// 倒数日描述
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.label_countdown_description)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Spacer(modifier = Modifier.height(24.dp))
// 分类选择
Text(
text = stringResource(Res.string.label_select_category),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(80.dp)
) {
items(state.categories) { category ->
CategoryCard(
category = category,
isSelected = selectedCategory == category,
onClick = { selectedCategory = category }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 目标日期
OutlinedTextField(
value = targetDate?.toString() ?: "",
onValueChange = { },
label = { Text(stringResource(Res.string.label_target_date)) },
modifier = Modifier.fillMaxWidth().clickable { showDatePicker = true },
readOnly = true,
trailingIcon = {
IconButton(onClick = { showDatePicker = true }) {
Icon(
Icons.Default.DateRange,
contentDescription = stringResource(Res.string.desc_select_date)
)
}
}
)
CompactDatePickerDialog(
show = showDatePicker,
initialSelected = targetDate,
onConfirm = { selected -> targetDate = selected },
onDismiss = { showDatePicker = false }
)
Spacer(modifier = Modifier.height(24.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
) {
Text(
text = stringResource(Res.string.label_notification_setting),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
ReminderFrequency.entries.forEach { frequency ->
FilterChip(
selected = notificationEnabled == frequency,
onClick = { notificationEnabled = frequency },
label = {
Text(
text = stringResource(frequency.displayNameRes),
textAlign = TextAlign.Center,
fontSize = 10.sp,
modifier = Modifier.fillMaxWidth()
)
},
modifier = Modifier.weight(1f),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(
alpha = 0.2f
),
selectedLabelColor = MaterialTheme.colorScheme.primary
),
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
}
}

View File

@@ -0,0 +1,543 @@
package com.taskttl.presentation.countdown
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.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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.CalendarToday
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.taskttl.core.routes.Routes
import com.taskttl.core.utils.DateUtils
import com.taskttl.data.local.model.Countdown
import com.taskttl.data.state.CountdownEffect
import com.taskttl.data.state.CountdownIntent
import com.taskttl.data.viewmodel.CountdownViewModel
import com.taskttl.ui.components.AppHeader
import com.taskttl.ui.components.CategoryFilter
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.delete
import taskttl.composeapp.generated.resources.desc_add_countdown
import taskttl.composeapp.generated.resources.label_countdown_list
import taskttl.composeapp.generated.resources.label_days
import taskttl.composeapp.generated.resources.label_edit
import taskttl.composeapp.generated.resources.text_add_countdown_tip
import taskttl.composeapp.generated.resources.text_no_countdowns
import taskttl.composeapp.generated.resources.title_countdown
import taskttl.composeapp.generated.resources.title_edit_countdown
@Composable
@Preview
fun CountdownScreen(
navController: NavHostController,
viewModel: CountdownViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CountdownEffect.NavigateBack -> {
navController.popBackStack()
}
is CountdownEffect.NavigateToCountdownDetail -> {
// onNavigateToCountdownDetail(effect.countdownId)
}
else -> {}
}
}
}
state.error?.let { error ->
LaunchedEffect(error) {
viewModel.handleIntent(CountdownIntent.ClearError)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
Column(modifier = Modifier.fillMaxSize()) {
AppHeader(
title = Res.string.title_countdown,
trailingIcon = Icons.Default.FilterList
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.padding(16.dp)
) {
// 分类筛选
CategoryFilter(
categories = state.categories,
selectedCategory = state.selectedCategory,
onCategorySelected = {
viewModel.handleIntent(CountdownIntent.FilterByCategory(it))
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "${stringResource(Res.string.label_countdown_list)} (${state.filteredCountdowns.size})",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
when {
state.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.filteredCountdowns.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(Res.string.text_no_countdowns),
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.text_add_countdown_tip),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.filteredCountdowns) { countdown ->
CountdownCard(
countdown = countdown,
onCardClick = {
navController.navigate(
Routes.Main.Countdown.CountdownDetail(countdown.id)
)
},
onEdit = {
navController.navigate(
Routes.Main.Countdown.EditCountdown(countdown.id)
)
},
onDelete = {
viewModel.handleIntent(
CountdownIntent.DeleteCountdown(countdown.id)
)
}
)
}
}
}
}
}
}
// 悬浮按钮
FloatingActionButton(
onClick = { navController.navigate(Routes.Main.Countdown.AddCountdown) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
containerColor = Color(0xFF667EEA)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(Res.string.desc_add_countdown)
)
}
}
}
@Composable
fun CountdownCard(
countdown: Countdown,
onEdit: () -> Unit = {},
onDelete: () -> Unit = {},
onCardClick: () -> Unit = {}
) {
val countdownTime = DateUtils.calculateCountdownTime(countdown.targetDate)
countdown.category
Box(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(12.dp))
.background(Color.White, RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onCardClick() }
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(countdown.category.color.backgroundColor)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 6.dp, end = 6.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = countdown.title,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF1A1A1A)
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = null,
tint = Color(0xFF666666),
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = countdown.targetDate.toString(),
fontSize = 14.sp,
color = Color(0xFF666666)
)
}
}
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.widthIn(min = 80.dp)
) {
Text(
text = countdownTime.days.toString(),
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = countdown.category.color.textColor
)
Text(
text = stringResource(Res.string.label_days),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF999999)
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 6.dp, end = 6.dp, bottom = 8.dp)
) {
if (countdown.description.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = countdown.description,
fontSize = 13.sp,
color = Color(0xFF999999),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier.background(
countdown.category.color.backgroundColor,
RoundedCornerShape(20.dp)
).padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = countdown.category.icon.icon,
contentDescription = null,
tint = countdown.category.color.iconColor,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = countdown.category.name,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = countdown.category.color.textColor
)
}
var showReminderDialog by remember { mutableStateOf(false) }
var showMoreMenu by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically) {
// IconBut({ showReminderDialog = true }, Icons.Default.Notifications)
// Spacer(modifier = Modifier.width(6.dp))
Box {
IconBut({ showMoreMenu = true }, Icons.Default.MoreHoriz)
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.label_edit)) },
onClick = {
onEdit.invoke();
showMoreMenu = false
}
)
// DropdownMenuItem(
// text = { Text("分享倒数日") },
// onClick = {
// // onShare();
// showMoreMenu = false
// }
// )
DropdownMenuItem(
text = {
Text(
text = stringResource(Res.string.delete),
color = Color.Red
)
},
onClick = {
onDelete.invoke();
showMoreMenu = false
}
)
}
}
}
if (showReminderDialog) {
ReminderDialog(
onDismiss = { showReminderDialog = false },
onSave = { /* TODO: 保存提醒逻辑 */ }
)
}
}
}
}
}
@Composable
fun IconBut(onClick: () -> Unit = {}, icon: ImageVector) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(Color(0xFFF5F5F5))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color(0xFF666666),
modifier = Modifier.size(20.dp)
)
}
}
@Composable
fun ReminderDialog(
onDismiss: () -> Unit,
onSave: (String) -> Unit
) {
var isEnabled by remember { mutableStateOf(true) }
var timeBefore by remember { mutableStateOf("1天前") }
var expanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { onDismiss() },
title = { Text("提醒设置") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("启用提醒")
Spacer(modifier = Modifier.weight(1f))
Switch(checked = isEnabled, onCheckedChange = { isEnabled = it })
}
if (isEnabled) {
Text("提前提醒时间")
Box {
OutlinedButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text(timeBefore)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
listOf("当天", "1天前", "3天前", "1周前").forEach {
DropdownMenuItem(
text = { Text(it) },
onClick = {
timeBefore = it
expanded = false
}
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = {
onSave(timeBefore)
onDismiss()
}) {
Text("保存")
}
},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text("取消")
}
}
)
}
@Composable
fun MoreActionsDialog(
onDismiss: () -> Unit,
onEdit: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit
) {
AlertDialog(
onDismissRequest = { onDismiss() },
title = { Text("更多操作") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ActionItem("编辑倒数日", Icons.Default.Edit) {
onEdit(); onDismiss()
}
ActionItem("分享倒数日", Icons.Default.Share) {
onShare(); onDismiss()
}
ActionItem("删除倒数日", Icons.Default.Delete, danger = true) {
onDelete(); onDismiss()
}
}
},
confirmButton = {
TextButton(onClick = { onDismiss() }) {
Text("关闭")
}
}
)
}
@Composable
private fun ActionItem(
text: String,
icon: ImageVector,
danger: Boolean = false,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onClick() }
.background(if (danger) Color(0xFFFFE6E6) else Color(0xFFF5F5F5))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (danger) Color(0xFFE53E3E) else Color(0xFF555555)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = text,
color = if (danger) Color(0xFFE53E3E) else Color(0xFF333333),
style = MaterialTheme.typography.bodyMedium
)
}
}

View File

@@ -0,0 +1,207 @@
package com.taskttl.presentation.onboarding
import androidx.compose.foundation.background
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.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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.taskttl.core.routes.Routes
import com.taskttl.data.local.model.OnboardingPage
import com.taskttl.data.state.OnboardingEvent
import com.taskttl.data.viewmodel.OnboardingViewModel
import kotlinx.coroutines.flow.collectLatest
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.continue_text
import taskttl.composeapp.generated.resources.get_start_text
import taskttl.composeapp.generated.resources.skip_text
/**
* 引导视图
* @param [navigatorToRoute] 导航到路线
* @param [viewModel] 视图模型
*/
@Preview
@Composable
fun OnboardingScreen(
navigatorToRoute: (Routes) -> Unit,
viewModel: OnboardingViewModel = koinViewModel()
) {
val onboardingPages = OnboardingPage.entries
val pagerState =
rememberPagerState(0, initialPageOffsetFraction = 0f, pageCount = { onboardingPages.size })
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is OnboardingEvent.NextPage -> {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
is OnboardingEvent.NavMain -> {
navigatorToRoute(Routes.Main)
}
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
// 右上角跳过
TextButton(
onClick = { viewModel.markOnboardingCompleted() },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 32.dp, end = 16.dp)
) {
Text(
stringResource(Res.string.skip_text) + ">",
fontSize = 16.sp,
color = Color.Black.copy(alpha = 0.6f)
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 30.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) { page ->
OnboardingPage(pageData = onboardingPages[page])
}
Spacer(modifier = Modifier.height(20.dp))
// 圆点指示器
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
repeat(onboardingPages.size) { index ->
IndicatorDot(active = pagerState.currentPage == index)
}
}
Spacer(modifier = Modifier.height(30.dp))
// 底部按钮
if (pagerState.currentPage < onboardingPages.lastIndex) {
Button(
onClick = {
if (pagerState.currentPage < onboardingPages.lastIndex) {
viewModel.sendEvent(OnboardingEvent.NextPage)
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF667EEA)),
shape = MaterialTheme.shapes.medium
) {
Text(
stringResource(Res.string.continue_text),
color = Color.White,
fontSize = 18.sp
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = { viewModel.markOnboardingCompleted() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF667EEA)),
shape = MaterialTheme.shapes.medium
) {
Text(
stringResource(Res.string.get_start_text),
color = Color.White,
fontSize = 18.sp
)
}
}
}
}
}
}
@Composable
fun OnboardingPage(pageData: OnboardingPage) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = pageData.icon,
contentDescription = null,
tint = pageData.color,
modifier = Modifier
.size(200.dp)
.padding(bottom = 30.dp)
)
Text(
text = stringResource(pageData.titleRes),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF333333),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 20.dp)
)
Text(
text = stringResource(pageData.descRes),
fontSize = 16.sp,
color = Color(0xFF666666),
lineHeight = 24.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
@Composable
fun IndicatorDot(active: Boolean) {
Box(
modifier = Modifier
.size(12.dp)
.background(
color = if (active) Color(0xFF667EEA) else Color(0xFFDDDDDD),
shape = CircleShape
)
)
}

View File

@@ -0,0 +1,313 @@
package com.taskttl.presentation.settings
import androidx.compose.foundation.background
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.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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Assignment
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.all_rights_reserved
import taskttl.composeapp.generated.resources.app_intro_content
import taskttl.composeapp.generated.resources.app_intro_title
import taskttl.composeapp.generated.resources.app_name
import taskttl.composeapp.generated.resources.app_name_description
import taskttl.composeapp.generated.resources.build_version
import taskttl.composeapp.generated.resources.contact_us
import taskttl.composeapp.generated.resources.copyright_year
import taskttl.composeapp.generated.resources.developer_text
import taskttl.composeapp.generated.resources.devttl_team
import taskttl.composeapp.generated.resources.email
import taskttl.composeapp.generated.resources.email_text
import taskttl.composeapp.generated.resources.tech_stack
import taskttl.composeapp.generated.resources.tech_stack_compose
import taskttl.composeapp.generated.resources.tech_stack_kmp
import taskttl.composeapp.generated.resources.tech_stack_koin
import taskttl.composeapp.generated.resources.tech_stack_mvi
import taskttl.composeapp.generated.resources.tech_stack_room
import taskttl.composeapp.generated.resources.title_about
import taskttl.composeapp.generated.resources.version
import taskttl.composeapp.generated.resources.web_text
import taskttl.composeapp.generated.resources.web_url
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
onNavigateBack: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_about,
showBack = true,
onBackClick = { onNavigateBack.invoke() }
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
// 应用图标和名称
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Assignment,
contentDescription = stringResource(Res.string.app_name),
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.app_name),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(Res.string.app_name_description),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
// 版本信息
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
AboutInfoRow(labelRes = Res.string.version, value = "1.0.0")
Spacer(modifier = Modifier.height(8.dp))
AboutInfoRow(labelRes = Res.string.build_version, value = "1")
}
}
Spacer(modifier = Modifier.height(16.dp))
// 应用描述
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(Res.string.app_intro_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.app_intro_content),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Justify
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 技术栈
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(Res.string.tech_stack),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
TechStackItem("Kotlin Multiplatform", Res.string.tech_stack_kmp)
TechStackItem("Jetpack Compose", Res.string.tech_stack_compose)
TechStackItem("Room Database", Res.string.tech_stack_room)
TechStackItem("Koin", Res.string.tech_stack_koin)
TechStackItem("MVI Architecture", Res.string.tech_stack_mvi)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 开发者信息
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(Res.string.developer_text),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.devttl_team),
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 联系方式
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(Res.string.contact_us),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
ContactItem(
icon = Icons.Default.Email,
labelRes = Res.string.email_text,
valueRes = Res.string.email
)
Spacer(modifier = Modifier.height(8.dp))
ContactItem(
icon = Icons.Default.Language,
labelRes = Res.string.web_text,
valueRes = Res.string.web_url
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 版权信息
Text(
text = "© ${stringResource(Res.string.copyright_year)} " +
"${stringResource(Res.string.devttl_team)}. " +
"${stringResource(Res.string.all_rights_reserved)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun AboutInfoRow(
labelRes: StringResource,
value: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun TechStackItem(
name: String,
descriptionRes: StringResource
) {
Column {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun ContactItem(
icon: ImageVector,
labelRes: StringResource,
valueRes: StringResource
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = stringResource(labelRes),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(valueRes),
style = MaterialTheme.typography.bodyMedium
)
}
}
}

View File

@@ -0,0 +1,371 @@
package com.taskttl.presentation.settings
import androidx.compose.foundation.background
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.cancel
import taskttl.composeapp.generated.resources.confirm
import taskttl.composeapp.generated.resources.desc_auto_backup
import taskttl.composeapp.generated.resources.desc_clear_all_data
import taskttl.composeapp.generated.resources.desc_clear_all_data_dialog
import taskttl.composeapp.generated.resources.desc_clear_completed_tasks
import taskttl.composeapp.generated.resources.desc_clear_expired_countdowns
import taskttl.composeapp.generated.resources.desc_export_data
import taskttl.composeapp.generated.resources.desc_import_data
import taskttl.composeapp.generated.resources.export
import taskttl.composeapp.generated.resources.import
import taskttl.composeapp.generated.resources.label_csv_format
import taskttl.composeapp.generated.resources.label_enter
import taskttl.composeapp.generated.resources.label_json_format
import taskttl.composeapp.generated.resources.label_select_export_format
import taskttl.composeapp.generated.resources.label_select_file
import taskttl.composeapp.generated.resources.label_select_import_file
import taskttl.composeapp.generated.resources.title_auto_backup
import taskttl.composeapp.generated.resources.title_backup_restore
import taskttl.composeapp.generated.resources.title_clear_all_data
import taskttl.composeapp.generated.resources.title_clear_completed_tasks
import taskttl.composeapp.generated.resources.title_clear_expired_countdowns
import taskttl.composeapp.generated.resources.title_data_clean
import taskttl.composeapp.generated.resources.title_data_management
import taskttl.composeapp.generated.resources.title_export_data
import taskttl.composeapp.generated.resources.title_import_data
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DataManagementScreen(
onNavigateBack: () -> Unit
) {
var showExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showClearDataDialog by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_data_management,
showBack = true,
onBackClick = { onNavigateBack.invoke() }
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
text = stringResource(Res.string.title_backup_restore),
style = MaterialTheme.typography.titleLarge
)
}
item {
DataManagementCard(
icon = Icons.Default.CloudUpload,
titleRes = Res.string.title_export_data,
descriptionRes = Res.string.desc_export_data,
onClick = { showExportDialog = true }
)
}
item {
DataManagementCard(
icon = Icons.Default.CloudDownload,
titleRes = Res.string.title_import_data,
descriptionRes = Res.string.desc_import_data,
onClick = { showImportDialog = true }
)
}
item {
DataManagementCard(
icon = Icons.Default.Sync,
titleRes = Res.string.title_auto_backup,
descriptionRes = Res.string.desc_auto_backup,
onClick = { /* TODO: 自动备份设置 */ }
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.title_data_clean),
style = MaterialTheme.typography.titleLarge
)
}
item {
DataManagementCard(
icon = Icons.Default.Delete,
titleRes = Res.string.title_clear_all_data,
descriptionRes = Res.string.desc_clear_all_data,
onClick = { showClearDataDialog = true },
isDestructive = true
)
}
item {
DataManagementCard(
icon = Icons.Default.CleaningServices,
titleRes = Res.string.title_clear_completed_tasks,
descriptionRes = Res.string.desc_clear_completed_tasks,
onClick = { /* TODO: 清理已完成任务 */ }
)
}
item {
DataManagementCard(
icon = Icons.Default.History,
titleRes = Res.string.title_clear_expired_countdowns,
descriptionRes = Res.string.desc_clear_expired_countdowns,
onClick = { /* TODO: 清理过期倒数日 */ }
)
}
}
}
// 导出对话框
if (showExportDialog) {
ExportDataDialog(
onDismiss = { showExportDialog = false },
onExport = { format ->
// TODO: 实现导出功能
showExportDialog = false
}
)
}
// 导入对话框
if (showImportDialog) {
ImportDataDialog(
onDismiss = { showImportDialog = false },
onImport = {
// TODO: 实现导入功能
showImportDialog = false
}
)
}
// 清除数据确认对话框
if (showClearDataDialog) {
AlertDialog(
onDismissRequest = { showClearDataDialog = false },
title = { Text(stringResource(Res.string.title_clear_all_data)) },
text = { Text(stringResource(Res.string.desc_clear_all_data_dialog)) },
confirmButton = {
TextButton(
onClick = {
// TODO: 实现清除数据功能
showClearDataDialog = false
}
) {
Text(stringResource(Res.string.confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showClearDataDialog = false }) {
Text(stringResource(Res.string.cancel))
}
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DataManagementCard(
icon: ImageVector,
titleRes: StringResource,
descriptionRes: StringResource,
onClick: () -> Unit,
isDestructive: Boolean = false
) {
Card(
onClick = onClick,
colors = if (isDestructive) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
} else {
CardDefaults.cardColors()
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = stringResource(titleRes),
tint = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleMedium,
color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = stringResource(Res.string.label_enter),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ExportDataDialog(
onDismiss: () -> Unit,
onExport: (String) -> Unit
) {
var selectedFormat by remember { mutableStateOf("JSON") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.title_export_data)) },
text = {
Column {
Text("${stringResource(Res.string.label_select_export_format)}")
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedFormat == "JSON",
onClick = { selectedFormat = "JSON" }
)
Text(stringResource(Res.string.label_json_format))
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedFormat == "CSV",
onClick = { selectedFormat = "CSV" }
)
Text(stringResource(Res.string.label_csv_format))
}
}
},
confirmButton = {
TextButton(onClick = { onExport(selectedFormat) }) {
Text(stringResource(Res.string.export))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.cancel))
}
}
)
}
@Composable
private fun ImportDataDialog(
onDismiss: () -> Unit,
onImport: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.title_import_data)) },
text = {
Column {
Text("${stringResource(Res.string.label_select_import_file)}")
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { /* TODO: 文件选择器 */ },
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.AttachFile,
contentDescription = stringResource(Res.string.label_select_file)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(Res.string.label_select_file))
}
}
},
confirmButton = {
TextButton(onClick = onImport) {
Text(stringResource(Res.string.import))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.cancel))
}
}
)
}

View File

@@ -0,0 +1,215 @@
package com.taskttl.presentation.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.taskttl.core.domain.FeedbackType
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.stringResource
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.button_cancel
import taskttl.composeapp.generated.resources.button_send_feedback
import taskttl.composeapp.generated.resources.feedback_contact
import taskttl.composeapp.generated.resources.feedback_contact_placeholder
import taskttl.composeapp.generated.resources.feedback_description
import taskttl.composeapp.generated.resources.feedback_placeholder
import taskttl.composeapp.generated.resources.feedback_type
import taskttl.composeapp.generated.resources.title_feedback
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedbackScreen(
onNavigateBack: () -> Unit,
onSubmit: () -> Unit
) {
var feedbackType by remember { mutableStateOf(FeedbackType.ISSUE) }
var contact by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_feedback,
showBack = true,
onBackClick = { onNavigateBack.invoke() },
trailingIcon = Icons.AutoMirrored.Filled.Send,
onTrailingClick = {
onSubmit()
}
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 反馈类型
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
stringResource(Res.string.feedback_type),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FeedbackType.entries.forEach { item ->
FeedbackTypeOption(
text = stringResource(item.titleRes),
selected = feedbackType == item,
onClick = { feedbackType = item },
icon = Icons.Default.BugReport,
modifier = Modifier.weight(1f)
)
}
}
}
}
// 问题描述
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
stringResource(Res.string.feedback_description),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
placeholder = { Text(stringResource(Res.string.feedback_placeholder)) }
)
}
}
// 联系方式
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
stringResource(Res.string.feedback_contact),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = contact,
onValueChange = { contact = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(stringResource(Res.string.feedback_contact_placeholder)) }
)
}
}
// 底部按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = {
onSubmit()
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(Res.string.button_send_feedback))
}
OutlinedButton(
onClick = { onNavigateBack.invoke() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(Res.string.button_cancel))
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun FeedbackTypeOption(
text: String,
selected: Boolean,
onClick: () -> Unit,
icon: ImageVector,
modifier: Modifier = Modifier
) {
val borderColor = if (selected) MaterialTheme.colorScheme.primary else Color.LightGray
val bgColor = if (selected) Color(0xFFF0F4FF) else Color.Transparent
val textColor =
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
OutlinedButton(
onClick = onClick,
modifier = modifier,
colors = ButtonDefaults.outlinedButtonColors(containerColor = bgColor),
border = BorderStroke(2.dp, borderColor),
shape = MaterialTheme.shapes.medium
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = null, tint = textColor)
Text(text, color = textColor)
}
}
}

View File

@@ -0,0 +1,49 @@
package com.taskttl.presentation.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.taskttl.core.ui.DevTTLWebView
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.stringResource
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.privacy_url
import taskttl.composeapp.generated.resources.title_about
@Composable
fun PrivacyScreen(onNavigateBack: () -> Unit) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_about,
showBack = true,
onBackClick = { onNavigateBack.invoke() }
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
DevTTLWebView(
modifier = Modifier.fillMaxSize(),
url = stringResource(Res.string.privacy_url)
)
}
}
}
}

View File

@@ -0,0 +1,317 @@
package com.taskttl.presentation.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.taskttl.core.routes.Routes
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.section_data_management
import taskttl.composeapp.generated.resources.section_general_settings
import taskttl.composeapp.generated.resources.section_help_feedback
import taskttl.composeapp.generated.resources.section_social_share
import taskttl.composeapp.generated.resources.setting_about_app
import taskttl.composeapp.generated.resources.setting_about_app_desc
import taskttl.composeapp.generated.resources.setting_category_management
import taskttl.composeapp.generated.resources.setting_category_management_desc
import taskttl.composeapp.generated.resources.setting_dark_mode
import taskttl.composeapp.generated.resources.setting_dark_mode_desc
import taskttl.composeapp.generated.resources.setting_data_management
import taskttl.composeapp.generated.resources.setting_data_management_desc
import taskttl.composeapp.generated.resources.setting_feedback
import taskttl.composeapp.generated.resources.setting_feedback_desc
import taskttl.composeapp.generated.resources.setting_invite_friend
import taskttl.composeapp.generated.resources.setting_invite_friend_desc
import taskttl.composeapp.generated.resources.setting_language
import taskttl.composeapp.generated.resources.setting_language_desc
import taskttl.composeapp.generated.resources.setting_privacy_policy
import taskttl.composeapp.generated.resources.setting_privacy_policy_desc
import taskttl.composeapp.generated.resources.setting_push_notification
import taskttl.composeapp.generated.resources.setting_push_notification_desc
import taskttl.composeapp.generated.resources.setting_share_achievement
import taskttl.composeapp.generated.resources.setting_share_achievement_desc
import taskttl.composeapp.generated.resources.title_app_settings
/**
* 设置屏幕
* @param [navController] 导航控制器
*/
@Composable
@Preview
fun SettingsScreen(
navController: NavHostController,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_app_settings,
trailingIcon = Icons.Default.Person,
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// 用户信息卡片
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(
// brush = Brush.linearGradient(
// colors = listOf(Color(0xFF667EEA), Color(0xFF764BA2))
// ),
// shape = RoundedCornerShape(16.dp)
// )
// .padding(20.dp),
// horizontalAlignment = Alignment.CenterHorizontally
// ) {
// Box(
// modifier = Modifier
// .size(60.dp)
// .background(Color.White.copy(alpha = 0.2f), shape = CircleShape),
// contentAlignment = Alignment.Center
// ) {
// Icon(
// imageVector = Icons.Default.Person,
// contentDescription = "用户头像",
// tint = Color.White
// )
// }
// Spacer(modifier = Modifier.height(12.dp))
// Text(
// "TaskMaster 用户",
// color = Color.White,
// fontWeight = FontWeight.Medium,
// fontSize = 18.sp
// )
// Text(
// "已使用 30 天 · 完成 156 个任务",
// color = Color.White.copy(alpha = 0.9f),
// fontSize = 14.sp
// )
// }
//
// Spacer(modifier = Modifier.height(24.dp))
// 通用设置
// SectionTitle(Icons.Default.Settings, Res.string.section_general_settings)
// var notificationEnabled by remember { mutableStateOf(true) }
// SettingItem(
// titleRes = Res.string.setting_push_notification,
// descriptionRes = Res.string.setting_push_notification_desc,
// showSwitch = true,
// switchState = notificationEnabled,
// onSwitchChanged = { notificationEnabled = it }
// )
// var darkMode by remember { mutableStateOf(false) }
//
// SettingItem(
// titleRes = Res.string.setting_dark_mode,
// descriptionRes = Res.string.setting_dark_mode_desc,
// showSwitch = true,
// switchState = darkMode,
// onSwitchChanged = { darkMode = it }
// )
// SettingItem(
// titleRes = Res.string.setting_language,
// descriptionRes = Res.string.setting_language_desc,
// showArrow = true
// )
Spacer(modifier = Modifier.height(16.dp))
// 数据管理
SectionTitle(Icons.Default.Storage, Res.string.section_data_management)
SettingItem(
titleRes = Res.string.setting_category_management,
descriptionRes = Res.string.setting_category_management_desc,
showArrow = true,
onClick = {
navController.navigate(Routes.Main.Settings.CategoryManagement)
}
)
// SettingItem(
// titleRes = Res.string.setting_data_management,
// descriptionRes = Res.string.setting_data_management_desc,
// showArrow = true,
// onClick = { navController.navigate(Routes.Main.Settings.DataManagement) }
// )
Spacer(modifier = Modifier.height(16.dp))
// // 社交分享
// SectionTitle(Icons.Default.Share, Res.string.section_social_share)
// SettingItem(
// titleRes = Res.string.setting_share_achievement,
// descriptionRes = Res.string.setting_share_achievement_desc,
// showArrow = true
// )
// SettingItem(
// titleRes = Res.string.setting_invite_friend,
// descriptionRes = Res.string.setting_invite_friend_desc,
// showArrow = true
// )
Spacer(modifier = Modifier.height(16.dp))
// 帮助与反馈
SectionTitle(Icons.AutoMirrored.Filled.Help, Res.string.section_help_feedback)
SettingItem(
titleRes = Res.string.setting_feedback,
descriptionRes = Res.string.setting_feedback_desc,
showArrow = true,
onClick = { navController.navigate(Routes.Main.Settings.Feedback) }
)
SettingItem(
titleRes = Res.string.setting_privacy_policy,
descriptionRes = Res.string.setting_privacy_policy_desc,
showArrow = true,
onClick = { navController.navigate(Routes.Main.Settings.Privacy) }
)
SettingItem(
titleRes = Res.string.setting_about_app,
descriptionRes = Res.string.setting_about_app_desc,
showArrow = true,
onClick = { navController.navigate(Routes.Main.Settings.About) }
)
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
/**
* 分区标题
* @param [icon] 图标
* @param [titleRes] 标题
*/
@Composable
fun SectionTitle(icon: ImageVector, titleRes: StringResource) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
Icon(
icon,
contentDescription = null,
tint = Color(0xFF667EEA),
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(titleRes),
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color(0xFF333333)
)
}
}
/**
* 设置项
* @param [titleRes] 标题
* @param [descriptionRes] 描述
* @param [showSwitch] 显示开关
* @param [switchState] 开关状态
* @param [onSwitchChanged] 开关已更改
* @param [showArrow] 显示箭头
* @param [modifier] 修饰符
* @param [onClick] 点击
*/
@Composable
fun SettingItem(
titleRes: StringResource,
descriptionRes: StringResource,
showSwitch: Boolean = false,
switchState: Boolean = false,
onSwitchChanged: ((Boolean) -> Unit)? = null,
showArrow: Boolean = false,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = onClick != null) { onClick?.invoke() }
.background(Color.White, shape = RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(titleRes),
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
color = Color(0xFF333333)
)
descriptionRes.let {
Spacer(modifier = Modifier.height(2.dp))
Text(stringResource(it), fontSize = 14.sp, color = Color(0xFF666666))
}
}
if (showSwitch && onSwitchChanged != null) {
Switch(checked = switchState, onCheckedChange = onSwitchChanged)
} else if (showArrow) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = Color(0xFFCCCCCC)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}

View File

@@ -0,0 +1,143 @@
package com.taskttl.presentation.splash
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ListAlt
import androidx.compose.material3.Icon
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.taskttl.core.routes.Routes
import com.taskttl.data.state.SplashState
import com.taskttl.data.viewmodel.SplashViewModel
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.app_name
import taskttl.composeapp.generated.resources.app_name_remark
/**
* 启动视图
* @param [navigatorToRoute] 导航到路线
* @param [viewModel] 视图模型
*/
@Preview
@Composable
fun SplashScreen(
navigatorToRoute: (Routes) -> Unit,
viewModel: SplashViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(state) {
when (state) {
SplashState.NavigateToOnboarding -> navigatorToRoute(Routes.Onboarding)
SplashState.NavigateToMain -> navigatorToRoute(Routes.Main)
else -> {}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(Color(0xFF667eea), Color(0xFF764ba2)),
start = Offset(0f, 0f),
end = Offset.Infinite
)
)
.padding(12.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 图标
Icon(
imageVector = Icons.AutoMirrored.Filled.ListAlt,
contentDescription = "logo",
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier
.size(200.dp)
.padding(bottom = 20.dp)
)
// 标题
Text(
text = stringResource(Res.string.app_name),
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(bottom = 10.dp)
)
// 副标题
Text(
text = stringResource(Res.string.app_name_remark),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.8f),
modifier = Modifier.padding(bottom = 50.dp)
)
// 底部三个小点
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
PulsingDot(delayMillis = 0)
PulsingDot(delayMillis = 200)
PulsingDot(delayMillis = 400)
}
}
}
}
@Composable
fun PulsingDot(delayMillis: Int) {
val infiniteTransition = rememberInfiniteTransition(label = "")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.5f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = LinearEasing, delayMillis = delayMillis),
repeatMode = RepeatMode.Reverse
),
label = ""
)
Box(
modifier = Modifier
.size(8.dp)
.scale(scale)
.background(Color.White, shape = CircleShape)
)
}

View File

@@ -0,0 +1,352 @@
package com.taskttl.presentation.statistics
import androidx.compose.foundation.background
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.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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Assignment
import androidx.compose.material.icons.automirrored.filled.TrendingUp
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.taskttl.core.routes.Routes
import com.taskttl.core.ui.Chip
import com.taskttl.data.local.model.Category
import com.taskttl.data.state.CountdownIntent
import com.taskttl.data.state.TaskIntent
import com.taskttl.data.viewmodel.CountdownViewModel
import com.taskttl.data.viewmodel.TaskViewModel
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.StringResource
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.category_countdown
import taskttl.composeapp.generated.resources.category_statistics
import taskttl.composeapp.generated.resources.category_task
import taskttl.composeapp.generated.resources.completed
import taskttl.composeapp.generated.resources.completion_rate
import taskttl.composeapp.generated.resources.overview
import taskttl.composeapp.generated.resources.setting_category_management
import taskttl.composeapp.generated.resources.title_statistics
import taskttl.composeapp.generated.resources.total_tasks
@Composable
@Preview
fun StatisticsScreen(
navController: NavHostController,
taskViewModel: TaskViewModel = koinViewModel(),
countdownViewModel: CountdownViewModel = koinViewModel()
) {
val taskState by taskViewModel.state.collectAsState()
val countdownState by countdownViewModel.state.collectAsState()
LaunchedEffect(Unit) {
taskViewModel.handleIntent(TaskIntent.LoadTasks)
countdownViewModel.handleIntent(CountdownIntent.LoadCountdowns)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
AppHeader(
title = Res.string.title_statistics,
trailingIcon = Icons.Default.CalendarToday
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 总览统计
item {
Text(
text = stringResource(Res.string.overview),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
titleRes = Res.string.total_tasks,
value = taskState.tasks.size.toString(),
icon = Icons.AutoMirrored.Filled.Assignment,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
StatisticCard(
titleRes = Res.string.completed,
value = taskState.tasks.count { it.isCompleted }.toString(),
icon = Icons.Default.CheckCircle,
color = Color(0xFF4CAF50),
modifier = Modifier.weight(1f)
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
titleRes = Res.string.category_countdown,
value = countdownState.countdowns.size.toString(),
icon = Icons.Default.Schedule,
color = Color(0xFFFF9800),
modifier = Modifier.weight(1f)
)
val completionRate = if (taskState.tasks.isNotEmpty()) {
(taskState.tasks.count { it.isCompleted }
.toFloat() / taskState.tasks.size * 100).toInt()
} else 0
StatisticCard(
titleRes = Res.string.completion_rate,
value = "$completionRate%",
icon = Icons.AutoMirrored.Filled.TrendingUp,
color = Color(0xFF9C27B0),
modifier = Modifier.weight(1f)
)
}
}
// 分类统计
item {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.category_statistics),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TextButton(onClick = {
navController.navigate(Routes.Main.Settings.CategoryManagement)
}) {
Text(stringResource(Res.string.setting_category_management))
}
}
}
item {
taskState.categories.let {
taskState.categories.forEach { category ->
val categoryTasks = taskState.tasks.filter { it.category == category }
val completedTasks = categoryTasks.count { it.isCompleted }
CategoryStatisticItem(
category = category,
totalCount = categoryTasks.size,
completedCount = completedTasks,
typeRes = Res.string.category_task
)
}
}
countdownState.categories.let {
countdownState.categories.forEach { category ->
val categoryCountdowns =
countdownState.countdowns.filter { it.category == category }
val activeCountdowns = categoryCountdowns.count { it.isActive }
CategoryStatisticItem(
category = category,
totalCount = categoryCountdowns.size,
completedCount = activeCountdowns,
typeRes = Res.string.category_countdown
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StatisticCard(
titleRes: StringResource,
value: String,
icon: ImageVector,
color: Color,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = stringResource(titleRes),
tint = color,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryStatisticItem(
category: Category,
totalCount: Int,
completedCount: Int,
typeRes: StringResource
) {
if (totalCount == 0) return
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 分类颜色指示器
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(category.color.backgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = category.icon.icon,
contentDescription = stringResource(category.icon.displayNameRes),
tint = category.color.iconColor,
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
// 分类信息
Column(
modifier = Modifier.weight(1f)
) {
Row() {
Text(
text = category.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = category.color.textColor
)
Spacer(modifier = Modifier.width(6.dp))
Chip(
textRes = category.type.displayNameRes,
)
}
Text(
text = "${stringResource(typeRes)}: $completedCount/$totalCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 进度条
Column(
horizontalAlignment = Alignment.End
) {
val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f
Text(
text = "${(progress * 100).toInt()}%",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = category.color.textColor
)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.width(80.dp)
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = category.color.textColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
}

View File

@@ -0,0 +1,232 @@
package com.taskttl.presentation.task
import androidx.compose.foundation.background
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.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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 org.jetbrains.compose.resources.stringResource
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_created_at
import taskttl.composeapp.generated.resources.label_description
import taskttl.composeapp.generated.resources.label_due_date
import taskttl.composeapp.generated.resources.label_none
import taskttl.composeapp.generated.resources.text_task_not_found
import taskttl.composeapp.generated.resources.title_task_info
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskDetailScreen(
taskId: String,
onNavigateBack: () -> Unit,
onNavigateToEdit: () -> Unit,
viewModel: TaskViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
val task = state.tasks.find { it.id == taskId }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is TaskEffect.NavigateBack -> {
onNavigateBack.invoke()
}
is TaskEffect.NavigateToEditTask -> {
onNavigateToEdit.invoke()
}
else -> {}
}
}
}
if (task == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(stringResource(Res.string.text_task_not_found))
}
return
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_task_info,
showBack = true,
onBackClick = { viewModel.handleIntent(TaskIntent.NavigateBack) },
trailingIcon = Icons.Default.Edit,
onTrailingClick = { viewModel.handleIntent(TaskIntent.NavigateToEditTask) }
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.Top
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Checkbox 圆点
IconButton(
onClick = { viewModel.handleIntent(TaskIntent.ToggleTaskCompletion(taskId)) },
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))
Text(
text = task.title,
modifier = Modifier.weight(1f),
fontSize = 20.sp,
color = Color(0xFF333333),
fontWeight = FontWeight.SemiBold
)
// 优先级指示器
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.height(16.dp))
// 类型
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
.background(Color(0xFFF8F9FA), RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Icon(Icons.AutoMirrored.Filled.Label, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text(text = task.category.name, color = task.category.color.textColor)
}
// 截止日期
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
.background(Color(0xFFF8F9FA), RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Icon(Icons.Default.CalendarToday, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "${stringResource(Res.string.label_due_date)}${
task.dueDate ?: stringResource(Res.string.label_none)
}"
)
}
// 创建日期
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
.background(Color(0xFFF8F9FA), RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Icon(Icons.Default.Timer, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text(text = "${stringResource(Res.string.label_created_at)}${task.createdAt}")
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(Res.string.label_description),
fontWeight = FontWeight.SemiBold,
color = Color(0xFF333333)
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.background(Color(0xFFF8F9FA), RoundedCornerShape(8.dp))
.padding(15.dp)
) {
Text(text = task.description, color = Color(0xFF666666), lineHeight = 20.sp)
}
}
}
}
}

View File

@@ -0,0 +1,281 @@
package com.taskttl.presentation.task
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.taskttl.data.local.model.Category
import com.taskttl.data.local.model.Task
import com.taskttl.data.local.model.TaskPriority
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.CategoryCard
import com.taskttl.ui.components.CompactDatePickerDialog
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
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_select_date
import taskttl.composeapp.generated.resources.hint_tags
import taskttl.composeapp.generated.resources.title_add_task
import taskttl.composeapp.generated.resources.title_due_date
import taskttl.composeapp.generated.resources.title_edit_task
import taskttl.composeapp.generated.resources.title_priority
import taskttl.composeapp.generated.resources.title_select_category
import taskttl.composeapp.generated.resources.title_tags
import taskttl.composeapp.generated.resources.title_task_description
import taskttl.composeapp.generated.resources.title_task_title
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalUuidApi::class)
@Composable
@Preview
fun TaskEditorScreen(
taskId: String? = null,
onNavigateBack: () -> Unit,
viewModel: TaskViewModel = koinViewModel()
) {
LaunchedEffect(taskId) {
taskId?.let { viewModel.handleIntent(TaskIntent.GetTaskById(it)) }
}
val state by viewModel.state.collectAsState()
val existingTask = state.editingTask
var showDatePicker by remember { mutableStateOf(false) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<Category?>(null) }
var selectedPriority by remember { mutableStateOf(TaskPriority.MEDIUM) }
var dueDate by remember { mutableStateOf(existingTask?.dueDate) }
var tags by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is TaskEffect.NavigateBack -> {
onNavigateBack.invoke()
}
else -> {}
}
}
}
LaunchedEffect(existingTask) {
existingTask?.let {
title = it.title
description = it.description
selectedCategory = it.category
selectedPriority = it.priority
dueDate = it.dueDate
tags = it.tags.joinToString(",")
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = if (taskId == null) Res.string.title_add_task else Res.string.title_edit_task,
showBack = true,
onBackClick = { viewModel.handleIntent(TaskIntent.NavigateBack) },
trailingIcon = if (taskId == null) Icons.Default.Add else Icons.Default.Edit,
onTrailingClick = {
if (title.isNotBlank() && selectedCategory != null) {
val now = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
val task = Task(
id = existingTask?.id ?: Uuid.random().toString(),
title = title.trim(),
description = description.trim(),
category = selectedCategory!!,
priority = selectedPriority,
createdAt = now,
updatedAt = now,
dueDate = dueDate,
tags = tags.split(",").map { it.trim() }.filter { it.isNotEmpty() }
)
if (taskId == null) {
viewModel.handleIntent(TaskIntent.AddTask(task))
} else {
existingTask?.let { task.isCompleted = existingTask.isCompleted }
viewModel.handleIntent(TaskIntent.UpdateTask(task))
}
}
}
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
// 任务标题
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(Res.string.title_task_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
// 任务描述
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.title_task_description)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Spacer(modifier = Modifier.height(24.dp))
// 分类选择
Text(
text = stringResource(Res.string.title_select_category),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(80.dp)
) {
items(state.categories) { category ->
CategoryCard(
category = category,
isSelected = selectedCategory == category,
onClick = { selectedCategory = category }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 优先级选择
Text(
text = stringResource(Res.string.title_priority),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TaskPriority.entries.forEach { priority ->
FilterChip(
selected = selectedPriority == priority,
onClick = { selectedPriority = priority },
label = {
Text(
text = stringResource(priority.displayNameRes),
textAlign = TextAlign.Center,
fontSize = 10.sp,
modifier = Modifier.fillMaxWidth()
)
},
modifier = Modifier.weight(1f),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = priority.color.copy(alpha = 0.2f),
selectedLabelColor = priority.color
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 截止日期
OutlinedTextField(
value = dueDate?.toString() ?: "",
onValueChange = { },
label = { Text(stringResource(Res.string.title_due_date)) },
modifier = Modifier.fillMaxWidth(),
readOnly = true,
trailingIcon = {
IconButton(onClick = { showDatePicker = true }) {
Icon(
Icons.Default.DateRange,
contentDescription = stringResource(Res.string.desc_select_date)
)
}
}
)
CompactDatePickerDialog(
show = showDatePicker,
initialSelected = dueDate,
onConfirm = { selected -> dueDate = selected },
onDismiss = { showDatePicker = false }
)
Spacer(modifier = Modifier.height(16.dp))
// 标签
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
label = { Text(stringResource(Res.string.title_tags)) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(stringResource(Res.string.hint_tags)) }
)
}
}
}
}

View File

@@ -0,0 +1,396 @@
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.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.NavigateToTaskDetail -> {
navController.navigate(Routes.Main.Task.TaskDetail(effect.taskId))
}
else -> {}
}
}
}
state.error?.let { error ->
LaunchedEffect(error) { 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(Color(0xFFF5F5F5))
.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.title }) { 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)
)
}
}
}
/**
* 任务卡项目
* @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
)
}
}
}