feat: 复刻小程序四大主页面与底部导航(UI 优先,本地 mock)

This commit is contained in:
AndyChen1024
2026-09-09 19:57:47 +08:00
parent f240682281
commit c80fb31eb1
53 changed files with 2778 additions and 68 deletions

View File

@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.mingyuan.android_client.feature.plan"
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -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) }
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}