Compare commits

...

6 Commits

Author SHA1 Message Date
AndyChen1024
e033c18aca feat: 「计划」未登录自动跳转「我的」 2026-09-09 22:20:01 +08:00
AndyChen1024
84c4bf64b3 feat: 「打卡」未登录自动跳转「我的」 2026-09-09 22:19:47 +08:00
AndyChen1024
0b8bcfe800 feat: 「首页」支持游客模式 2026-09-09 22:19:12 +08:00
AndyChen1024
03410d7ea2 feat: 「我的」页退出登录/登录接入会话状态 2026-09-09 22:16:25 +08:00
AndyChen1024
c41c04ff23 feat: 引入共享会话状态与未登录占位组件 2026-09-09 22:15:46 +08:00
AndyChen1024
c80fb31eb1 feat: 复刻小程序四大主页面与底部导航(UI 优先,本地 mock) 2026-09-09 19:57:47 +08:00
55 changed files with 2985 additions and 68 deletions

3
.gitignore vendored
View File

@@ -30,3 +30,6 @@ out/
# Agent skills(安装产生的陈旧副本,规范源见 .agents/skills + skills-lock.json) # Agent skills(安装产生的陈旧副本,规范源见 .agents/skills + skills-lock.json)
agent/ agent/
# 临时文件(截图、日志等本地草稿)
tmp/

View File

@@ -15,9 +15,11 @@ android {
dependencies { dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:ui")) implementation(project(":core:ui"))
implementation(project(":core:model"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":feature:auth")) implementation(project(":feature:auth"))
implementation(project(":feature:home"))
implementation(project(":feature:checkin")) implementation(project(":feature:checkin"))
implementation(project(":feature:plan")) implementation(project(":feature:plan"))
implementation(project(":feature:course")) implementation(project(":feature:course"))
@@ -25,6 +27,7 @@ dependencies {
implementation(project(":feature:feedback")) implementation(project(":feature:feedback"))
implementation(project(":feature:profile")) implementation(project(":feature:profile"))
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)

View File

@@ -1,41 +1,132 @@
package com.mingyuan.android_client package com.mingyuan.android_client
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.mingyuan.android_client.feature.auth.AUTH_ROUTE import androidx.navigation.navArgument
import com.mingyuan.android_client.feature.auth.AuthScreen import com.mingyuan.android_client.core.designsystem.theme.TabActive
import com.mingyuan.android_client.core.designsystem.theme.TabBackground
import com.mingyuan.android_client.core.designsystem.theme.TabInactive
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
import com.mingyuan.android_client.feature.checkin.CheckinScreen import com.mingyuan.android_client.feature.checkin.CheckinScreen
import com.mingyuan.android_client.feature.course.COURSE_ROUTE import com.mingyuan.android_client.feature.home.HOME_ROUTE
import com.mingyuan.android_client.feature.course.CourseScreen import com.mingyuan.android_client.feature.home.HomeScreen
import com.mingyuan.android_client.feature.feedback.FEEDBACK_ROUTE
import com.mingyuan.android_client.feature.feedback.FeedbackScreen
import com.mingyuan.android_client.feature.plan.PLAN_ROUTE import com.mingyuan.android_client.feature.plan.PLAN_ROUTE
import com.mingyuan.android_client.feature.plan.PLAN_SUBMIT_ROUTE
import com.mingyuan.android_client.feature.plan.PlanScreen import com.mingyuan.android_client.feature.plan.PlanScreen
import com.mingyuan.android_client.feature.plan.PlanSubmitScreen
import com.mingyuan.android_client.feature.plan.planSubmitRoute
import com.mingyuan.android_client.feature.profile.PROFILE_ROUTE import com.mingyuan.android_client.feature.profile.PROFILE_ROUTE
import com.mingyuan.android_client.feature.profile.ProfileScreen import com.mingyuan.android_client.feature.profile.ProfileScreen
import com.mingyuan.android_client.feature.survey.SURVEY_ROUTE
import com.mingyuan.android_client.feature.survey.SurveyScreen
/** /**
* The single navigation graph for the app. Each feature exposes its own route constant and * The single navigation graph for the app. Each feature exposes its own route constant and screen
* screen composable; this host wires them together (see docs/architecture.md). * composable; this host wires them together (see docs/architecture.md).
*/ */
@Composable @Composable
fun AppNavHost() { fun AppNavHost() {
val navController = rememberNavController() val navController = rememberNavController()
NavHost( val backStackEntry by navController.currentBackStackEntryAsState()
navController = navController, val currentRoute = backStackEntry?.destination?.route
startDestination = CHECKIN_ROUTE, val topLevelRoutes = TopLevelDestination.entries.map { it.route }.toSet()
) {
composable(AUTH_ROUTE) { AuthScreen() } val navigateToTab: (String) -> Unit = { route ->
composable(CHECKIN_ROUTE) { CheckinScreen() } navController.navigate(route) {
composable(PLAN_ROUTE) { PlanScreen() } popUpTo(navController.graph.findStartDestination().id) {
composable(COURSE_ROUTE) { CourseScreen() } saveState = true
composable(SURVEY_ROUTE) { SurveyScreen() } }
composable(FEEDBACK_ROUTE) { FeedbackScreen() } launchSingleTop = true
composable(PROFILE_ROUTE) { ProfileScreen() } restoreState = true
}
}
Scaffold(
containerColor = TabBackground,
bottomBar = {
if (currentRoute in topLevelRoutes) {
MingyuanBottomBar(
currentRoute = currentRoute,
onNavigate = navigateToTab,
)
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = HOME_ROUTE,
modifier = Modifier.padding(innerPadding),
) {
composable(HOME_ROUTE) {
HomeScreen(
onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) },
onNavigateToPlan = { navigateToTab(PLAN_ROUTE) },
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) },
)
}
composable(CHECKIN_ROUTE) {
CheckinScreen(onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) })
}
composable(PLAN_ROUTE) {
PlanScreen(
onNavigateToPlanSubmit = { type, periodKey ->
navController.navigate(planSubmitRoute(type, periodKey))
},
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) },
)
}
composable(
route = PLAN_SUBMIT_ROUTE,
arguments = listOf(
navArgument("planType") { type = NavType.StringType },
navArgument("periodKey") {
type = NavType.StringType
defaultValue = ""
},
),
) {
PlanSubmitScreen(onBack = { navController.popBackStack() })
}
composable(PROFILE_ROUTE) {
ProfileScreen(onLoggedOut = { navigateToTab(HOME_ROUTE) })
}
}
}
}
@Composable
private fun MingyuanBottomBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
) {
NavigationBar(containerColor = TabBackground) {
TopLevelDestination.entries.forEach { destination ->
NavigationBarItem(
selected = currentRoute == destination.route,
onClick = { onNavigate(destination.route) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = TabActive,
selectedTextColor = TabActive,
unselectedIconColor = TabInactive,
unselectedTextColor = TabInactive,
indicatorColor = Color.Transparent,
),
)
}
} }
} }

