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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user