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.checkin"
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

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

View File

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

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

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

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

View File

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

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

View File

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

View File

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

View File

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