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

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