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

@@ -13,4 +13,6 @@ dependencies {
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(project(":core:datastore"))
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -0,0 +1,36 @@
package com.mingyuan.android_client.core.data.di
import com.mingyuan.android_client.core.data.repository.FakeCheckinRepository
import com.mingyuan.android_client.core.data.repository.FakeHomeRepository
import com.mingyuan.android_client.core.data.repository.FakePlanRepository
import com.mingyuan.android_client.core.data.repository.FakeProfileRepository
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
import com.mingyuan.android_client.core.domain.repository.HomeRepository
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindHomeRepository(impl: FakeHomeRepository): HomeRepository
@Binds
@Singleton
abstract fun bindCheckinRepository(impl: FakeCheckinRepository): CheckinRepository
@Binds
@Singleton
abstract fun bindPlanRepository(impl: FakePlanRepository): PlanRepository
@Binds
@Singleton
abstract fun bindProfileRepository(impl: FakeProfileRepository): ProfileRepository
}

View File

@@ -0,0 +1,63 @@
package com.mingyuan.android_client.core.data.repository
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 com.mingyuan.android_client.core.model.GroupMember
import com.mingyuan.android_client.core.model.PersonalCheckinRecord
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakeCheckinRepository @Inject constructor() : CheckinRepository {
private val record = MutableStateFlow(seedCheckin())
override fun observeCheckin(): Flow<CheckinRecord> = flow {
delay(250)
emitAll(record)
}
override suspend fun submitCheckin(status: CheckinStatus) {
delay(300)
record.update { current ->
current.copy(
checked = true,
status = status,
groupMembers = current.groupMembers.map { member ->
if (member.id == "me") {
member.copy(checkedIn = true, checkinTime = "现在", status = status)
} else {
member
}
},
)
}
}
private fun seedCheckin() = CheckinRecord(
classDate = "2026-09-09",
weekday = "周三",
dayNum = 9,
checked = false,
status = null,
groupName = "进阶一组",
groupMembers = listOf(
GroupMember("m1", "张三", true, "19:02", CheckinStatus.NORMAL),
GroupMember("m2", "李四", true, "19:30", CheckinStatus.LATE),
GroupMember("me", "我", false, null, null),
GroupMember("m3", "王五", true, "09:10", CheckinStatus.LEAVE),
GroupMember("m4", "赵六", false, null, null),
),
personalHistory = listOf(
PersonalCheckinRecord("2026-09-06", CheckinStatus.NORMAL, "19:05"),
PersonalCheckinRecord("2026-09-02", CheckinStatus.LATE, "19:32"),
),
)
}

View File

@@ -0,0 +1,49 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.HomeRepository
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakeHomeRepository @Inject constructor() : HomeRepository {
override fun observeHome(): Flow<HomeData> = flow {
delay(250)
emit(seedHome())
}
private fun seedHome() = HomeData(
greeting = "晚上好",
displayName = "张三",
encouragement = "坚持每周打卡,与小组共同进步。",
modules = listOf(
HomeModule("checkin", "📋", "打卡介绍", "每日打卡记录", ModuleDestination.CHECKIN),
HomeModule("plan", "📝", "计划介绍", "周/月/年计划", ModuleDestination.PLAN),
HomeModule("about", "ℹ️", "关于我们", "了解明缘云道场", ModuleDestination.ABOUT),
),
intros = listOf(
IntroCard(
"📚 学习成长工具",
"明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯,实现持续进步。好好学习,天天向上。",
),
IntroCard(
"✅ 周打卡",
"每周打卡,支持多种状态记录(正常、迟到、请假、早退、不参加讨论等)。查看小组打卡情况,与同学们互相监督、共同进步。",
),
IntroCard(
"📝 计划管理",
"提供周计划、月计划、年计划三级管理,支持文字与图片上传。清晰追踪每个周期的目标完成情况,让成长有迹可循。",
),
IntroCard(
"👥 小组共学",
"加入学习小组,查看组员的打卡与计划完成情况。在集体的氛围中保持学习动力,与志同道合的伙伴一起成长。",
),
),
)
}

View File

