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,33 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.mingyuan.android_client.core.designsystem.theme.AmberGradient
import com.mingyuan.android_client.core.designsystem.theme.Divider
@Composable
fun AmberProgressBar(
progress: Float,
modifier: Modifier = Modifier,
) {
val fraction = progress.coerceIn(0f, 1f)
Box(
modifier = modifier
.fillMaxWidth()
.height(8.dp)
.background(Divider, RoundedCornerShape(4.dp)),
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.height(8.dp)
.background(AmberGradient, RoundedCornerShape(4.dp)),
)
}
}

View File

@@ -0,0 +1,37 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
@Composable
fun Avatar(
initial: String,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
backgroundColor: Color = BrandAmber,
textColor: Color = Color.White,
) {
Box(
modifier = modifier.size(size).background(backgroundColor, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = initial.take(1),
color = textColor,
fontSize = (size.value * 0.42f).sp,
fontWeight = FontWeight.Bold,
)
}
}

View File

@@ -0,0 +1,36 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
@Composable
fun LabeledRow(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Text(label, color = MutedText, fontSize = 13.sp)
Spacer(Modifier.width(16.dp))
Text(
text = value,
color = PrimaryText,
fontSize = 14.sp,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
)
}
}

View File

@@ -0,0 +1,74 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 com.mingyuan.android_client.core.designsystem.theme.AmberGradient
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.Disabled
enum class ButtonVariant { PRIMARY, DANGER, OUTLINE }
@Composable
fun MingyuanButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.PRIMARY,
enabled: Boolean = true,
fullWidth: Boolean = false,
) {
val shape = RoundedCornerShape(50)
val backgroundModifier = when {
!enabled -> Modifier.background(Disabled, shape)
variant == ButtonVariant.PRIMARY -> Modifier.background(AmberGradient, shape)
else -> Modifier.background(Color.White, shape)
}
val borderModifier = when {
!enabled -> Modifier
variant == ButtonVariant.DANGER -> Modifier.border(1.dp, Danger, shape)
variant == ButtonVariant.OUTLINE -> Modifier.border(1.dp, BrandAmber, shape)
else -> Modifier
}
val textColor = when {
!enabled -> Color.White
variant == ButtonVariant.PRIMARY -> Color.White
variant == ButtonVariant.DANGER -> Danger
else -> BrandAmber
}
Box(
modifier = modifier
.then(if (fullWidth) Modifier.fillMaxWidth() else Modifier)
.clip(shape)
.then(backgroundModifier)
.then(borderModifier)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = textColor,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
}
}

View File

@@ -0,0 +1,35 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.mingyuan.android_client.core.designsystem.theme.SoftShadow
@Composable
fun SectionCard(
modifier: Modifier = Modifier,
cornerRadius: Int = 16,
backgroundColor: Color = Color.White,
contentPadding: Int = 16,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(cornerRadius.dp),
ambientColor = SoftShadow,
spotColor = SoftShadow,
)
.background(backgroundColor, RoundedCornerShape(cornerRadius.dp))
.padding(contentPadding.dp),
content = content,
)
}

View File

@@ -0,0 +1,54 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.designsystem.theme.SegmentedTrack
@Composable
fun SegmentedControl(
labels: List<String>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.background(SegmentedTrack, RoundedCornerShape(50))
.padding(4.dp),
) {
labels.forEachIndexed { index, label ->
val selected = index == selectedIndex
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(50))
.background(if (selected) Color.White else Color.Transparent)
.clickable { onSelect(index) }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = if (selected) BrandAmber else SecondaryText,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}

View File

@@ -0,0 +1,39 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import com.mingyuan.android_client.core.designsystem.theme.SkeletonColor
@Composable
fun SkeletonBox(
modifier: Modifier = Modifier,
cornerRadius: Int = 12,
) {
val transition = rememberInfiniteTransition(label = "skeleton")
val alpha by transition.animateFloat(
initialValue = 0.4f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "skeleton-alpha",
)
Box(
modifier = modifier
.graphicsLayer { this.alpha = alpha }
.background(SkeletonColor, RoundedCornerShape(cornerRadius.dp)),
)
}

View File

@@ -0,0 +1,67 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.ErrorBackground
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
@Composable
fun EmptyState(
emoji: String,
message: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.size(72.dp).background(NeutralChip, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(emoji, fontSize = 32.sp)
}
Spacer(Modifier.height(12.dp))
Text(message, color = FaintText, fontSize = 14.sp)
}
}
@Composable
fun ErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.size(72.dp).background(ErrorBackground, CircleShape),
contentAlignment = Alignment.Center,
) {
Text("!", color = Danger, fontSize = 32.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(12.dp))
Text(message, color = MutedText, fontSize = 14.sp)
Spacer(Modifier.height(16.dp))
MingyuanButton(text = "重新加载", onClick = onRetry)
}
}