View File

@@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import com.mingyuan.android_client.core.designsystem.theme.MingyuanTheme import com.mingyuan.android_client.core.designsystem.theme.MingyuanTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View File

@@ -0,0 +1,24 @@
package com.mingyuan.android_client
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.ui.graphics.vector.ImageVector
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
import com.mingyuan.android_client.feature.home.HOME_ROUTE
import com.mingyuan.android_client.feature.plan.PLAN_ROUTE
import com.mingyuan.android_client.feature.profile.PROFILE_ROUTE
/** The four top-level tabs shown in the bottom navigation bar. */
enum class TopLevelDestination(
val route: String,
val label: String,
val icon: ImageVector,
) {
HOME(HOME_ROUTE, "首页", Icons.Filled.Home),
CHECKIN(CHECKIN_ROUTE, "打卡", Icons.Filled.CheckCircle),
PLAN(PLAN_ROUTE, "计划", Icons.Filled.DateRange),
PROFILE(PROFILE_ROUTE, "我的", Icons.Filled.Person),
}

View File

@@ -25,8 +25,8 @@ class RootPlugin : Plugin<Project> {
":app" to setOf( ":app" to setOf(
":core:common", ":core:model", ":core:domain", ":core:designsystem", ":core:common", ":core:model", ":core:domain", ":core:designsystem",
":core:ui", ":core:data", ":core:network", ":core:database", ":core:datastore", ":core:ui", ":core:data", ":core:network", ":core:database", ":core:datastore",
":feature:auth", ":feature:checkin", ":feature:plan", ":feature:course", ":feature:auth", ":feature:home", ":feature:checkin", ":feature:plan",
":feature:survey", ":feature:feedback", ":feature:profile", ":feature:course", ":feature:survey", ":feature:feedback", ":feature:profile",
), ),
":core:common" to emptySet(), ":core:common" to emptySet(),
":core:model" to emptySet(), ":core:model" to emptySet(),
@@ -40,6 +40,7 @@ class RootPlugin : Plugin<Project> {
":core:domain", ":core:model", ":core:network", ":core:database", ":core:datastore", ":core:domain", ":core:model", ":core:network", ":core:database", ":core:datastore",
), ),
":feature:auth" to featureAllowed, ":feature:auth" to featureAllowed,
":feature:home" to featureAllowed,
":feature:checkin" to featureAllowed, ":feature:checkin" to featureAllowed,
":feature:plan" to featureAllowed, ":feature:plan" to featureAllowed,
":feature:course" to featureAllowed, ":feature:course" to featureAllowed,

View File

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

View File