@@ -0,0 +1,96 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.model.MyPlanRecord
import com.mingyuan.android_client.core.model.PlanGroup
import com.mingyuan.android_client.core.model.PlanMember
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakePlanRepository @Inject constructor() : PlanRepository {
private val plans = PlanType.entries.associateWith { type -> MutableStateFlow(seedPlan(type)) }
private val submits = mutableMapOf<String, MutableStateFlow<PlanSubmitData>>()
override fun observePlan(type: PlanType): Flow<PlanRecord> = flow {
delay(250)
emitAll(plans.getValue(type))
}
override fun observePlanSubmit(type: PlanType, periodKey: String): Flow<PlanSubmitData> = flow {
delay(250)
emitAll(submitState(type, periodKey))
}
override suspend fun savePlan(
type: PlanType,
periodKey: String,
content: String,
attachments: List<String>,
) {
delay(300)
val submit = submitState(type, periodKey)
submit.value = submit.value.copy(content = content, attachments = attachments, checked = true)
val plan = plans.getValue(type)
plan.value = plan.value.copy(
myChecked = true,
myRecord = MyPlanRecord("已完成", "现在", content, attachments),
)
}
private fun submitState(type: PlanType, periodKey: String): MutableStateFlow<PlanSubmitData> =
submits.getOrPut("${type.name}:$periodKey") { MutableStateFlow(seedSubmit(type)) }
private fun seedPlan(type: PlanType) = PlanRecord(
type = type,
periodKey = periodKeyFor(type),
canSubmit = true,
message = if (type == PlanType.WEEKLY) "本周计划提交截止:周日晚 23:59" else null,
myChecked = type == PlanType.WEEKLY,
myRecord = if (type == PlanType.WEEKLY) {
MyPlanRecord("已完成", "今天 18:30", "本周完成两章学习,整理笔记并复习错题。", listOf("图1", "图2"))
} else {
null
},
groups = listOf(
PlanGroup("g1", "进阶一组", 4, 6, memberSeed()),
PlanGroup("g2", "进阶二组", 5, 5, memberSeed()),
PlanGroup("g3", "进阶三组", 2, 7, memberSeed()),
),
unassignedCount = 2,
)
private fun seedSubmit(type: PlanType) = PlanSubmitData(
content = if (type == PlanType.WEEKLY) "本周完成两章学习,整理笔记并复习错题。" else "",
attachments = if (type == PlanType.WEEKLY) listOf("图1", "图2") else emptyList(),
checked = type == PlanType.WEEKLY,
canSubmit = true,
message = null,
history = if (type == PlanType.WEEKLY) {
emptyList()
} else {
listOf(MyPlanRecord("已完成", "2026-08 完成", "上月计划内容……", emptyList()))
},
)
private fun periodKeyFor(type: PlanType): String = when (type) {
PlanType.WEEKLY -> "2026 第 37 周"
PlanType.MONTHLY -> "2026 年 9 月"
PlanType.YEARLY -> "2026 年"
}
private fun memberSeed() = listOf(
PlanMember("p1", "张三", true),
PlanMember("p2", "李四", true),
PlanMember("p3", "王五", false),
)
}

View File

@@ -0,0 +1,51 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import com.mingyuan.android_client.core.model.ClassGroup
import com.mingyuan.android_client.core.model.UserProfile
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakeProfileRepository @Inject constructor() : ProfileRepository {
private val profile = MutableStateFlow(seedProfile())
override fun observeProfile(): Flow<UserProfile> = flow {
delay(250)
emitAll(profile)
}
override suspend fun deleteAccount() {
delay(300)
profile.value = seedProfile().copy(
displayName = "游客",
name = "",
studentId = "",
phone = null,
group = null,
avatarUrl = null,
classGroups = emptyList(),
)
}
private fun seedProfile() = UserProfile(
displayName = "张三",
name = "张三",
studentId = "20260001",
phone = "138****1234",
role = "成员",
group = "进阶一组",
loginType = "手机号",
avatarUrl = null,
classGroups = listOf(
ClassGroup("2026 秋季", "进阶一组", "成员"),
ClassGroup("2026 春季", "进阶二组", "组长"),
),
)
}

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

View File

@@ -3,6 +3,7 @@ plugins {
}
dependencies {
implementation(project(":core:model"))
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.truth)

View File

@@ -0,0 +1,11 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.CheckinRecord
import com.mingyuan.android_client.core.model.CheckinStatus
import kotlinx.coroutines.flow.Flow
interface CheckinRepository {
fun observeCheckin(): Flow<CheckinRecord>
suspend fun submitCheckin(status: CheckinStatus)
}

View File

@@ -0,0 +1,8 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.HomeData
import kotlinx.coroutines.flow.Flow
interface HomeRepository {
fun observeHome(): Flow<HomeData>
}

View File

@@ -0,0 +1,19 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
import kotlinx.coroutines.flow.Flow
interface PlanRepository {
fun observePlan(type: PlanType): Flow<PlanRecord>
fun observePlanSubmit(type: PlanType, periodKey: String): Flow<PlanSubmitData>
suspend fun savePlan(
type: PlanType,
periodKey: String,
content: String,
attachments: List<String>,
)
}

View File

@@ -0,0 +1,10 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.UserProfile
import kotlinx.coroutines.flow.Flow
interface ProfileRepository {
fun observeProfile(): Flow<UserProfile>
suspend fun deleteAccount()
}

View File

