feat: 复刻小程序四大主页面与底部导航(UI 优先,本地 mock)
This commit is contained in:
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.checkin"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,301 @@
|
||||
package com.mingyuan.android_client.feature.checkin
|
||||
|
||||
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.shape.RoundedCornerShape
|
||||
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.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.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.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.FaintText
|
||||
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.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.StatusNormal
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SuccessBackground
|
||||
import com.mingyuan.android_client.core.model.CheckinRecord
|
||||
import com.mingyuan.android_client.core.model.CheckinStatus
|
||||
import com.mingyuan.android_client.core.model.GroupMember
|
||||
import com.mingyuan.android_client.core.model.PersonalCheckinRecord
|
||||
import com.mingyuan.android_client.core.ui.status.StatusBadge
|
||||
import com.mingyuan.android_client.core.ui.status.StatusPill
|
||||
|
||||
/** Navigation route for this feature, referenced by the app-level NavHost. */
|
||||
const val CHECKIN_ROUTE = "checkin"
|
||||
|
||||
@Composable
|
||||
fun CheckinScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Check-in")
|
||||
fun CheckinScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CheckinViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selectedStatus by viewModel.selectedStatus.collectAsStateWithLifecycle()
|
||||
val submitting by viewModel.submitting.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = uiState) {
|
||||
is CheckinUiState.Loading -> CheckinSkeleton(modifier)
|
||||
is CheckinUiState.Error -> ErrorState(
|
||||
message = state.message,
|
||||
onRetry = {},
|
||||
modifier = modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
is CheckinUiState.Success -> CheckinContent(
|
||||
record = state.record,
|
||||
selectedStatus = selectedStatus,
|
||||
submitting = submitting,
|
||||
onSubmit = viewModel::submit,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckinContent(
|
||||
record: CheckinRecord,
|
||||
selectedStatus: CheckinStatus,
|
||||
submitting: Boolean,
|
||||
onSubmit: (CheckinStatus) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var tab by remember { mutableStateOf(CheckinTab.GROUP) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item { StatusTitleCard(record) }
|
||||
item { StatusSelector(selectedStatus, submitting, onSubmit) }
|
||||
item { RecordPanel(record, tab) { tab = it } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusTitleCard(record: CheckinRecord) {
|
||||
SectionCard(cornerRadius = 20) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(62.dp)
|
||||
.background(PrimaryText, RoundedCornerShape(14.dp))
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(record.weekday, color = Color(0xFFE8DCCB), fontSize = 12.sp)
|
||||
Text(
|
||||
text = record.dayNum.toString(),
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (record.checked) "已打卡" else "未打卡",
|
||||
color = if (record.checked) StatusNormal else BrandAmber,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val status = record.status
|
||||
if (record.checked && status != null) {
|
||||
StatusBadge(status)
|
||||
} else {
|
||||
Text("请选择状态完成打卡", color = MutedText, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusSelector(
|
||||
selectedStatus: CheckinStatus,
|
||||
submitting: Boolean,
|
||||
onSubmit: (CheckinStatus) -> Unit,
|
||||
) {
|
||||
SectionCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
CheckinStatus.entries.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
row.forEach { status ->
|
||||
StatusPill(
|
||||
status = status,
|
||||
selected = status == selectedStatus,
|
||||
onClick = { onSubmit(status) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
repeat(3 - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
if (submitting) {
|
||||
Text("提交中…", color = MutedText, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CheckinTab { GROUP, PERSONAL }
|
||||
|
||||
@Composable
|
||||
private fun RecordPanel(
|
||||
record: CheckinRecord,
|
||||
tab: CheckinTab,
|
||||
onTabChange: (CheckinTab) -> Unit,
|
||||
) {
|
||||
SectionCard {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
TabItem("小组", tab == CheckinTab.GROUP) { onTabChange(CheckinTab.GROUP) }
|
||||
TabItem("个人", tab == CheckinTab.PERSONAL) { onTabChange(CheckinTab.PERSONAL) }
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
when (tab) {
|
||||
CheckinTab.GROUP -> GroupList(record)
|
||||
CheckinTab.PERSONAL -> PersonalList(record.personalHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Column(Modifier.clickable(onClick = onClick)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (selected) BrandAmber else SecondaryText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.width(40.dp)
|
||||
.height(2.dp)
|
||||
.background(if (selected) BrandAmber else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupList(record: CheckinRecord) {
|
||||
val checkedCount = record.groupMembers.count { it.checkedIn }
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${record.groupName} · ${record.classDate}",
|
||||
color = PrimaryText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
text = "$checkedCount/${record.groupMembers.size} 已打卡",
|
||||
color = MutedText,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
record.groupMembers.forEach { member ->
|
||||
GroupMemberRow(member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupMemberRow(member: GroupMember) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
initial = member.name,
|
||||
size = 30.dp,
|
||||
backgroundColor = if (member.checkedIn) SuccessBackground else NeutralChip,
|
||||
textColor = if (member.checkedIn) StatusNormal else BrandAmber,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(member.name, color = PrimaryText, fontSize = 14.sp, modifier = Modifier.weight(1f))
|
||||
val status = member.status
|
||||
if (member.checkedIn && status != null) {
|
||||
Text("打卡 ${member.checkinTime}", color = MutedText, fontSize = 12.sp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
StatusBadge(status)
|
||||
} else {
|
||||
Text("未打卡", color = FaintText, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonalList(history: List<PersonalCheckinRecord>) {
|
||||
if (history.isEmpty()) {
|
||||
EmptyState("📒", "暂无个人打卡记录")
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
history.forEach { item ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = item.date,
|
||||
color = PrimaryText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusBadge(item.status)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(item.time, color = MutedText, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckinSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(120.dp), cornerRadius = 20)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(140.dp), cornerRadius = 16)
|
||||
repeat(4) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
package com.mingyuan.android_client.feature.checkin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
|
||||
import com.mingyuan.android_client.core.model.CheckinRecord
|
||||
import com.mingyuan.android_client.core.model.CheckinStatus
|
||||
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 CheckinUiState {
|
||||
data object Loading : CheckinUiState
|
||||
data class Success(val record: CheckinRecord) : CheckinUiState
|
||||
data class Error(val message: String) : CheckinUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class CheckinViewModel @Inject constructor() : ViewModel()
|
||||
class CheckinViewModel @Inject constructor(
|
||||
private val checkinRepository: CheckinRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _selectedStatus = MutableStateFlow(CheckinStatus.NORMAL)
|
||||
val selectedStatus: StateFlow<CheckinStatus> = _selectedStatus.asStateFlow()
|
||||
|
||||
private val _submitting = MutableStateFlow(false)
|
||||
val submitting: StateFlow<Boolean> = _submitting.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<CheckinUiState> = checkinRepository.observeCheckin()
|
||||
.map<CheckinRecord, CheckinUiState>(CheckinUiState::Success)
|
||||
.onStart { emit(CheckinUiState.Loading) }
|
||||
.catch { emit(CheckinUiState.Error(it.message ?: "加载失败,请稍后再试")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = CheckinUiState.Loading,
|
||||
)
|
||||
|
||||
fun submit(status: CheckinStatus) {
|
||||
_selectedStatus.value = status
|
||||
viewModelScope.launch {
|
||||
_submitting.value = true
|
||||
checkinRepository.submitCheckin(status)
|
||||
_submitting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
feature/home/build.gradle.kts
Normal file
11
feature/home/build.gradle.kts
Normal file
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
alias(libs.plugins.mcl.android.feature)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.home"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
2
feature/home/src/main/AndroidManifest.xml
Normal file
2
feature/home/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.mingyuan.android_client.feature.home
|
||||
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.mingyuan.android_client.core.designsystem.component.ErrorState
|
||||
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.HeroGradient
|
||||
import com.mingyuan.android_client.core.designsystem.theme.LightAmber
|
||||
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.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.model.HomeData
|
||||
import com.mingyuan.android_client.core.model.HomeModule
|
||||
import com.mingyuan.android_client.core.model.IntroCard
|
||||
import com.mingyuan.android_client.core.model.ModuleDestination
|
||||
|
||||
/** Navigation route for this feature, referenced by the app-level NavHost. */
|
||||
const val HOME_ROUTE = "home"
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onNavigateToCheckin: () -> Unit,
|
||||
onNavigateToPlan: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = uiState) {
|
||||
is HomeUiState.Loading -> HomeSkeleton(modifier)
|
||||
is HomeUiState.Error -> ErrorState(
|
||||
message = state.message,
|
||||
onRetry = {},
|
||||
modifier = modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
is HomeUiState.Success -> HomeContent(
|
||||
data = state.data,
|
||||
onModuleClick = { module ->
|
||||
when (module.destination) {
|
||||
ModuleDestination.CHECKIN -> onNavigateToCheckin()
|
||||
ModuleDestination.PLAN -> onNavigateToPlan()
|
||||
ModuleDestination.ABOUT -> Unit
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(140.dp), cornerRadius = 24)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(3) {
|
||||
SkeletonBox(Modifier.weight(1f).height(104.dp), cornerRadius = 22)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeContent(
|
||||
data: HomeData,
|
||||
onModuleClick: (HomeModule) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item { HeroCard(data) }
|
||||
item { ModuleGrid(data.modules, onModuleClick) }
|
||||
item { IntroSection(data.intros) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroCard(data: HomeData) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(
|
||||
elevation = 6.dp,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
spotColor = Color(0x2E2C1810),
|
||||
ambientColor = Color(0x2E2C1810),
|
||||
)
|
||||
.background(HeroGradient, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color(0x2EC67B30), RoundedCornerShape(50))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(data.greeting, color = LightAmber, fontSize = 11.sp)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = data.displayName,
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(data.encouragement, color = Color(0xFFE8DCCB), fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleGrid(
|
||||
modules: List<HomeModule>,
|
||||
onModuleClick: (HomeModule) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
modules.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
row.forEach { module ->
|
||||
ModuleItem(module, onModuleClick, Modifier.weight(1f))
|
||||
}
|
||||
repeat(3 - row.size) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleItem(
|
||||
module: HomeModule,
|
||||
onModuleClick: (HomeModule) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.shadow(
|
||||
elevation = 3.dp,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
spotColor = Color(0x0F2C1810),
|
||||
ambientColor = Color(0x0F2C1810),
|
||||
)
|
||||
.background(Color.White, RoundedCornerShape(22.dp))
|
||||
.clickable { onModuleClick(module) }
|
||||
.padding(vertical = 16.dp, horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(44.dp).background(NeutralChip, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(module.emoji, fontSize = 20.sp)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(module.title, color = PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(module.description, color = MutedText, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntroSection(intros: List<IntroCard>) {
|
||||
Column {
|
||||
Text("产品介绍", color = PrimaryText, fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
intros.forEach { intro ->
|
||||
IntroItem(intro)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntroItem(intro: IntroCard) {
|
||||
SectionCard {
|
||||
Text(intro.title, color = BrandAmber, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(intro.body, color = SecondaryText, fontSize = 13.sp, lineHeight = 22.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mingyuan.android_client.feature.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.HomeRepository
|
||||
import com.mingyuan.android_client.core.model.HomeData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface HomeUiState {
|
||||
data object Loading : HomeUiState
|
||||
data class Success(val data: HomeData) : HomeUiState
|
||||
data class Error(val message: String) : HomeUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
homeRepository: HomeRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<HomeUiState> = homeRepository.observeHome()
|
||||
.map<HomeData, HomeUiState>(HomeUiState::Success)
|
||||
.onStart { emit(HomeUiState.Loading) }
|
||||
.catch { emit(HomeUiState.Error(it.message ?: "加载失败,请稍后再试")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = HomeUiState.Loading,
|
||||
)
|
||||
}
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.plan"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.profile"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,216 @@
|
||||
package com.mingyuan.android_client.feature.profile
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.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.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.Avatar
|
||||
import com.mingyuan.android_client.core.designsystem.component.ButtonVariant
|
||||
import com.mingyuan.android_client.core.designsystem.component.ErrorState
|
||||
import com.mingyuan.android_client.core.designsystem.component.LabeledRow
|
||||
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.Danger
|
||||
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.PageBackground
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
|
||||
import com.mingyuan.android_client.core.model.ClassGroup
|
||||
import com.mingyuan.android_client.core.model.UserProfile
|
||||
|
||||
/** Navigation route for this feature, referenced by the app-level NavHost. */
|
||||
const val PROFILE_ROUTE = "profile"
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Profile")
|
||||
fun ProfileScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ProfileViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
when (val state = uiState) {
|
||||
is ProfileUiState.Loading -> ProfileSkeleton(modifier)
|
||||
is ProfileUiState.Error -> ErrorState(
|
||||
message = state.message,
|
||||
onRetry = {},
|
||||
modifier = modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
is ProfileUiState.Success -> ProfileContent(
|
||||
profile = state.profile,
|
||||
onDelete = { showDeleteDialog = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("注销账号") },
|
||||
text = { Text("确定要注销账号吗?此操作不可恢复。") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteAccount()
|
||||
},
|
||||
) {
|
||||
Text("确定", color = Danger)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) { Text("取消") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileContent(
|
||||
profile: UserProfile,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item { ProfileHeaderCard(profile) }
|
||||
item { InfoCard(profile) }
|
||||
if (profile.classGroups.isNotEmpty()) {
|
||||
item { ClassGroupSection(profile.classGroups) }
|
||||
}
|
||||
item {
|
||||
MingyuanButton(
|
||||
text = "注销账号",
|
||||
onClick = onDelete,
|
||||
variant = ButtonVariant.DANGER,
|
||||
fullWidth = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileHeaderCard(profile: UserProfile) {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box {
|
||||
Avatar(initial = profile.displayName, size = 56.dp)
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(20.dp)
|
||||
.background(BrandAmber, CircleShape)
|
||||
.border(2.dp, PageBackground, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("📷", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = profile.displayName,
|
||||
color = PrimaryText,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.background(NeutralChip, RoundedCornerShape(50))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = profile.role,
|
||||
color = BrandAmber,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(profile.studentId, color = MutedText, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(profile: UserProfile) {
|
||||
SectionCard {
|
||||
Text("个人信息", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LabeledRow("姓名", profile.name)
|
||||
LabeledRow("学号", profile.studentId)
|
||||
LabeledRow("手机号", profile.phone ?: "未绑定")
|
||||
LabeledRow("角色", profile.role)
|
||||
LabeledRow("小组", profile.group ?: "未分组")
|
||||
LabeledRow("登录方式", profile.loginType)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClassGroupSection(classGroups: List<ClassGroup>) {
|
||||
SectionCard {
|
||||
Text("小组分组", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
classGroups.forEach { group ->
|
||||
LabeledRow(group.period, "${group.group} · ${group.role}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SkeletonBox(Modifier.size(56.dp), cornerRadius = 28)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
SkeletonBox(Modifier.width(120.dp).height(20.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SkeletonBox(Modifier.width(80.dp).height(12.dp))
|
||||
}
|
||||
}
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(200.dp), cornerRadius = 16)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,43 @@
|
||||
package com.mingyuan.android_client.feature.profile
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
|
||||
import com.mingyuan.android_client.core.model.UserProfile
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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 ProfileUiState {
|
||||
data object Loading : ProfileUiState
|
||||
data class Success(val profile: UserProfile) : ProfileUiState
|
||||
data class Error(val message: String) : ProfileUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ProfileViewModel @Inject constructor() : ViewModel()
|
||||
class ProfileViewModel @Inject constructor(
|
||||
private val profileRepository: ProfileRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<ProfileUiState> = profileRepository.observeProfile()
|
||||
.map<UserProfile, ProfileUiState>(ProfileUiState::Success)
|
||||
.onStart { emit(ProfileUiState.Loading) }
|
||||
.catch { emit(ProfileUiState.Error(it.message ?: "加载失败,请稍后再试")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = ProfileUiState.Loading,
|
||||
)
|
||||
|
||||
fun deleteAccount() {
|
||||
viewModelScope.launch {
|
||||
profileRepository.deleteAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user