@@ -0,0 +1,42 @@
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.data.repository.FakeSessionRepository
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 com.mingyuan.android_client.core.domain.repository.SessionRepository
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
@Binds
@Singleton
abstract fun bindSessionRepository(impl: FakeSessionRepository): SessionRepository
}

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,66 @@
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())
}
override fun observeGuestHome(): Flow<HomeData> = flow {
delay(250)
emit(seedGuestHome())
}
private fun seedHome() = HomeData(
greeting = "晚上好",
displayName = "张三",
encouragement = "坚持每周打卡,与小组共同进步。",
modules = modules(),
intros = intros(),
)
private fun seedGuestHome() = HomeData(
greeting = "欢迎来到明缘云道场",
displayName = "游客",
encouragement = "登录后即可打卡、提交计划",
modules = modules(),
intros = intros(),
)
private fun modules() = listOf(
HomeModule("checkin", "📋", "打卡介绍", "每日打卡记录", ModuleDestination.CHECKIN),
HomeModule("plan", "📝", "计划介绍", "周/月/年计划", ModuleDestination.PLAN),
HomeModule("about", "ℹ️", "关于我们", "了解明缘云道场", ModuleDestination.ABOUT),
)
private fun 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,38 @@
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)
}
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,25 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakeSessionRepository @Inject constructor() : SessionRepository {
private val _isLoggedIn = MutableStateFlow(true)
override val isLoggedIn: Flow<Boolean> = _isLoggedIn
override suspend fun login() {
delay(300)
_isLoggedIn.value = true
}
override suspend fun logout() {
delay(300)
_isLoggedIn.value = false
}
}

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,97 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.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
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
@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)
}
}
@Composable
fun LoggedOutState(
onLogin: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.size(72.dp).background(NeutralChip, CircleShape),
contentAlignment = Alignment.Center,
) {
Text("🔒", fontSize = 32.sp)
}
Spacer(Modifier.height(12.dp))
Text("未登录", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
Text("登录后即可查看", color = FaintText, fontSize = 13.sp)
if (onLogin != null) {
Spacer(Modifier.height(16.dp))
MingyuanButton(text = "登录", onClick = onLogin)
}
}
}

View File

