feat: 复刻小程序四大主页面与底部导航(UI 优先,本地 mock)
This commit is contained in:
@@ -1,18 +1,380 @@
|
||||
package com.mingyuan.android_client.feature.plan
|
||||
|
||||
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.PaddingValues
|
||||
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.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.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 androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.mingyuan.android_client.core.designsystem.component.AmberProgressBar
|
||||
import com.mingyuan.android_client.core.designsystem.component.Avatar
|
||||
import com.mingyuan.android_client.core.designsystem.component.EmptyState
|
||||
import com.mingyuan.android_client.core.designsystem.component.ErrorState
|
||||
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
|
||||
import com.mingyuan.android_client.core.designsystem.component.SectionCard
|
||||
import com.mingyuan.android_client.core.designsystem.component.SegmentedControl
|
||||
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
|
||||
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
|
||||
import com.mingyuan.android_client.core.designsystem.theme.Danger
|
||||
import com.mingyuan.android_client.core.designsystem.theme.FaintText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.MutedLightText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.MutedText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.NeutralChip
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PlanBackground
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.StatusNormal
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SuccessBackground
|
||||
import com.mingyuan.android_client.core.designsystem.theme.WarningBackground
|
||||
import com.mingyuan.android_client.core.model.MyPlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanGroup
|
||||
import com.mingyuan.android_client.core.model.PlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
|
||||
/** Navigation route for this feature, referenced by the app-level NavHost. */
|
||||
const val PLAN_ROUTE = "plan"
|
||||
|
||||
@Composable
|
||||
fun PlanScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Plan")
|
||||
fun PlanScreen(
|
||||
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlanViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selectedType by viewModel.selectedType.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().background(PlanBackground)) {
|
||||
Box(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
SegmentedControl(
|
||||
labels = PlanType.entries.map { it.label },
|
||||
selectedIndex = PlanType.entries.indexOf(selectedType),
|
||||
onSelect = { viewModel.selectType(PlanType.entries[it]) },
|
||||
)
|
||||
}
|
||||
|
||||
when (val state = uiState) {
|
||||
is PlanUiState.Loading -> PlanSkeleton()
|
||||
is PlanUiState.Error -> ErrorState(
|
||||
message = state.message,
|
||||
onRetry = {},
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
is PlanUiState.Success -> PlanContent(
|
||||
record = state.record,
|
||||
selectedType = selectedType,
|
||||
onNavigateToPlanSubmit = onNavigateToPlanSubmit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanContent(
|
||||
record: PlanRecord,
|
||||
selectedType: PlanType,
|
||||
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
|
||||
) {
|
||||
var selectedGroup by remember { mutableStateOf<PlanGroup?>(null) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { PlanHead(record, selectedType) }
|
||||
item { MyPlanStatusCard(record, selectedType, onNavigateToPlanSubmit) }
|
||||
val myRecord = record.myRecord
|
||||
if (record.myChecked && myRecord != null) {
|
||||
item { UploadedPlanCard(myRecord) }
|
||||
}
|
||||
val message = record.message
|
||||
if (message != null) {
|
||||
item { WindowMessage(message) }
|
||||
}
|
||||
if (record.groups.isEmpty()) {
|
||||
item { EmptyState("📭", "暂无小组") }
|
||||
} else {
|
||||
item {
|
||||
Text("小组打卡", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
items(record.groups, key = { it.id }) { group ->
|
||||
GroupCard(group) { selectedGroup = group }
|
||||
}
|
||||
}
|
||||
if (record.unassignedCount > 0) {
|
||||
item {
|
||||
Text(
|
||||
text = "另有未分组 ${record.unassignedCount} 人",
|
||||
color = FaintText,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedGroup?.let { group ->
|
||||
GroupDetailDialog(group, onDismiss = { selectedGroup = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanHead(record: PlanRecord, selectedType: PlanType) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(selectedType.label, color = PrimaryText, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text(record.periodKey, color = MutedLightText, fontSize = 12.sp)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.background(
|
||||
if (record.myChecked) SuccessBackground else WarningBackground,
|
||||
RoundedCornerShape(50),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (record.myChecked) "已提交" else "待提交",
|
||||
color = if (record.myChecked) StatusNormal else BrandAmber,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyPlanStatusCard(
|
||||
record: PlanRecord,
|
||||
selectedType: PlanType,
|
||||
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
|
||||
) {
|
||||
SectionCard(cornerRadius = 16) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
if (record.myChecked) {
|
||||
Brush.linearGradient(listOf(StatusNormal, Color(0xFF43A047)))
|
||||
} else {
|
||||
Brush.linearGradient(listOf(Danger, Color(0xFFE5736C)))
|
||||
},
|
||||
CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (record.myChecked) "✓" else "●",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (record.myChecked) "已完成" else "未完成",
|
||||
color = if (record.myChecked) StatusNormal else Danger,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text("我的${selectedType.label}", color = MutedText, fontSize = 12.sp)
|
||||
}
|
||||
MingyuanButton(
|
||||
text = if (record.myChecked) "修改" else "新增打卡",
|
||||
onClick = { onNavigateToPlanSubmit(selectedType, record.periodKey) },
|
||||
enabled = record.canSubmit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadedPlanCard(myRecord: MyPlanRecord) {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "我上传的计划",
|
||||
color = PrimaryText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.background(WarningBackground, RoundedCornerShape(50))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text("未完成", color = BrandAmber, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("${myRecord.submittedTime} 提交", color = MutedText, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(myRecord.content, color = SecondaryText, fontSize = 13.sp, lineHeight = 20.sp)
|
||||
if (myRecord.attachments.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(myRecord.attachments.size) {
|
||||
ThumbnailPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThumbnailPlaceholder() {
|
||||
Box(
|
||||
Modifier.size(64.dp).background(NeutralChip, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("🖼️", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WindowMessage(message: String) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(WarningBackground, RoundedCornerShape(10.dp))
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("⏰", fontSize = 14.sp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(message, color = Danger, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupCard(group: PlanGroup, onClick: () -> Unit) {
|
||||
val progress = if (group.memberCount > 0) {
|
||||
group.checkedCount.toFloat() / group.memberCount
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
SectionCard(cornerRadius = 14) {
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
Brush.linearGradient(listOf(Color(0xFFF3E3CF), Color(0xFFFBE7D1))),
|
||||
RoundedCornerShape(12.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(group.name.take(1), color = BrandAmber, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(group.name, color = PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
AmberProgressBar(progress)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "已打卡 ${group.checkedCount}",
|
||||
color = StatusNormal,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "${group.memberCount}人",
|
||||
color = BrandAmber,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("›", color = FaintText, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupDetailDialog(group: PlanGroup, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("关闭") }
|
||||
},
|
||||
title = { Text(group.name, fontSize = 16.sp, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
group.members.forEach { member ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
initial = member.name,
|
||||
size = 32.dp,
|
||||
backgroundColor = if (member.filled) SuccessBackground else NeutralChip,
|
||||
textColor = if (member.filled) StatusNormal else BrandAmber,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = member.name,
|
||||
color = PrimaryText,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = if (member.filled) "已完成" else "未完成",
|
||||
color = if (member.filled) StatusNormal else Danger,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanSkeleton() {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(40.dp), cornerRadius = 12)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(80.dp), cornerRadius = 16)
|
||||
repeat(3) { SkeletonBox(Modifier.fillMaxWidth().height(72.dp), cornerRadius = 14) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.mingyuan.android_client.feature.plan
|
||||
|
||||
import android.net.Uri
|
||||
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.PaddingValues
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.mingyuan.android_client.core.designsystem.component.EmptyState
|
||||
import com.mingyuan.android_client.core.designsystem.component.ErrorState
|
||||
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
|
||||
import com.mingyuan.android_client.core.designsystem.component.SectionCard
|
||||
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
|
||||
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
|
||||
import com.mingyuan.android_client.core.designsystem.theme.Disabled
|
||||
import com.mingyuan.android_client.core.designsystem.theme.FaintText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.MutedText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.TextareaBorder
|
||||
import com.mingyuan.android_client.core.model.MyPlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanSubmitData
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
|
||||
/** Navigation route for this feature, referenced by the app-level NavHost. */
|
||||
const val PLAN_SUBMIT_ROUTE = "plan_submit?planType={planType}&periodKey={periodKey}"
|
||||
|
||||
fun planSubmitRoute(planType: PlanType, periodKey: String): String =
|
||||
"plan_submit?planType=${planType.name}&periodKey=${Uri.encode(periodKey)}"
|
||||
|
||||
@Composable
|
||||
fun PlanSubmitScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlanSubmitViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val saving by viewModel.saving.collectAsStateWithLifecycle()
|
||||
val saved by viewModel.saved.collectAsStateWithLifecycle()
|
||||
|
||||
var content by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
val state = uiState
|
||||
if (state is PlanSubmitUiState.Success && content.isEmpty() && state.data.content.isNotEmpty()) {
|
||||
content = state.data.content
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(saved) {
|
||||
if (saved) {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().background(PageBackground)) {
|
||||
SubmitHeader(viewModel.planType, viewModel.periodKey, onBack)
|
||||
|
||||
when (val state = uiState) {
|
||||
is PlanSubmitUiState.Loading -> SubmitSkeleton()
|
||||
is PlanSubmitUiState.Error -> ErrorState(
|
||||
message = state.message,
|
||||
onRetry = {},
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
is PlanSubmitUiState.Success -> SubmitContent(
|
||||
data = state.data,
|
||||
content = content,
|
||||
onContentChange = { content = it },
|
||||
saving = saving,
|
||||
onSave = { viewModel.save(content) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitHeader(planType: PlanType, periodKey: String, onBack: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(Color.White).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "‹",
|
||||
color = PrimaryText,
|
||||
fontSize = 24.sp,
|
||||
modifier = Modifier.clickable(onClick = onBack).padding(end = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${planType.label} · $periodKey",
|
||||
color = PrimaryText,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitContent(
|
||||
data: PlanSubmitData,
|
||||
content: String,
|
||||
onContentChange: (String) -> Unit,
|
||||
saving: Boolean,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val message = data.message
|
||||
if (!data.canSubmit && message != null) {
|
||||
item { Text(message, color = MutedText, fontSize = 12.sp) }
|
||||
}
|
||||
item {
|
||||
PlanTextarea(
|
||||
content = content,
|
||||
onContentChange = onContentChange,
|
||||
enabled = data.canSubmit,
|
||||
)
|
||||
}
|
||||
item { UploadButton(attachmentsCount = data.attachments.size, enabled = data.canSubmit) }
|
||||
item {
|
||||
MingyuanButton(
|
||||
text = when {
|
||||
!data.canSubmit -> "不可提交"
|
||||
data.checked -> "保存修改"
|
||||
else -> "保存计划"
|
||||
},
|
||||
onClick = onSave,
|
||||
enabled = data.canSubmit && content.isNotBlank() && !saving,
|
||||
fullWidth = true,
|
||||
)
|
||||
}
|
||||
item { HistorySection(data.history) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanTextarea(
|
||||
content: String,
|
||||
onContentChange: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp)
|
||||
.border(1.dp, TextareaBorder, RoundedCornerShape(12.dp))
|
||||
.background(if (enabled) PageBackground else Color(0xFFF6F1EA), RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
if (content.isEmpty()) {
|
||||
Text("写下计划内容", color = FaintText, fontSize = 14.sp)
|
||||
}
|
||||
BasicTextField(
|
||||
value = content,
|
||||
onValueChange = { if (it.length <= 1000) onContentChange(it) },
|
||||
enabled = enabled,
|
||||
textStyle = TextStyle(color = PrimaryText, fontSize = 14.sp),
|
||||
cursorBrush = SolidColor(BrandAmber),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${content.length}/1000",
|
||||
color = FaintText,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadButton(attachmentsCount: Int, enabled: Boolean) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(2.dp, if (enabled) BrandAmber else Disabled, RoundedCornerShape(12.dp))
|
||||
.background(PageBackground, RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+ 添加图片($attachmentsCount/4)",
|
||||
color = if (enabled) BrandAmber else Disabled,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistorySection(history: List<MyPlanRecord>) {
|
||||
Column {
|
||||
Text("历史计划", color = PrimaryText, fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (history.isEmpty()) {
|
||||
EmptyState("📭", "暂无历史计划")
|
||||
} else {
|
||||
history.forEach { item ->
|
||||
SectionCard {
|
||||
Text("${item.submittedTime} · ${item.status}", color = BrandAmber, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(item.content, color = SecondaryText, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitSkeleton() {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(120.dp), cornerRadius = 12)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 12)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.mingyuan.android_client.feature.plan
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.PlanRepository
|
||||
import com.mingyuan.android_client.core.model.PlanSubmitData
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface PlanSubmitUiState {
|
||||
data object Loading : PlanSubmitUiState
|
||||
data class Success(val data: PlanSubmitData) : PlanSubmitUiState
|
||||
data class Error(val message: String) : PlanSubmitUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class PlanSubmitViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val planRepository: PlanRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val planType: PlanType = savedStateHandle.get<String>("planType")
|
||||
?.let { runCatching { PlanType.valueOf(it) }.getOrNull() }
|
||||
?: PlanType.WEEKLY
|
||||
|
||||
val periodKey: String = savedStateHandle.get<String>("periodKey").orEmpty()
|
||||
|
||||
private val _saving = MutableStateFlow(false)
|
||||
val saving: StateFlow<Boolean> = _saving.asStateFlow()
|
||||
|
||||
private val _saved = MutableStateFlow(false)
|
||||
val saved: StateFlow<Boolean> = _saved.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<PlanSubmitUiState> = planRepository.observePlanSubmit(planType, periodKey)
|
||||
.map<PlanSubmitData, PlanSubmitUiState>(PlanSubmitUiState::Success)
|
||||
.onStart { emit(PlanSubmitUiState.Loading) }
|
||||
.catch { emit(PlanSubmitUiState.Error(it.message ?: "加载失败,请稍后再试")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = PlanSubmitUiState.Loading,
|
||||
)
|
||||
|
||||
fun save(content: String) {
|
||||
viewModelScope.launch {
|
||||
_saving.value = true
|
||||
planRepository.savePlan(planType, periodKey, content, emptyList())
|
||||
_saving.value = false
|
||||
_saved.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,48 @@
|
||||
package com.mingyuan.android_client.feature.plan
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.PlanRepository
|
||||
import com.mingyuan.android_client.core.model.PlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface PlanUiState {
|
||||
data object Loading : PlanUiState
|
||||
data class Success(val record: PlanRecord) : PlanUiState
|
||||
data class Error(val message: String) : PlanUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class PlanViewModel @Inject constructor() : ViewModel()
|
||||
class PlanViewModel @Inject constructor(
|
||||
private val planRepository: PlanRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _selectedType = MutableStateFlow(PlanType.WEEKLY)
|
||||
val selectedType: StateFlow<PlanType> = _selectedType.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<PlanUiState> = _selectedType
|
||||
.flatMapLatest { type -> planRepository.observePlan(type) }
|
||||
.map<PlanRecord, PlanUiState>(PlanUiState::Success)
|
||||
.onStart { emit(PlanUiState.Loading) }
|
||||
.catch { emit(PlanUiState.Error(it.message ?: "加载失败,请稍后再试")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = PlanUiState.Loading,
|
||||
)
|
||||
|
||||
fun selectType(type: PlanType) {
|
||||
_selectedType.value = type
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user