更新
This commit is contained in:
@@ -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() }
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user