@@ -2,10 +2,48 @@ package com.mingyuan.android_client.core.designsystem.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) // Brand palette — ported from the WeChat mini-program design tokens.
val PurpleGrey80 = Color(0xFFCCC2DC) val PageBackground = Color(0xFFFDFBF7)
val Pink80 = Color(0xFFEFB8C8) val PlanBackground = Color(0xFFF6F2EC)
val Purple40 = Color(0xFF6650A4) val PrimaryText = Color(0xFF2C1810)
val PurpleGrey40 = Color(0xFF625B71) val SecondaryText = Color(0xFF5A4A3A)
val Pink40 = Color(0xFF7D5260) 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 package com.mingyuan.android_client.core.designsystem.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = Purple40, primary = BrandAmber,
secondary = PurpleGrey40, onPrimary = Color.White,
tertiary = Pink40, 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]. * 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 @Composable
fun MingyuanTheme( fun MingyuanTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+.
dynamicColor: Boolean = true,
content: @Composable () -> Unit, 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( MaterialTheme(
colorScheme = colorScheme, colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = Typography, typography = Typography,
content = content, content = content,
) )

View File

@@ -3,6 +3,7 @@ plugins {
} }
dependencies { dependencies {
implementation(project(":core:model"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.truth) 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,10 @@
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>
fun observeGuestHome(): 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,8 @@
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>
}

View File

@@ -0,0 +1,11 @@
package com.mingyuan.android_client.core.domain.repository
import kotlinx.coroutines.flow.Flow
interface SessionRepository {
val isLoggedIn: Flow<Boolean>
suspend fun login()
suspend fun logout()
}

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

View File

@@ -18,7 +18,7 @@
| `core:datastore` | android library | DataStore 偏好存储 | | `core:datastore` | android library | DataStore 偏好存储 |
| `core:designsystem` | android library + compose | 主题 / 颜色 / 字体 / 通用组件 | | `core:designsystem` | android library + compose | 主题 / 颜色 / 字体 / 通用组件 |
| `core:ui` | android library + compose + hilt | 共享 Compose UI 与导航辅助 | | `core:ui` | android library + compose + hilt | 共享 Compose UI 与导航辅助 |
| `feature:*` | feature | 各业务功能(auth/checkin/plan/course/survey/feedback/profile) | | `feature:*` | feature | 各业务功能(auth/home/checkin/plan/course/survey/feedback/profile) |
## 分层与依赖方向 ## 分层与依赖方向

View File

@@ -5,3 +5,7 @@ plugins {
android { android {
namespace = "com.mingyuan.android_client.feature.checkin" namespace = "com.mingyuan.android_client.feature.checkin"
} }
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -1,18 +1,311 @@
package com.mingyuan.android_client.feature.checkin 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.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.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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier 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.LoggedOutState
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. */ /** Navigation route for this feature, referenced by the app-level NavHost. */
const val CHECKIN_ROUTE = "checkin" const val CHECKIN_ROUTE = "checkin"
@Composable @Composable
fun CheckinScreen(modifier: Modifier = Modifier) { fun CheckinScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onNavigateToProfile: () -> Unit,
Text(text = "Check-in") modifier: Modifier = Modifier,
viewModel: CheckinViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedStatus by viewModel.selectedStatus.collectAsStateWithLifecycle()
val submitting by viewModel.submitting.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState is CheckinUiState.LoggedOut) {
onNavigateToProfile()
}
}
when (val state = uiState) {
is CheckinUiState.Loading -> CheckinSkeleton(modifier)
is CheckinUiState.LoggedOut -> LoggedOutState(modifier = 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,67 @@
package com.mingyuan.android_client.feature.checkin package com.mingyuan.android_client.feature.checkin
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.model.CheckinRecord
import com.mingyuan.android_client.core.model.CheckinStatus
import dagger.hilt.android.lifecycle.HiltViewModel 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.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
sealed interface CheckinUiState {
data object Loading : CheckinUiState
data class Success(val record: CheckinRecord) : CheckinUiState
data object LoggedOut : CheckinUiState
data class Error(val message: String) : CheckinUiState
}
@HiltViewModel @HiltViewModel
class CheckinViewModel @Inject constructor() : ViewModel() class CheckinViewModel @Inject constructor(
private val checkinRepository: CheckinRepository,
private val sessionRepository: SessionRepository,
) : 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> = sessionRepository.isLoggedIn
.flatMapLatest { loggedIn ->
if (loggedIn) {
checkinRepository.observeCheckin()
.map<CheckinRecord, CheckinUiState>(CheckinUiState::Success)
.onStart { emit(CheckinUiState.Loading) }
} else {
flowOf(CheckinUiState.LoggedOut)
}
}
.catch { emit(CheckinUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = CheckinUiState.Loading,
)
fun submit(status: CheckinStatus) {
_selectedStatus.value = status
viewModelScope.launch {
_submitting.value = true
checkinRepository.submitCheckin(status)
_submitting.value = false
}
}
}

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,248 @@
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.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.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,
onNavigateToProfile: () -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showLoginDialog by remember { mutableStateOf(false) }
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 ->
if (state.isGuest) {
showLoginDialog = true
} else {
when (module.destination) {
ModuleDestination.CHECKIN -> onNavigateToCheckin()
ModuleDestination.PLAN -> onNavigateToPlan()
ModuleDestination.ABOUT -> Unit
}
}
},
modifier = modifier,
)
}
if (showLoginDialog) {
AlertDialog(
onDismissRequest = { showLoginDialog = false },
title = { Text("登录提示") },
text = { Text("登录后使用此功能,是否前往登录?") },
confirmButton = {
TextButton(
onClick = {
showLoginDialog = false
onNavigateToProfile()
},
) {
Text("去登录")
}
},
dismissButton = {
TextButton(onClick = { showLoginDialog = false }) { Text("取消") }
},
)
}
}
@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,47 @@
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.domain.repository.SessionRepository
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.flatMapLatest
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, val isGuest: Boolean) : HomeUiState
data class Error(val message: String) : HomeUiState
}
@HiltViewModel
class HomeViewModel @Inject constructor(
homeRepository: HomeRepository,
sessionRepository: SessionRepository,
) : ViewModel() {
val uiState: StateFlow<HomeUiState> = sessionRepository.isLoggedIn
.flatMapLatest { loggedIn ->
val source = if (loggedIn) {
homeRepository.observeHome()
} else {
homeRepository.observeGuestHome()
}
source
.map<HomeData, HomeUiState> { HomeUiState.Success(it, isGuest = !loggedIn) }
.onStart { emit(HomeUiState.Loading) }
}
.catch { emit(HomeUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = HomeUiState.Loading,
)
}

View File

@@ -5,3 +5,7 @@ plugins {
android { android {
namespace = "com.mingyuan.android_client.feature.plan" namespace = "com.mingyuan.android_client.feature.plan"
} }
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -1,18 +1,396 @@
package com.mingyuan.android_client.feature.plan package com.mingyuan.android_client.feature.plan
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.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.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.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
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.AmberProgressBar
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.LoggedOutState
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
import com.mingyuan.android_client.core.designsystem.component.SectionCard
import com.mingyuan.android_client.core.designsystem.component.SegmentedControl
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.Danger
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.MutedLightText
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.PlanBackground
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.designsystem.theme.WarningBackground
import com.mingyuan.android_client.core.model.MyPlanRecord
import com.mingyuan.android_client.core.model.PlanGroup
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanType
/** Navigation route for this feature, referenced by the app-level NavHost. */ /** Navigation route for this feature, referenced by the app-level NavHost. */
const val PLAN_ROUTE = "plan" const val PLAN_ROUTE = "plan"
@Composable @Composable
fun PlanScreen(modifier: Modifier = Modifier) { fun PlanScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onNavigateToPlanSubmit: (PlanType, String) -> Unit,
Text(text = "Plan") onNavigateToProfile: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlanViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedType by viewModel.selectedType.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState is PlanUiState.LoggedOut) {
onNavigateToProfile()
}
}
if (uiState is PlanUiState.LoggedOut) {
LoggedOutState(modifier = modifier.fillMaxSize().background(PlanBackground))
return
}
Column(modifier = modifier.fillMaxSize().background(PlanBackground)) {
Box(Modifier.fillMaxWidth().padding(16.dp)) {
SegmentedControl(
labels = PlanType.entries.map { it.label },
selectedIndex = PlanType.entries.indexOf(selectedType),
onSelect = { viewModel.selectType(PlanType.entries[it]) },
)
}
when (val state = uiState) {
is PlanUiState.Loading -> PlanSkeleton()
is PlanUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = Modifier.padding(16.dp),
)
is PlanUiState.Success -> PlanContent(
record = state.record,
selectedType = selectedType,
onNavigateToPlanSubmit = onNavigateToPlanSubmit,
)
is PlanUiState.LoggedOut -> Unit // handled above
}
}
}
@Composable
private fun PlanContent(
record: PlanRecord,
selectedType: PlanType,
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
) {
var selectedGroup by remember { mutableStateOf<PlanGroup?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { PlanHead(record, selectedType) }
item { MyPlanStatusCard(record, selectedType, onNavigateToPlanSubmit) }
val myRecord = record.myRecord
if (record.myChecked && myRecord != null) {
item { UploadedPlanCard(myRecord) }
}
val message = record.message
if (message != null) {
item { WindowMessage(message) }
}
if (record.groups.isEmpty()) {
item { EmptyState("📭", "暂无小组") }
} else {
item {
Text("小组打卡", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
}
items(record.groups, key = { it.id }) { group ->
GroupCard(group) { selectedGroup = group }
}
}
if (record.unassignedCount > 0) {
item {
Text(
text = "另有未分组 ${record.unassignedCount} 人",
color = FaintText,
fontSize = 12.sp,
)
}
}
}
selectedGroup?.let { group ->
GroupDetailDialog(group, onDismiss = { selectedGroup = null })
}
}
@Composable
private fun PlanHead(record: PlanRecord, selectedType: PlanType) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(selectedType.label, color = PrimaryText, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(record.periodKey, color = MutedLightText, fontSize = 12.sp)
}
Box(
Modifier
.background(
if (record.myChecked) SuccessBackground else WarningBackground,
RoundedCornerShape(50),
)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = if (record.myChecked) "已提交" else "待提交",
color = if (record.myChecked) StatusNormal else BrandAmber,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun MyPlanStatusCard(
record: PlanRecord,
selectedType: PlanType,
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
) {
SectionCard(cornerRadius = 16) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(
if (record.myChecked) {
Brush.linearGradient(listOf(StatusNormal, Color(0xFF43A047)))
} else {
Brush.linearGradient(listOf(Danger, Color(0xFFE5736C)))
},
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = if (record.myChecked) "✓" else "●",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = if (record.myChecked) "已完成" else "未完成",
color = if (record.myChecked) StatusNormal else Danger,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
)
Text("我的${selectedType.label}", color = MutedText, fontSize = 12.sp)
}
MingyuanButton(
text = if (record.myChecked) "修改" else "新增打卡",
onClick = { onNavigateToPlanSubmit(selectedType, record.periodKey) },
enabled = record.canSubmit,
)
}
}
}
@Composable
private fun UploadedPlanCard(myRecord: MyPlanRecord) {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "我上传的计划",
color = PrimaryText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Box(
Modifier
.background(WarningBackground, RoundedCornerShape(50))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text("未完成", color = BrandAmber, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
}
}
Spacer(Modifier.height(8.dp))
Text("${myRecord.submittedTime} 提交", color = MutedText, fontSize = 12.sp)
Spacer(Modifier.height(8.dp))
Text(myRecord.content, color = SecondaryText, fontSize = 13.sp, lineHeight = 20.sp)
if (myRecord.attachments.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(myRecord.attachments.size) {
ThumbnailPlaceholder()
}
}
}
}
}
@Composable
private fun ThumbnailPlaceholder() {
Box(
Modifier.size(64.dp).background(NeutralChip, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text("🖼️", fontSize = 20.sp)
}
}
@Composable
private fun WindowMessage(message: String) {
Row(
Modifier
.fillMaxWidth()
.background(WarningBackground, RoundedCornerShape(10.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("⏰", fontSize = 14.sp)
Spacer(Modifier.width(8.dp))
Text(message, color = Danger, fontSize = 12.sp)
}
}
@Composable
private fun GroupCard(group: PlanGroup, onClick: () -> Unit) {
val progress = if (group.memberCount > 0) {
group.checkedCount.toFloat() / group.memberCount
} else {
0f
}
SectionCard(cornerRadius = 14) {
Row(
modifier = Modifier.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(40.dp)
.background(
Brush.linearGradient(listOf(Color(0xFFF3E3CF), Color(0xFFFBE7D1))),
RoundedCornerShape(12.dp),
),
contentAlignment = Alignment.Center,
) {
Text(group.name.take(1), color = BrandAmber, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(group.name, color = PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
AmberProgressBar(progress)
}
Spacer(Modifier.width(12.dp))
Text(
text = "已打卡 ${group.checkedCount}",
color = StatusNormal,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(6.dp))
Text(
text = "${group.memberCount}人",
color = BrandAmber,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(4.dp))
Text("›", color = FaintText, fontSize = 18.sp)
}
}
}
@Composable
private fun GroupDetailDialog(group: PlanGroup, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text("关闭") }
},
title = { Text(group.name, fontSize = 16.sp, fontWeight = FontWeight.Bold) },
text = {
Column {
group.members.forEach { member ->
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
initial = member.name,
size = 32.dp,
backgroundColor = if (member.filled) SuccessBackground else NeutralChip,
textColor = if (member.filled) StatusNormal else BrandAmber,
)
Spacer(Modifier.width(12.dp))
Text(
text = member.name,
color = PrimaryText,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
)
Text(
text = if (member.filled) "已完成" else "未完成",
color = if (member.filled) StatusNormal else Danger,
fontSize = 12.sp,
)
}
}
}
},
)
}
@Composable
private fun PlanSkeleton() {
Column(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SkeletonBox(Modifier.fillMaxWidth().height(40.dp), cornerRadius = 12)
SkeletonBox(Modifier.fillMaxWidth().height(80.dp), cornerRadius = 16)
repeat(3) { SkeletonBox(Modifier.fillMaxWidth().height(72.dp), cornerRadius = 14) }
} }
} }