View File

@@ -2,10 +2,48 @@ package com.mingyuan.android_client.core.designsystem.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
// Brand palette — ported from the WeChat mini-program design tokens.
val PageBackground = Color(0xFFFDFBF7)
val PlanBackground = Color(0xFFF6F2EC)
val Purple40 = Color(0xFF6650A4)
val PurpleGrey40 = Color(0xFF625B71)
val Pink40 = Color(0xFF7D5260)
val PrimaryText = Color(0xFF2C1810)
val SecondaryText = Color(0xFF5A4A3A)
val MutedText = Color(0xFF8B7A6A)
val MutedLightText = Color(0xFFA99A89)
val FaintText = Color(0xFFB6A996)
val BrandAmber = Color(0xFFC67B30)
val LightAmber = Color(0xFFE3B37C)
val AmberMid = Color(0xFFE4A05E)
val AmberBright = Color(0xFFEFA45D)
val HeroGradientStart = Color(0xFF2C1810)
val HeroGradientMid = Color(0xFF4A2B1D)
val HeroGradientEnd = Color(0xFF6B3F24)
val StatusNormal = Color(0xFF2E7D32)
val StatusLate = Color(0xFFC67B30)
val StatusLeave = Color(0xFF3A7BD5)
val StatusEarlyLeave = Color(0xFF8B7A6A)
val StatusAbsent = Color(0xFFC0392B)
val Danger = Color(0xFFC0392B)
val ErrorBackground = Color(0xFFFDEAEA)
val SuccessBackground = Color(0xFFE8F4EB)
val WarningBackground = Color(0xFFFFF3E6)
val WarningBorder = Color(0xFFF7D9B9)
val NeutralChip = Color(0xFFF6EFE6)
val SegmentedTrack = Color(0xFFEFE7DC)
val Divider = Color(0xFFF2EDE7)
val PillIdleBorder = Color(0xFFD8C7B4)
val TextareaBorder = Color(0xFFE4D9CB)
val Disabled = Color(0xFFD8C7B4)
val TabBackground = Color(0xFFFFFFFF)
val TabInactive = Color(0xFF5A4A3A)
val TabActive = Color(0xFFC67B30)
val SoftShadow = Color(0x0D2C1810)
val SkeletonColor = Color(0xFFEFE6D9)
val SkeletonHighlight = Color(0xFFFAF4EC)

View File

@@ -0,0 +1,16 @@
package com.mingyuan.android_client.core.designsystem.theme
import androidx.compose.ui.graphics.Brush
// Brand gradients (135deg, top-left → bottom-right, matching the mini-program).
val HeroGradient = Brush.linearGradient(
colors = listOf(HeroGradientStart, HeroGradientMid, HeroGradientEnd),
)
val AmberGradient = Brush.linearGradient(
colors = listOf(BrandAmber, AmberMid),
)
val AvatarGradient = Brush.linearGradient(
colors = listOf(BrandAmber, LightAmber),
)

View File

@@ -1,49 +1,59 @@
package com.mingyuan.android_client.core.designsystem.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
primary = BrandAmber,
onPrimary = Color.White,
primaryContainer = LightAmber,
onPrimaryContainer = PrimaryText,
secondary = AmberMid,
onSecondary = Color.White,
tertiary = StatusLeave,
background = PageBackground,
onBackground = PrimaryText,
surface = Color.White,
onSurface = PrimaryText,
surfaceVariant = NeutralChip,
onSurfaceVariant = MutedText,
error = Danger,
onError = Color.White,
outline = Divider,
outlineVariant = PillIdleBorder,
)
private val DarkColorScheme = darkColorScheme(
primary = LightAmber,
onPrimary = PrimaryText,
primaryContainer = HeroGradientMid,
onPrimaryContainer = LightAmber,
background = HeroGradientStart,
onBackground = Color(0xFFFDFBF7),
surface = Color(0xFF2A1E18),
onSurface = Color(0xFFFDFBF7),
surfaceVariant = Color(0xFF3A2A20),
onSurfaceVariant = MutedLightText,
error = Color(0xFFE5736C),
)
/**
* The app's single Material theme. All UI entry points wrap their content in [MingyuanTheme].
*
* Dynamic color is deliberately disabled so the brand palette always renders as designed,
* regardless of the device's wallpaper-based Material You scheme.
*/
@Composable
fun MingyuanTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+.
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = Typography,
content = content,
)