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