View File

@@ -0,0 +1,257 @@
package com.mingyuan.android_client.feature.plan
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
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.EmptyState
import com.mingyuan.android_client.core.designsystem.component.ErrorState
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
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.Disabled
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.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.TextareaBorder
import com.mingyuan.android_client.core.model.MyPlanRecord
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val PLAN_SUBMIT_ROUTE = "plan_submit?planType={planType}&periodKey={periodKey}"
fun planSubmitRoute(planType: PlanType, periodKey: String): String =
"plan_submit?planType=${planType.name}&periodKey=${Uri.encode(periodKey)}"
@Composable
fun PlanSubmitScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlanSubmitViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val saving by viewModel.saving.collectAsStateWithLifecycle()
val saved by viewModel.saved.collectAsStateWithLifecycle()
var content by remember { mutableStateOf("") }
LaunchedEffect(uiState) {
val state = uiState
if (state is PlanSubmitUiState.Success && content.isEmpty() && state.data.content.isNotEmpty()) {
content = state.data.content
}
}
LaunchedEffect(saved) {
if (saved) {
onBack()
}
}
Column(modifier = modifier.fillMaxSize().background(PageBackground)) {
SubmitHeader(viewModel.planType, viewModel.periodKey, onBack)
when (val state = uiState) {
is PlanSubmitUiState.Loading -> SubmitSkeleton()
is PlanSubmitUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = Modifier.padding(16.dp),
)
is PlanSubmitUiState.Success -> SubmitContent(
data = state.data,
content = content,
onContentChange = { content = it },
saving = saving,
onSave = { viewModel.save(content) },
)
}
}
}
@Composable
private fun SubmitHeader(planType: PlanType, periodKey: String, onBack: () -> Unit) {
Row(
Modifier.fillMaxWidth().background(Color.White).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "‹",
color = PrimaryText,
fontSize = 24.sp,
modifier = Modifier.clickable(onClick = onBack).padding(end = 8.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = "${planType.label} · $periodKey",
color = PrimaryText,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
}
}
@Composable
private fun SubmitContent(
data: PlanSubmitData,
content: String,
onContentChange: (String) -> Unit,
saving: Boolean,
onSave: () -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val message = data.message
if (!data.canSubmit && message != null) {
item { Text(message, color = MutedText, fontSize = 12.sp) }
}
item {
PlanTextarea(
content = content,
onContentChange = onContentChange,
enabled = data.canSubmit,
)
}
item { UploadButton(attachmentsCount = data.attachments.size, enabled = data.canSubmit) }
item {
MingyuanButton(
text = when {
!data.canSubmit -> "不可提交"
data.checked -> "保存修改"
else -> "保存计划"
},
onClick = onSave,
enabled = data.canSubmit && content.isNotBlank() && !saving,
fullWidth = true,
)
}
item { HistorySection(data.history) }
}
}
@Composable
private fun PlanTextarea(
content: String,
onContentChange: (String) -> Unit,
enabled: Boolean,
) {
Column {
Box(
Modifier
.fillMaxWidth()
.heightIn(min = 120.dp)
.border(1.dp, TextareaBorder, RoundedCornerShape(12.dp))
.background(if (enabled) PageBackground else Color(0xFFF6F1EA), RoundedCornerShape(12.dp))
.padding(12.dp),
) {
if (content.isEmpty()) {
Text("写下计划内容", color = FaintText, fontSize = 14.sp)
}
BasicTextField(
value = content,
onValueChange = { if (it.length <= 1000) onContentChange(it) },
enabled = enabled,
textStyle = TextStyle(color = PrimaryText, fontSize = 14.sp),
cursorBrush = SolidColor(BrandAmber),
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(4.dp))
Text(
text = "${content.length}/1000",
color = FaintText,
fontSize = 12.sp,
modifier = Modifier.align(Alignment.End),
)
}
}
@Composable
private fun UploadButton(attachmentsCount: Int, enabled: Boolean) {
Box(
Modifier
.fillMaxWidth()
.border(2.dp, if (enabled) BrandAmber else Disabled, RoundedCornerShape(12.dp))
.background(PageBackground, RoundedCornerShape(12.dp))
.padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "+ 添加图片($attachmentsCount/4)",
color = if (enabled) BrandAmber else Disabled,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun HistorySection(history: List<MyPlanRecord>) {
Column {
Text("历史计划", color = PrimaryText, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
if (history.isEmpty()) {
EmptyState("📭", "暂无历史计划")
} else {
history.forEach { item ->
SectionCard {
Text("${item.submittedTime} · ${item.status}", color = BrandAmber, fontSize = 12.sp)
Spacer(Modifier.height(4.dp))
Text(item.content, color = SecondaryText, fontSize = 13.sp)
}
}
}
}
}
@Composable
private fun SubmitSkeleton() {
Column(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SkeletonBox(Modifier.fillMaxWidth().height(120.dp), cornerRadius = 12)
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 12)
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
}
}

View File

@@ -0,0 +1,63 @@
package com.mingyuan.android_client.feature.plan
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
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 PlanSubmitUiState {
data object Loading : PlanSubmitUiState
data class Success(val data: PlanSubmitData) : PlanSubmitUiState
data class Error(val message: String) : PlanSubmitUiState
}
@HiltViewModel
class PlanSubmitViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val planRepository: PlanRepository,
) : ViewModel() {
val planType: PlanType = savedStateHandle.get<String>("planType")
?.let { runCatching { PlanType.valueOf(it) }.getOrNull() }
?: PlanType.WEEKLY
val periodKey: String = savedStateHandle.get<String>("periodKey").orEmpty()
private val _saving = MutableStateFlow(false)
val saving: StateFlow<Boolean> = _saving.asStateFlow()
private val _saved = MutableStateFlow(false)
val saved: StateFlow<Boolean> = _saved.asStateFlow()
val uiState: StateFlow<PlanSubmitUiState> = planRepository.observePlanSubmit(planType, periodKey)
.map<PlanSubmitData, PlanSubmitUiState>(PlanSubmitUiState::Success)
.onStart { emit(PlanSubmitUiState.Loading) }
.catch { emit(PlanSubmitUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PlanSubmitUiState.Loading,
)
fun save(content: String) {
viewModelScope.launch {
_saving.value = true
planRepository.savePlan(planType, periodKey, content, emptyList())
_saving.value = false
_saved.value = true
}
}
}

View File

@@ -1,8 +1,60 @@
package com.mingyuan.android_client.feature.plan package com.mingyuan.android_client.feature.plan
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanType
import dagger.hilt.android.lifecycle.HiltViewModel 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.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
sealed interface PlanUiState {
data object Loading : PlanUiState
data class Success(val record: PlanRecord) : PlanUiState
data object LoggedOut : PlanUiState
data class Error(val message: String) : PlanUiState
}
@HiltViewModel @HiltViewModel
class PlanViewModel @Inject constructor() : ViewModel() class PlanViewModel @Inject constructor(
private val planRepository: PlanRepository,
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _selectedType = MutableStateFlow(PlanType.WEEKLY)
val selectedType: StateFlow<PlanType> = _selectedType.asStateFlow()
val uiState: StateFlow<PlanUiState> =
combine(sessionRepository.isLoggedIn, _selectedType) { loggedIn, type -> loggedIn to type }
.flatMapLatest { (loggedIn, type) ->
if (loggedIn) {
planRepository.observePlan(type)
.map<PlanRecord, PlanUiState>(PlanUiState::Success)
.onStart { emit(PlanUiState.Loading) }
} else {
flowOf(PlanUiState.LoggedOut)
}
}
.catch { emit(PlanUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = PlanUiState.Loading,
)
fun selectType(type: PlanType) {
_selectedType.value = type
}
}

View File

@@ -5,3 +5,7 @@ plugins {
android { android {
namespace = "com.mingyuan.android_client.feature.profile" namespace = "com.mingyuan.android_client.feature.profile"
} }
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -1,18 +1,224 @@
package com.mingyuan.android_client.feature.profile package com.mingyuan.android_client.feature.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier 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 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.ButtonVariant
import com.mingyuan.android_client.core.designsystem.component.ErrorState
import com.mingyuan.android_client.core.designsystem.component.LabeledRow
import com.mingyuan.android_client.core.designsystem.component.LoggedOutState
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
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.Danger
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.model.ClassGroup
import com.mingyuan.android_client.core.model.UserProfile
/** Navigation route for this feature, referenced by the app-level NavHost. */ /** Navigation route for this feature, referenced by the app-level NavHost. */
const val PROFILE_ROUTE = "profile" const val PROFILE_ROUTE = "profile"
@Composable @Composable
fun ProfileScreen(modifier: Modifier = Modifier) { fun ProfileScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onLoggedOut: () -> Unit,
Text(text = "Profile") modifier: Modifier = Modifier,
viewModel: ProfileViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showLogoutDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.logoutEvents.collect { onLoggedOut() }
}
when (val state = uiState) {
is ProfileUiState.Loading -> ProfileSkeleton(modifier)
is ProfileUiState.LoggedOut -> LoggedOutState(onLogin = viewModel::login, modifier = modifier)
is ProfileUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = modifier.padding(16.dp),
)
is ProfileUiState.Success -> ProfileContent(
profile = state.profile,
onLogout = { showLogoutDialog = true },
modifier = modifier,
)
}
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("退出登录") },
text = { Text("确定要退出登录吗?退出后需要重新登录。") },
confirmButton = {
TextButton(
onClick = {
showLogoutDialog = false
viewModel.logout()
},
) {
Text("确定", color = Danger)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) { Text("取消") }
},
)
}
}
@Composable
private fun ProfileContent(
profile: UserProfile,
onLogout: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize().background(PageBackground),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { ProfileHeaderCard(profile) }
item { InfoCard(profile) }
if (profile.classGroups.isNotEmpty()) {
item { ClassGroupSection(profile.classGroups) }
}
item {
MingyuanButton(
text = "退出登录",
onClick = onLogout,
variant = ButtonVariant.DANGER,
fullWidth = true,
)
}
}
}
@Composable
private fun ProfileHeaderCard(profile: UserProfile) {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
Avatar(initial = profile.displayName, size = 56.dp)
Box(
Modifier
.align(Alignment.BottomEnd)
.size(20.dp)
.background(BrandAmber, CircleShape)
.border(2.dp, PageBackground, CircleShape),
contentAlignment = Alignment.Center,
) {
Text("📷", fontSize = 10.sp)
}
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = profile.displayName,
color = PrimaryText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(8.dp))
Box(
Modifier
.background(NeutralChip, RoundedCornerShape(50))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = profile.role,
color = BrandAmber,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Spacer(Modifier.height(4.dp))
Text(profile.studentId, color = MutedText, fontSize = 12.sp)
}
}
}
}
@Composable
private fun InfoCard(profile: UserProfile) {
SectionCard {
Text("个人信息", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
LabeledRow("姓名", profile.name)
LabeledRow("学号", profile.studentId)
LabeledRow("手机号", profile.phone ?: "未绑定")
LabeledRow("角色", profile.role)
LabeledRow("小组", profile.group ?: "未分组")
LabeledRow("登录方式", profile.loginType)
}
}
@Composable
private fun ClassGroupSection(classGroups: List<ClassGroup>) {
SectionCard {
Text("小组分组", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
classGroups.forEach { group ->
LabeledRow(group.period, "${group.group} · ${group.role}")
}
}
}
@Composable
private fun ProfileSkeleton(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
SkeletonBox(Modifier.size(56.dp), cornerRadius = 28)
Spacer(Modifier.width(16.dp))
Column {
SkeletonBox(Modifier.width(120.dp).height(20.dp))
Spacer(Modifier.height(8.dp))
SkeletonBox(Modifier.width(80.dp).height(12.dp))
}
}
SkeletonBox(Modifier.fillMaxWidth().height(200.dp), cornerRadius = 16)
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
} }
} }