@@ -0,0 +1,34 @@
package com.mingyuan.android_client.core.model
enum class CheckinStatus(val label: String) {
NORMAL("正常"),
LATE("迟到"),
LEAVE("请假"),
ABSENT("不参加讨论"),
EARLY_LEAVE("早退"),
}
data class GroupMember(
val id: String,
val name: String,
val checkedIn: Boolean,
val checkinTime: String?,
val status: CheckinStatus?,
)
data class PersonalCheckinRecord(
val date: String,
val status: CheckinStatus,
val time: String,
)
data class CheckinRecord(
val classDate: String,
val weekday: String,
val dayNum: Int,
val checked: Boolean,
val status: CheckinStatus?,
val groupName: String,
val groupMembers: List<GroupMember>,
val personalHistory: List<PersonalCheckinRecord>,
)

View File

@@ -0,0 +1,28 @@
package com.mingyuan.android_client.core.model
enum class ModuleDestination {
CHECKIN,
PLAN,
ABOUT,
}
data class HomeModule(
val id: String,
val emoji: String,
val title: String,
val description: String,
val destination: ModuleDestination,
)
data class IntroCard(
val title: String,
val body: String,
)
data class HomeData(
val greeting: String,
val displayName: String,
val encouragement: String,
val modules: List<HomeModule>,
val intros: List<IntroCard>,
)

View File

@@ -0,0 +1,48 @@
package com.mingyuan.android_client.core.model
enum class PlanType(val label: String) {
WEEKLY("周计划"),
MONTHLY("月计划"),
YEARLY("年计划"),
}
data class MyPlanRecord(
val status: String,
val submittedTime: String,
val content: String,
val attachments: List<String>,
)
data class PlanMember(
val id: String,
val name: String,
val filled: Boolean,
)
data class PlanGroup(
val id: String,
val name: String,
val checkedCount: Int,
val memberCount: Int,
val members: List<PlanMember>,
)
data class PlanRecord(
val type: PlanType,
val periodKey: String,
val canSubmit: Boolean,
val message: String?,
val myChecked: Boolean,
val myRecord: MyPlanRecord?,
val groups: List<PlanGroup>,
val unassignedCount: Int,
)
data class PlanSubmitData(
val content: String,
val attachments: List<String>,
val checked: Boolean,
val canSubmit: Boolean,
val message: String?,
val history: List<MyPlanRecord>,
)

View File

@@ -0,0 +1,19 @@
package com.mingyuan.android_client.core.model
data class ClassGroup(
val period: String,
val group: String,
val role: String,
)
data class UserProfile(
val displayName: String,
val name: String,
val studentId: String,
val phone: String?,
val role: String,
val group: String?,
val loginType: String,
val avatarUrl: String?,
val classGroups: List<ClassGroup>,
)

View File

@@ -0,0 +1,37 @@
package com.mingyuan.android_client.core.ui.status
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.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.model.CheckinStatus
@Composable
fun StatusBadge(
status: CheckinStatus,
modifier: Modifier = Modifier,
) {
val color = status.statusColor()
Row(
modifier = modifier
.background(color.copy(alpha = 0.12f), RoundedCornerShape(50))
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(6.dp).background(color, CircleShape))
Spacer(Modifier.width(4.dp))
Text(status.label, color = color, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
}
}

View File

@@ -0,0 +1,17 @@
package com.mingyuan.android_client.core.ui.status
import androidx.compose.ui.graphics.Color
import com.mingyuan.android_client.core.designsystem.theme.StatusAbsent
import com.mingyuan.android_client.core.designsystem.theme.StatusEarlyLeave
import com.mingyuan.android_client.core.designsystem.theme.StatusLate
import com.mingyuan.android_client.core.designsystem.theme.StatusLeave
import com.mingyuan.android_client.core.designsystem.theme.StatusNormal
import com.mingyuan.android_client.core.model.CheckinStatus
fun CheckinStatus.statusColor(): Color = when (this) {
CheckinStatus.NORMAL -> StatusNormal
CheckinStatus.LATE -> StatusLate
CheckinStatus.LEAVE -> StatusLeave
CheckinStatus.ABSENT -> StatusAbsent
CheckinStatus.EARLY_LEAVE -> StatusEarlyLeave
}

View File

@@ -0,0 +1,60 @@
package com.mingyuan.android_client.core.ui.status
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.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 com.mingyuan.android_client.core.designsystem.theme.PillIdleBorder
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.model.CheckinStatus
@Composable
fun StatusPill(
status: CheckinStatus,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val color = status.statusColor()
val shape = RoundedCornerShape(50)
val background = if (selected) color else Color.White
val textColor = if (selected) Color.White else SecondaryText
val borderColor = if (selected) color else PillIdleBorder
val dotColor = if (selected) Color.White else color
Row(
modifier = modifier
.shadow(
elevation = if (selected) 6.dp else 0.dp,
shape = shape,
spotColor = color.copy(alpha = 0.22f),
ambientColor = color.copy(alpha = 0.22f),
)
.background(background, shape)
.border(1.dp, borderColor, shape)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(8.dp).background(dotColor, CircleShape))
Spacer(Modifier.width(6.dp))
Text(status.label, color = textColor, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
}