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