View File

@@ -1,8 +1,67 @@
package com.mingyuan.android_client.feature.profile package com.mingyuan.android_client.feature.profile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.model.UserProfile
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Success(val profile: UserProfile) : ProfileUiState
data object LoggedOut : ProfileUiState
data class Error(val message: String) : ProfileUiState
}
@HiltViewModel @HiltViewModel
class ProfileViewModel @Inject constructor() : ViewModel() class ProfileViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
private val sessionRepository: SessionRepository,
) : ViewModel() {
val uiState: StateFlow<ProfileUiState> = sessionRepository.isLoggedIn
.flatMapLatest { loggedIn ->
if (loggedIn) {
profileRepository.observeProfile()
.map<UserProfile, ProfileUiState>(ProfileUiState::Success)
.onStart { emit(ProfileUiState.Loading) }
} else {
flowOf(ProfileUiState.LoggedOut)
}
}
.catch { emit(ProfileUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ProfileUiState.Loading,
)
private val _logoutEvents = MutableSharedFlow<Unit>()
val logoutEvents: SharedFlow<Unit> = _logoutEvents
fun login() {
viewModelScope.launch {
sessionRepository.login()
}
}
fun logout() {
viewModelScope.launch {
sessionRepository.logout()
_logoutEvents.emit(Unit)
}
}
}

View File

@@ -33,6 +33,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
# AndroidX # AndroidX
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }

View File

@@ -36,6 +36,7 @@ include(":core:database")
include(":core:datastore") include(":core:datastore")
include(":core:data") include(":core:data")
include(":feature:auth") include(":feature:auth")
include(":feature:home")
include(":feature:checkin") include(":feature:checkin")
include(":feature:plan") include(":feature:plan")
include(":feature:course") include(":feature:course")