Compare commits
10 Commits
f240682281
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84980f0fa9 | ||
|
|
b14f883e3e | ||
|
|
762e3975ca | ||
|
|
18f6ddd449 | ||
|
|
e033c18aca | ||
|
|
84c4bf64b3 | ||
|
|
0b8bcfe800 | ||
|
|
03410d7ea2 | ||
|
|
c41c04ff23 | ||
|
|
c80fb31eb1 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,6 +8,7 @@ gradle/gradle-daemon-jvm.properties
|
||||
*.iml
|
||||
.idea/
|
||||
local.properties
|
||||
secrets.properties
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -30,3 +31,6 @@ out/
|
||||
|
||||
# Agent skills(安装产生的陈旧副本,规范源见 .agents/skills + skills-lock.json)
|
||||
agent/
|
||||
|
||||
# 临时文件(截图、日志等本地草稿)
|
||||
tmp/
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -2,6 +2,16 @@
|
||||
|
||||
「明缘进阶场」的 Android 客户端(`com.mingyuan.android_client`),对接 `wechat-backend` 提供的 auth / checkin / plan / course-vote / survey / feedback / profile API。当前处于**基础建设阶段**:多模块骨架已就绪,各 feature 为空占位,尚未接入真实后端。
|
||||
|
||||
## 关联项目
|
||||
|
||||
本仓库是「明缘进阶场」三端之一。分析功能/业务时可跨仓库参考对应实现。
|
||||
|
||||
| 端 | 仓库地址 | 本地路径 |
|
||||
|------|------|------|
|
||||
| Android(本仓库) | https://git.muguaclub.cn/mingyuan/android-client | 当前目录(`android-client`) |
|
||||
| 后端 | https://git.muguaclub.cn/mingyuan/wechat-backend | `../wechat-backend` |
|
||||
| 小程序 | https://git.muguaclub.cn/mingyuan/wechat-clock-mini | `../wechat-clock-mini` |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,9 +15,11 @@ android {
|
||||
dependencies {
|
||||
implementation(project(":core:designsystem"))
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
|
||||
implementation(project(":feature:auth"))
|
||||
implementation(project(":feature:home"))
|
||||
implementation(project(":feature:checkin"))
|
||||
implementation(project(":feature:plan"))
|
||||
implementation(project(":feature:course"))
|
||||
@@ -25,6 +27,7 @@ dependencies {
|
||||
implementation(project(":feature:feedback"))
|
||||
implementation(project(":feature:profile"))
|
||||
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
|
||||
@@ -1,41 +1,140 @@
|
||||
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.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.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
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.auth.AUTH_ROUTE
|
||||
import com.mingyuan.android_client.feature.auth.AuthScreen
|
||||
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
|
||||
import com.mingyuan.android_client.feature.checkin.CheckinScreen
|
||||
import com.mingyuan.android_client.feature.course.COURSE_ROUTE
|
||||
import com.mingyuan.android_client.feature.course.CourseScreen
|
||||
import com.mingyuan.android_client.feature.feedback.FEEDBACK_ROUTE
|
||||
import com.mingyuan.android_client.feature.feedback.FeedbackScreen
|
||||
import com.mingyuan.android_client.feature.home.HOME_ROUTE
|
||||
import com.mingyuan.android_client.feature.home.HomeScreen
|
||||
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.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.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
|
||||
* screen composable; this host wires them together (see docs/architecture.md).
|
||||
* The single navigation graph for the app. Each feature exposes its own route constant and screen
|
||||
* composable; this host wires them together (see docs/architecture.md).
|
||||
*/
|
||||
@Composable
|
||||
fun AppNavHost() {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = CHECKIN_ROUTE,
|
||||
) {
|
||||
composable(AUTH_ROUTE) { AuthScreen() }
|
||||
composable(CHECKIN_ROUTE) { CheckinScreen() }
|
||||
composable(PLAN_ROUTE) { PlanScreen() }
|
||||
composable(COURSE_ROUTE) { CourseScreen() }
|
||||
composable(SURVEY_ROUTE) { SurveyScreen() }
|
||||
composable(FEEDBACK_ROUTE) { FeedbackScreen() }
|
||||
composable(PROFILE_ROUTE) { ProfileScreen() }
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val topLevelRoutes = TopLevelDestination.entries.map { it.route }.toSet()
|
||||
|
||||
val navigateToTab: (String) -> Unit = { route ->
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
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) },
|
||||
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
|
||||
)
|
||||
}
|
||||
composable(CHECKIN_ROUTE) {
|
||||
CheckinScreen(onNavigateToAuth = { navController.navigate(AUTH_ROUTE) })
|
||||
}
|
||||
composable(PLAN_ROUTE) {
|
||||
PlanScreen(
|
||||
onNavigateToPlanSubmit = { type, periodKey ->
|
||||
navController.navigate(planSubmitRoute(type, periodKey))
|
||||
},
|
||||
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = PLAN_SUBMIT_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument("planType") { type = NavType.StringType },
|
||||
navArgument("periodKey") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) {
|
||||
PlanSubmitScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(AUTH_ROUTE) {
|
||||
AuthScreen(onLoggedIn = { navController.popBackStack() })
|
||||
}
|
||||
composable(PROFILE_ROUTE) {
|
||||
ProfileScreen(
|
||||
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.mingyuan.android_client.core.designsystem.theme.MingyuanTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -25,8 +25,8 @@ class RootPlugin : Plugin<Project> {
|
||||
":app" to setOf(
|
||||
":core:common", ":core:model", ":core:domain", ":core:designsystem",
|
||||
":core:ui", ":core:data", ":core:network", ":core:database", ":core:datastore",
|
||||
":feature:auth", ":feature:checkin", ":feature:plan", ":feature:course",
|
||||
":feature:survey", ":feature:feedback", ":feature:profile",
|
||||
":feature:auth", ":feature:home", ":feature:checkin", ":feature:plan",
|
||||
":feature:course", ":feature:survey", ":feature:feedback", ":feature:profile",
|
||||
),
|
||||
":core:common" to emptySet(),
|
||||
":core:model" to emptySet(),
|
||||
@@ -40,6 +40,7 @@ class RootPlugin : Plugin<Project> {
|
||||
":core:domain", ":core:model", ":core:network", ":core:database", ":core:datastore",
|
||||
),
|
||||
":feature:auth" to featureAllowed,
|
||||
":feature:home" to featureAllowed,
|
||||
":feature:checkin" to featureAllowed,
|
||||
":feature:plan" to featureAllowed,
|
||||
":feature:course" to featureAllowed,
|
||||
|
||||
@@ -13,4 +13,6 @@ dependencies {
|
||||
implementation(project(":core:network"))
|
||||
implementation(project(":core:database"))
|
||||
implementation(project(":core:datastore"))
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.mingyuan.android_client.core.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
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.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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
|
||||
|
||||
companion object {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("session") }
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
"👥 小组共学",
|
||||
"加入学习小组,查看组员的打卡与计划完成情况。在集体的氛围中保持学习动力,与志同道合的伙伴一起成长。",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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 春季", "进阶二组", "组长"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.mingyuan.android_client.core.data.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.mingyuan.android_client.core.domain.repository.LoginResult
|
||||
import com.mingyuan.android_client.core.domain.repository.SessionRepository
|
||||
import com.mingyuan.android_client.core.domain.repository.SmsCodeResult
|
||||
import com.mingyuan.android_client.core.model.Session
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Local-mock session implementation. It simulates the "phone + SMS code" login flow and persists
|
||||
* the token via DataStore so the login survives process restarts (mirroring the mini-program's
|
||||
* `wx.getStorageSync('token')`). Swap this for a network-backed implementation when the backend
|
||||
* endpoints are ready.
|
||||
*
|
||||
* Mock credentials: phone `13812341234`, code `123456`.
|
||||
*/
|
||||
@Singleton
|
||||
class FakeSessionRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : SessionRepository {
|
||||
|
||||
private companion object {
|
||||
val TOKEN_KEY = stringPreferencesKey("token")
|
||||
const val MOCK_SMS_CODE = "123456"
|
||||
val PHONE_PATTERN = Regex("^1\\d{10}$")
|
||||
// Mirrors the backend's "phone must be pre-provisioned" constraint.
|
||||
val PHONE_WHITELIST = setOf("13812341234")
|
||||
}
|
||||
|
||||
private var pendingCode: String? = null
|
||||
private var pendingPhone: String? = null
|
||||
|
||||
override val session: Flow<Session?> = dataStore.data
|
||||
.map { prefs -> prefs[TOKEN_KEY]?.let(::Session) }
|
||||
|
||||
override suspend fun requestSmsCode(phone: String): SmsCodeResult {
|
||||
if (!PHONE_PATTERN.matches(phone)) {
|
||||
return SmsCodeResult.Failure("请输入正确的手机号")
|
||||
}
|
||||
delay(500)
|
||||
pendingPhone = phone
|
||||
pendingCode = MOCK_SMS_CODE
|
||||
return SmsCodeResult.Success
|
||||
}
|
||||
|
||||
override suspend fun login(phone: String, code: String): LoginResult {
|
||||
if (phone != pendingPhone || code != pendingCode) {
|
||||
return LoginResult.Failure("验证码错误")
|
||||
}
|
||||
if (phone !in PHONE_WHITELIST) {
|
||||
return LoginResult.Failure("手机号未匹配,无法登录")
|
||||
}
|
||||
delay(500)
|
||||
dataStore.edit { it[TOKEN_KEY] = "mock-token-$phone" }
|
||||
pendingPhone = null
|
||||
pendingCode = null
|
||||
return LoginResult.Success
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
dataStore.edit { it.remove(TOKEN_KEY) }
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
api(libs.androidx.datastore.preferences)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,48 @@ package com.mingyuan.android_client.core.designsystem.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
// Brand palette — ported from the WeChat mini-program design tokens.
|
||||
val PageBackground = Color(0xFFFDFBF7)
|
||||
val PlanBackground = Color(0xFFF6F2EC)
|
||||
|
||||
val Purple40 = Color(0xFF6650A4)
|
||||
val PurpleGrey40 = Color(0xFF625B71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
val PrimaryText = Color(0xFF2C1810)
|
||||
val SecondaryText = Color(0xFF5A4A3A)
|
||||
val MutedText = Color(0xFF8B7A6A)
|
||||
val MutedLightText = Color(0xFFA99A89)
|
||||
val FaintText = Color(0xFFB6A996)
|
||||
|
||||
val BrandAmber = Color(0xFFC67B30)
|
||||
val LightAmber = Color(0xFFE3B37C)
|
||||
val AmberMid = Color(0xFFE4A05E)
|
||||
val AmberBright = Color(0xFFEFA45D)
|
||||
|
||||
val HeroGradientStart = Color(0xFF2C1810)
|
||||
val HeroGradientMid = Color(0xFF4A2B1D)
|
||||
val HeroGradientEnd = Color(0xFF6B3F24)
|
||||
|
||||
val StatusNormal = Color(0xFF2E7D32)
|
||||
val StatusLate = Color(0xFFC67B30)
|
||||
val StatusLeave = Color(0xFF3A7BD5)
|
||||
val StatusEarlyLeave = Color(0xFF8B7A6A)
|
||||
val StatusAbsent = Color(0xFFC0392B)
|
||||
|
||||
val Danger = Color(0xFFC0392B)
|
||||
val ErrorBackground = Color(0xFFFDEAEA)
|
||||
val SuccessBackground = Color(0xFFE8F4EB)
|
||||
val WarningBackground = Color(0xFFFFF3E6)
|
||||
val WarningBorder = Color(0xFFF7D9B9)
|
||||
|
||||
val NeutralChip = Color(0xFFF6EFE6)
|
||||
val SegmentedTrack = Color(0xFFEFE7DC)
|
||||
val Divider = Color(0xFFF2EDE7)
|
||||
val PillIdleBorder = Color(0xFFD8C7B4)
|
||||
val TextareaBorder = Color(0xFFE4D9CB)
|
||||
val Disabled = Color(0xFFD8C7B4)
|
||||
|
||||
val TabBackground = Color(0xFFFFFFFF)
|
||||
val TabInactive = Color(0xFF5A4A3A)
|
||||
val TabActive = Color(0xFFC67B30)
|
||||
|
||||
val SoftShadow = Color(0x0D2C1810)
|
||||
val SkeletonColor = Color(0xFFEFE6D9)
|
||||
val SkeletonHighlight = Color(0xFFFAF4EC)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -1,49 +1,59 @@
|
||||
package com.mingyuan.android_client.core.designsystem.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
)
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
primary = BrandAmber,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = LightAmber,
|
||||
onPrimaryContainer = PrimaryText,
|
||||
secondary = AmberMid,
|
||||
onSecondary = Color.White,
|
||||
tertiary = StatusLeave,
|
||||
background = PageBackground,
|
||||
onBackground = PrimaryText,
|
||||
surface = Color.White,
|
||||
onSurface = PrimaryText,
|
||||
surfaceVariant = NeutralChip,
|
||||
onSurfaceVariant = MutedText,
|
||||
error = Danger,
|
||||
onError = Color.White,
|
||||
outline = Divider,
|
||||
outlineVariant = PillIdleBorder,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = LightAmber,
|
||||
onPrimary = PrimaryText,
|
||||
primaryContainer = HeroGradientMid,
|
||||
onPrimaryContainer = LightAmber,
|
||||
background = HeroGradientStart,
|
||||
onBackground = Color(0xFFFDFBF7),
|
||||
surface = Color(0xFF2A1E18),
|
||||
onSurface = Color(0xFFFDFBF7),
|
||||
surfaceVariant = Color(0xFF3A2A20),
|
||||
onSurfaceVariant = MutedLightText,
|
||||
error = Color(0xFFE5736C),
|
||||
)
|
||||
|
||||
/**
|
||||
* The app's single Material theme. All UI entry points wrap their content in [MingyuanTheme].
|
||||
*
|
||||
* Dynamic color is deliberately disabled so the brand palette always renders as designed,
|
||||
* regardless of the device's wallpaper-based Material You scheme.
|
||||
*/
|
||||
@Composable
|
||||
fun MingyuanTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+.
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.truth)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.mingyuan.android_client.core.domain.repository
|
||||
|
||||
import com.mingyuan.android_client.core.model.Session
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
sealed interface SmsCodeResult {
|
||||
data object Success : SmsCodeResult
|
||||
data class Failure(val message: String) : SmsCodeResult
|
||||
}
|
||||
|
||||
sealed interface LoginResult {
|
||||
data object Success : LoginResult
|
||||
data class Failure(val message: String) : LoginResult
|
||||
}
|
||||
|
||||
interface SessionRepository {
|
||||
/** The current session, or `null` when logged out. */
|
||||
val session: Flow<Session?>
|
||||
|
||||
/** Derived convenience kept for the existing feature ViewModels (home/checkin/plan/profile). */
|
||||
val isLoggedIn: Flow<Boolean>
|
||||
get() = session.map { it != null }
|
||||
|
||||
suspend fun requestSmsCode(phone: String): SmsCodeResult
|
||||
|
||||
suspend fun login(phone: String, code: String): LoginResult
|
||||
|
||||
suspend fun logout()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.mingyuan.android_client.core.model
|
||||
|
||||
/**
|
||||
* A logged-in session. Deliberately minimal for now: the token is the only thing needed to decide
|
||||
* whether the user is logged in, and the user profile is still provided by [ProfileRepository].
|
||||
* When the real backend lands, this grows a `user: AuthUser` field and the local data source grows
|
||||
* a user cache.
|
||||
*/
|
||||
data class Session(
|
||||
val token: String,
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
| `core:datastore` | android library | DataStore 偏好存储 |
|
||||
| `core:designsystem` | android library + compose | 主题 / 颜色 / 字体 / 通用组件 |
|
||||
| `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) |
|
||||
|
||||
## 分层与依赖方向
|
||||
|
||||
|
||||
657
docs/需求规格说明书-V4.0-小程序与后端.md
Normal file
657
docs/需求规格说明书-V4.0-小程序与后端.md
Normal file
@@ -0,0 +1,657 @@
|
||||
# 明缘云道场小程序 + 后端 API · 需求规格说明书 V4.0
|
||||
|
||||
> 基于 `miniprogram/`(微信小程序前端)和 `server/`(FastAPI 后端)实际代码反向整理。
|
||||
> 与 V2.0/V3.0(涵盖 Web 主站 + 学员网站)平行存在;本文档 **仅覆盖这两个子系统**。
|
||||
>
|
||||
> · 适用范围:`miniprogram/`、`server/`、`server/migrations/`
|
||||
> · 反向工程日期:2026-09-10
|
||||
> · 反向基线:`git` 工作树(HEAD = 当前)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. 文档信息
|
||||
2. 项目概述
|
||||
3. 用户角色与权限
|
||||
4. 术语表
|
||||
5. 小程序前端(miniprogram/)
|
||||
6. 后端 API(server/)
|
||||
7. 数据模型
|
||||
8. 部署与配置
|
||||
9. 安全与合规
|
||||
10. 非功能性需求
|
||||
11. 错误约定
|
||||
12. 已知问题与 TODO
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
| 项 | 内容 |
|
||||
| ----- | --------------------------------------------------- |
|
||||
| 软件名称 | 明缘云道场小程序(前端)+ 明缘进阶场 API(后端) |
|
||||
| 版本 | V1.0 / V4.0 文档 |
|
||||
| 平台 | 前端:微信小程序 · 后端:Linux + Python 3.9 + FastAPI |
|
||||
| 关键依赖 | 微信开发者工具 · PyMySQL + DBUtils · httpx · python-dotenv |
|
||||
| 客户端入口 | 微信内搜索 / 扫码进入「明缘云道场」 |
|
||||
| 服务端入口 | uvicorn `server.main:app` 监听 `:8000` |
|
||||
| 文档目的 | 把代码事实定型为可验收的需求规格 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目概述
|
||||
|
||||
### 2.1 子系统定位
|
||||
|
||||
| 子系统 | 代码路径 | 角色 |
|
||||
| ------ | -------------- | ---------------------------------------------- |
|
||||
| 小程序前端 | `miniprogram/` | 用户交互入口(打卡 / 计划 / 个人),本地无状态,所有数据走后端 API |
|
||||
| 后端 API | `server/` | 鉴权、业务逻辑、MySQL 读写、文件存储、WebView 桥接 |
|
||||
| 微信生态 | 微信开放接口 | `wx.login` 换取 openid、`getuserphonenumber` 绑手机号 |
|
||||
|
||||
### 2.2 业务目标
|
||||
|
||||
为「明缘进阶场」体系的成员提供基于微信的 **轻量化学习管理工具**,核心是:
|
||||
|
||||
1. **每周打卡**(周日上课日)
|
||||
2. **周 / 月 / 年计划** 三级管理
|
||||
3. **小组统计**(学员互看,管理员全览)
|
||||
4. **个人主页**(资料、头像、登录方式)
|
||||
5. 内嵌 **H5 复用既有 Web 复盘系统**(通过 WebView + cookie 桥接)
|
||||
|
||||
### 2.3 不在范围内(明确)
|
||||
- 不在前端代码内的子系统(Web 主站 `xinhuo/19qi/18qi/`、学员站)——见 V3.0
|
||||
- 不做支付、不做 IM、不做推送(小程序代码中无相关痕迹)
|
||||
- 不做服务端渲染 SSR
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户角色与权限
|
||||
|
||||
### 3.1 角色矩阵
|
||||
|
||||
代码侧只识别两种角色(`auth_users.role` 字段):
|
||||
|
||||
| 角色键 | 中文 | 判定方式 | 用例 |
|
||||
| -------------------------------------------------------- | -------- | ------------------------------------------------ | -------------------- |
|
||||
| `teacher` | 管理员 / 老师 | `(user.role or '').strip().lower() == 'teacher'` | 看所有小组、看未分组人数、看全部打卡统计 |
|
||||
| 其他(含 `student`、`member`、`leader`、`group_leader`、`admin`) | 成员 / 组长 | 非 teacher 走学员分支 | 只看自己所在小组的数据 |
|
||||
|
||||
> 注:`ROLE_LABELS`(`pages/profile/profile.js`)将 `student/member → 成员`、`teacher/admin → 管理员`、`leader/group_leader → 组长`,但后端权限只认 `teacher`,其他角色后端一律视作学员。
|
||||
|
||||
### 3.2 权限矩阵
|
||||
|
||||
| 接口前缀 | 学员 | 老师 |
|
||||
| ------------------------------------ | ---------------- | ----------------- |
|
||||
| `/api/v1/auth/*` | ✅ 自助 | ✅ 同左 |
|
||||
| `/api/v1/profile` | ✅ 自己 | ✅ 自己 |
|
||||
| `/api/v1/profile/avatar` | ✅ 自己 | ✅ 自己 |
|
||||
| `/api/v1/checkin/weeks` | ✅ | ✅ |
|
||||
| `/api/v1/checkin/groups` | ✅(仅看自己小组) | ✅(看全部 + 未分组数) |
|
||||
| `/api/v1/checkin/group` | 仅自己小组(传他组 → 403) | 任意小组(不传看自己) |
|
||||
| `/api/v1/checkin` POST | ✅ 自己 | ✅ 自己 |
|
||||
| `/api/v1/checkin/list` | ✅ 自己历史 | ✅ 自己历史 |
|
||||
| `/api/v1/plan/*` GET | ✅ 看自己 / 自己小组 | ✅ 看全部 + 未分组数 |
|
||||
| `/api/v1/plan` POST `/checkin` | ✅ 自己当前周期 | ✅ 自己当前周期(限当前周期) |
|
||||
| `/api/v1/plan/file/{sid}/...` DELETE | 仅自己 sid(403) | 仅自己 sid(403) |
|
||||
| `/api/v1/webview/redirect` | 凭 token | 凭 token(所有权限同 H5) |
|
||||
| `/api/v1/survey/*` | ✅ | ✅(无鉴权) |
|
||||
| `/api/v1/feedback` GET | ⚠ 无鉴权(已知问题) | ⚠ 无鉴权(已知问题) |
|
||||
| `/api/v1/course-vote` | ✅ 自己投 | ✅ 自己投 |
|
||||
| `/api/v1/yijing/*` | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --------------------- | --------------------------------------------------------------------------------- |
|
||||
| openid | 微信用户唯一标识,由 `code_to_openid(code)` 换取 |
|
||||
| token / session token | 64 位 hex,会话标识,写入 `auth_sessions` 表(不是 JWT,名字沿用老 JWT 配置) |
|
||||
| class_date | 上课日 = 每周周日(周一 ~ 周日组成的自然周) |
|
||||
| period_key | 计划周期键:周=YYYY-Www、月=YYYY-MM、年=YYYY |
|
||||
| 小组(group) | `auth_users.group` 字段,如「01组」 |
|
||||
| display_name | 形如「01组张三」(仅当 group 形如 `NN组` 时拼接) |
|
||||
| webview 桥接 | 通过 `/api/v1/webview/redirect?token=...&target=...` 把 URL 上的 token 换成 cookie 再 302 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 小程序前端(miniprogram/)
|
||||
|
||||
### 5.1 全局结构
|
||||
|
||||
```
|
||||
miniprogram/
|
||||
├── app.js App 入口:onLaunch 调 ensureLogin → /profile
|
||||
├── app.json 7 个页面 + 4 tabBar + 导航主题色
|
||||
├── app.wxss 全局基础样式(背景 #FDFBF7、主色 #C67B30)
|
||||
├── project.config.json
|
||||
├── project.private.config.json
|
||||
├── sitemap.json
|
||||
├── assets/ tabBar 图标(home/checkin/plan/profile × 2)
|
||||
├── utils/
|
||||
│ ├── api.js request / planFileUrl / 环境切换 / 401 跳登录
|
||||
│ └── auth.js ensureLogin 单例 + loginWithPhone + token 存取
|
||||
└── pages/
|
||||
├── home/ 首页(已登录 → 内嵌 H5;游客 → 原生介绍)
|
||||
├── checkin/ 打卡(老师总览+详情 / 学员小组+个人)
|
||||
├── plan/ 计划总览(周/月/年)
|
||||
├── plan-submit/ 计划提交(文本 + 至多 4 张图)
|
||||
├── profile/ 个人主页(资料 / 头像 / 退出)
|
||||
├── webview/ 通用 WebView(域名白名单)
|
||||
└── privacy/ 隐私政策 + 用户协议(静态展示)
|
||||
```
|
||||
|
||||
### 5.2 主题色与视觉规范
|
||||
|
||||
| token | 值 | 用途 |
|
||||
| ----- | ------------------------------ | ------------- |
|
||||
| 主背景 | `#FDFBF7` | 全站冷色米白底 |
|
||||
| 顶栏 | `#2C1810` 渐变 → `#6B3F24` | Hero 卡背景 |
|
||||
| 主交互色 | `#C67B30` | 主按钮、未选中状态、强调色 |
|
||||
| 成功 | `#2E7D32`(+ 浅底 `#E8F4EB`) | 「正常」打卡、已完成 |
|
||||
| 警示 | `#C0392B`(+ 浅底 `#FDEAEA`) | 「不参加讨论」、错误页 |
|
||||
| 信息 | `#3A7BD5` | 「请假」 |
|
||||
| 文本主 | `#2C1810` | 标题 |
|
||||
| 文本辅 | `#8B7A6A` / `#999` | 描述、时间戳 |
|
||||
| 圆角 | `12rpx / 16rpx / 24rpx` | 卡片、按钮、Hero |
|
||||
| 字号 | 22/24/26/28/30/32/34/40/48 rpx | 时间戳到主标题 |
|
||||
|
||||
### 5.3 API 客户端(utils/api.js)
|
||||
|
||||
#### 5.3.1 环境切换(多域名)
|
||||
|
||||
```js
|
||||
getAccountInfoSync().miniProgram.envVersion
|
||||
├── 'develop' → baseUrl = https://test.muguaclub.cn/clock
|
||||
├── 'trial' → baseUrl = https://test.muguaclub.cn/clock
|
||||
└── 'release' → baseUrl = https://m.muguaclub.cn/clock
|
||||
```
|
||||
|
||||
- 三个环境对应 `STATIC_BASE_URL` + `H5_BASE_URL` 同源
|
||||
|
||||
#### 5.3.2 request 行为约定
|
||||
|
||||
| 入参 | 默认 | 说明 |
|
||||
| -------- | ------ | --------------------------------------------------------- |
|
||||
| `url` | 必填 | 必须以 `/api/v1` 开头 |
|
||||
| `method` | `GET` | 影响幂等重试 |
|
||||
| `data` | `{}` | 自动 JSON |
|
||||
| `auth` | `true` | true 时自动从 storage 取 `token` 加 `Authorization: Bearer xxx` |
|
||||
|
||||
行为:
|
||||
|
||||
- **401** → 删 token + `goProfile()` 切到「我的」页(带 1.2s 防连点锁)
|
||||
- **幂等方法(GET / DELETE)** 失败自动 800ms 后重试一次;命中 `domain not` 错误不重试
|
||||
- **非 2xx** → reject 原 data(`{error/detail/message}`)
|
||||
|
||||
#### 5.3.3 planFileUrl 路径约定
|
||||
|
||||
`attachments/{user_id}/{plan_type}/{period_key}/{filename}` →
|
||||
`STATIC_BASE_URL + /clock/static/plan/<encoded/path>`
|
||||
|
||||
> 前 4 段缺失则返回空串,强制调用方重新加载。
|
||||
|
||||
### 5.4 登录态(utils/auth.js)
|
||||
|
||||
#### 5.4.1 单例 + 指数退避
|
||||
|
||||
- `loginPromise` 全局单例,解决 `App.onLaunch` 与首页 `onShow` 同时调 `ensureLogin` 的竞态
|
||||
- `wxLogin` 内部失败重试 3 次(首次 600ms → 800ms → 1000ms)
|
||||
- 失败兜底:`wechatLogin` 整个流程失败再重试 1 次(500ms 后)
|
||||
- 任何情况下 `loginPromise` 完成后清空 → 让下一次重新发起(避免冷启动偶发失败后卡死)
|
||||
|
||||
#### 5.4.2 双入口登录
|
||||
|
||||
| 入口 | 触发 |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| 微信授权登录 | `ensureLogin()` → `/api/v1/auth/wechat-login` 用 code 换 token;后端返回 `need_phone:true` 时 resolve(null),前端引导到「我的」页绑定手机号 |
|
||||
| 手机号登录 | `loginWithPhone(phoneCode)` → `/api/v1/auth/bind-phone` 必须同时传 `code` 和 `phone_code`;绑定成功后写 token 并返回 is_new:true |
|
||||
|
||||
### 5.5 页面清单
|
||||
|
||||
#### 5.5.1 home 首页
|
||||
|
||||
- **登录态判断**:`isLoggedIn = !!getToken()`
|
||||
- **已登录** → `<web-view src="/api/v1/webview/redirect?token=...&target=...">`,key 自增强制重建(解决切 tab 白屏)
|
||||
- **未登录** → 原生介绍页(Hero 头图 + 4 个模块卡 + 4 个产品介绍卡 + 引导登录按钮)
|
||||
- 提供分享:`onShareAppMessage` / `onShareTimeline`
|
||||
|
||||
#### 5.5.2 checkin 打卡(核心)
|
||||
|
||||
**老师视图**
|
||||
|
||||
- `view = 'overview'`:所有小组打卡进度 + 自己的打卡状态
|
||||
- `view = 'detail'`:点开某小组看每个成员是否打卡 / 状态
|
||||
|
||||
**学员视图**
|
||||
|
||||
- 顶部打卡卡:5 个状态按钮(正常/迟到/请假/不参加讨论/早退),单选切换
|
||||
- `tab = 'group'`:本组成员列表
|
||||
- `tab = 'personal'`:个人打卡历史(分页,每页 20 条)
|
||||
|
||||
**通用交互**
|
||||
|
||||
- 周选择器:`/api/v1/checkin/weeks` 返 2022 年到当前的所有上课周六
|
||||
- 下拉刷新 + 上拉加载更多(个人历史 tab)
|
||||
- 周计划 / 月计划 / 年计划的周期切换(plan 页面公用同一套 picker)
|
||||
|
||||
**打卡规则(来自 `routes/checkin.py`)**
|
||||
|
||||
- 状态:`正常 / 迟到 / 请假 / 不参加讨论 / 早退`
|
||||
- 默认状态 `正常`
|
||||
- 备注最大 500 字符(仅非「正常」时允许)
|
||||
- 打卡窗口:上课日所在自然周(周一 ~ 周日)与当前周相同 / 未来周可打卡
|
||||
|
||||
#### 5.5.3 plan 计划(核心)
|
||||
|
||||
- 类型切换 tab:**周计划 / 月计划 / 年计划**
|
||||
- 卡片:
|
||||
- 自己的状态(已提交/待提交 + 当前周期)
|
||||
- 「提交 / 修改」按钮,`canSubmit=false` 时 disabled
|
||||
- 窗口提示(如「周四截止,已过提交时间」)
|
||||
- 老师视图:所有小组列表
|
||||
- 学员视图:tab = 小组(自己成员)/ 个人(历史最多 20 条)
|
||||
- 周期 key:
|
||||
- 周 = `YYYY-Www`(ISO 周)
|
||||
- 月 = `YYYY-MM`
|
||||
- 年 = `YYYY`
|
||||
|
||||
**提交时间窗(`routes/plan.py`)**
|
||||
|
||||
- 周:上一周五 00:00 ~ 本周四 23:59:59
|
||||
- 月 / 年:仅当前周期可提交,历史 / 未来都禁止
|
||||
|
||||
#### 5.5.4 plan-submit 提交页
|
||||
|
||||
| 控件 | 限制 |
|
||||
| -------- | ------------------------------------------------------------------ |
|
||||
| textarea | maxlength 1000、disabled=!canSubmit |
|
||||
| 图片 | 最多 4 张、允许后缀 `jpg/jpeg/png/gif/webp/bmp`、>1MB 自动逐级压缩 80→60→45 |
|
||||
| 删除 | 调 `/api/v1/plan/file/{sid}/{plan_type}/{period}/{filename}` DELETE |
|
||||
| 提交 | 文本或图片必有一项;已有记录弹覆盖确认 |
|
||||
|
||||
**上传协议**:`wx.uploadFile`,`formData = { plan_type, period_key }`,`name='files'`,**支持 multipart 多文件**
|
||||
|
||||
#### 5.5.5 profile 个人主页
|
||||
|
||||
**视图状态机**:`view ∈ { loading, profile, phoneLogin, loadError }`
|
||||
|
||||
- 已登录 + 有 token → 显示完整资料 + 头像 + 班级分组列表 + 退出登录
|
||||
- 未登录 / need_phone → 显示手机号登录卡(强依赖隐私协议勾选)
|
||||
- chooseAvatar:调 `/api/v1/profile/avatar` 单文件上传,avatar 文件名统一为 `avatar.{ext}`,旧头像自动清理
|
||||
- 退出登录:调 `/api/v1/auth/logout`(尽力而为)+ 清本地 token + 清隐私同意标记 + reLaunch 回首页
|
||||
|
||||
#### 5.5.6 webview 通用 web-view
|
||||
|
||||
- 入参:`url`(必须 URL 编码)
|
||||
- **域名白名单**(`isAllowedUrl`):仅允许 `m.muguaclub.cn` 和 `muguaclub.cn` 及其子域;非法 URL 渲染错误页
|
||||
|
||||
#### 5.5.7 privacy 隐私 / 协议页
|
||||
|
||||
- 两个静态文档:隐私政策 8 节、用户协议 8 节
|
||||
- 通过 query `type` 切换:`privacy` / `agreement`
|
||||
- 最后更新日期占位:**2026 年 9 月**
|
||||
|
||||
### 5.6 通用 UI 特性
|
||||
|
||||
- **骨架屏**:列表 / 表单 / 详情都有 `sk-*` 占位
|
||||
- **错误页**:圆形「!」 + 失败文案 + 「重新加载」按钮
|
||||
- **空态**:emoji + 简短说明
|
||||
- **分享**:所有页面都实现 `onShareAppMessage` + `onShareTimeline`
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端 API(server/)
|
||||
|
||||
### 6.1 工程骨架
|
||||
|
||||
```
|
||||
server/
|
||||
├── __init__.py
|
||||
├── main.py FastAPI() + 挂 9 个 router + /api/v1/health
|
||||
├── config.py 从 .env 读所有密钥 / 路径
|
||||
├── db.py PyMySQL + DBUtils PooledDB,单例 per db
|
||||
├── security.py encrypt_phone(hmac-sha256) + create_session_token
|
||||
├── wechat.py code_to_openid + get_phone_number + _get_access_token
|
||||
├── user.py make_display_name + get_class_groups + user_payload
|
||||
├── routes/
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py /api/v1/auth/*
|
||||
│ ├── checkin.py /api/v1/checkin/*
|
||||
│ ├── plan.py /api/v1/plan/*
|
||||
│ ├── profile.py /api/v1/profile*
|
||||
│ ├── webview.py /api/v1/webview/redirect
|
||||
│ ├── survey.py /api/v1/survey/* (无鉴权)
|
||||
│ ├── feedback.py /api/v1/feedback/* (含仅 ADMIN_KEY 的管理接口)
|
||||
│ ├── course_vote.py /api/v1/course-vote*
|
||||
│ └── yijing.py /api/v1/yijing/* (从老 Flask 服务迁移)
|
||||
└── migrations/
|
||||
├── 001_add_wechat_openid_column.sql
|
||||
├── 002_plan_perf_index.sql
|
||||
├── 003_checkin_records_user_id.sql
|
||||
└── 004_plan_checkins_user_id.sql
|
||||
```
|
||||
|
||||
> ⚠ 已知 bug(`main.py:29`):`uvicorn.run("server_prd.main:app", ...)` 引用不存在的包,启动会 ImportError。
|
||||
|
||||
### 6.2 全局运行时依赖
|
||||
|
||||
| 依赖 | 作用 |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FastAPI | Web 框架 |
|
||||
| uvicorn | ASGI 运行(默认 :8000,host 127.0.0.1,dev 模式 reload) |
|
||||
| PyMySQL | MySQL 驱动 |
|
||||
| DBUtils `PooledDB` | 连接池:`DB_POOL_MIN_CACHED=1 / MAX_CACHED=5 / MAX_CONNECTIONS=10 / MAX_USAGE=100`、ping=1、connect_timeout=5s、read_timeout=5s |
|
||||
| httpx | 调用 `api.weixin.qq.com`(jscode2session / access_token / 手机号解密) |
|
||||
| python-dotenv | `load_dotenv(dotenv_path, override=True)` |
|
||||
| pydantic | 请求体 / 响应模型 |
|
||||
|
||||
### 6.3 配置项(config.py)
|
||||
|
||||
| 键 | 默认 | 用途 |
|
||||
| ---------------------------------- | ------------------------------------------- | -------------------------------------------- |
|
||||
| `WECHAT_APPID` / `WECHAT_SECRET` | 空 | 微信 API 凭据 |
|
||||
| `JWT_SECRET` * | 空 | **代码里没有真正用上 JWT**,命名沿用历史,实际为 `auth_sessions` |
|
||||
| `JWT_ALGORITHM` | `HS256` | 同上,遗留 |
|
||||
| `JWT_TTL_SECONDS` | `90*24*3600`(90 天) | 会话有效期 |
|
||||
| `ADMIN_KEY` | 空 | `/api/v1/feedback/resolve` 管理密钥 |
|
||||
| `MYSQL_HOST/PORT/USER/PASSWORD/DB` | `localhost/3306/root/''/mys_info_v3` | 主业务库 |
|
||||
| `SURVEY_DB` | `myxhxc` | 问卷库(独立库,独立连接池) |
|
||||
| `PHONE_HMAC_KEY` | `MingYuan2026AuthKey` | 手机号 HMAC-SHA256 盐 |
|
||||
| `H5_BASE_URL` | `http://127.0.0.1:8001` | WebView 跳转上游(H5 主站) |
|
||||
| `FEEDBACK_DIR` | `/home/introduction/feedback` | 用户反馈 JSON + 图片目录 |
|
||||
| `COURSE_VOTES_FILE` | `/home/introduction/test/course_votes.json` | 课程投票持久化文件 |
|
||||
| `PLAN_ATTACH_DIR` | `/home/static/plan` | 计划附件根目录 |
|
||||
| `STATIC_ROOT` / `AVATAR_DIR` | `/home/static[/avatars]` | 头像静态文件目录(nginx 直读) |
|
||||
| `DB_POOL_*` | 上表 | 连接池参数(仅从 os.environ 读,不走 config) |
|
||||
|
||||
> 注:`config.py` 用 `os.environ.get(name, default)`,但 `db.py` 用 `os.environ.get("DB_POOL_*")` 直接读。
|
||||
|
||||
### 6.4 数据库连接池(db.py)
|
||||
|
||||
- 多库支持:`_get_pool(database)` 按 db 名缓存 pool,**双数据库**(主业务 + survey)
|
||||
- `fetch_one / fetch_all / execute` 三个 API,借 / 还连接(`close()` 让连接回池)
|
||||
- `close_all()` 用于测试 / reload hook
|
||||
|
||||
### 6.5 鉴权(`get_current_user`)
|
||||
|
||||
判定顺序:
|
||||
|
||||
1. `Authorization: Bearer xxx`(小程序 API 默认)
|
||||
2. `Cookie: auth_token=xxx`(H5 走 webview 桥接后自动带)
|
||||
3. 都无 → 401
|
||||
|
||||
查表:
|
||||
|
||||
- `auth_sessions WHERE token=? AND expires_at>NOW()` 拿到 `user_id`
|
||||
- 再 `SELECT * FROM auth_users WHERE id=?`
|
||||
|
||||
### 6.6 接口约定
|
||||
|
||||
#### 6.6.1 /api/v1/auth
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
| ---- | --------------- | --- | ------------------------------------------------------------------------------------------------ |
|
||||
| POST | `/wechat-login` | 否 | `code` → openid → 查 user;命中即发 token,未命中返 `need_phone:true` |
|
||||
| POST | `/bind-phone` | 否 | `code, phone_code` → 拿手机号 → HMAC 查 user;命中即 update `wechat_openid` + 发 token;首次绑定返 `is_new:true` |
|
||||
| GET | `/verify` | 是 | 返回 `user_payload` |
|
||||
| POST | `/logout` | 是 | 删除 session + 清 user.wechat_openid + 删 cookie |
|
||||
|
||||
#### 6.6.2 /api/v1/profile
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ----------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| GET | `/profile` | user_payload + `phone_display`(注意:**原文里 user 表无 phone_display 字段**,8.2 已知问题) + `wechat_avatar` + `login_type` |
|
||||
| POST | `/profile/avatar` | multipart 单文件,4MB 上限,存 `avatar.{ext}`,旧头像自动删;URL `=/clock/static/avatars/{uid}/avatar{ext}` |
|
||||
|
||||
#### 6.6.3 /api/v1/checkin
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ----------------------------------------- | ---------------------------------- |
|
||||
| GET | `/weeks` | 2022 年至当前的所有上课周六列表 + current_week |
|
||||
| GET | `/week` | **已弃用**,仅供旧客户端 |
|
||||
| GET | `/groups?class_date=YYYY-MM-DD` | 老师→全部小组 + 未分组数;学员→自己小组 + my_group |
|
||||
| GET | `/group?class_date=&group_name=` | 单组成员 + 各自打卡状态;学员传他组→403 |
|
||||
| POST | `/checkin` `{status, class_date?, note?}` | upsert(按 user_id + class_date 唯一键) |
|
||||
| GET | `/list?page=&size=` | 个人历史分页,默认 10,上限 50 |
|
||||
|
||||
#### 6.6.4 /api/v1/plan
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| GET | `/periods?type=` | 周/月/年周期列表 + current_period |
|
||||
| GET | `/status?type=&period_key?=` | 看本期是否已提交 + can_submit + message |
|
||||
| GET | `/groups?type=&period_key?=` | 老师看全部 + 未分组;学员看自己小组 |
|
||||
| GET | `/group?type=&group_name=&period_key?=` | 单组成员列表 + 各自 filled |
|
||||
| POST | `/checkin` `{plan_type, period_key?, status?, content?, attachments[]?}` | upsert(按 user_id + plan_type + period_key) |
|
||||
| GET | `/history?type=&page=&size=` | 个人历史分页 |
|
||||
| POST | `/upload` multipart `{plan_type, period_key, files[]}` | 至多 4 文件、每文件 ≤10MB、白名单扩展名、保存路径 `attachments/{user_id}/{plan_type}/{period_key}/{filename}` |
|
||||
| GET | `/file/{sid}/{plan_type}/{period}/{filename}` | 直读静态文件 |
|
||||
| DELETE | `/file/{sid}/...` | 校验 sid == user.id,否则 403 |
|
||||
|
||||
#### 6.6.5 /api/v1/webview
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
| --- | -------------------------- | -------------- | ------------------------------------------- |
|
||||
| GET | `/redirect?token=&target=` | 否(凭 URL token) | 校验 token → set HttpOnly cookie → 302 target |
|
||||
|
||||
安全要点:
|
||||
|
||||
- `target` 仅允许相对路径(`/xxx`)或白名单域 `m.muguaclub.cn` 及其子域
|
||||
- cookie:`HttpOnly`、`Secure(scheme=https)`、`SameSite=Lax`、`MaxAge=JWT_TTL_SECONDS`
|
||||
|
||||
#### 6.6.6 /api/v1/survey(独立库 `myxhxc`,无鉴权)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ------------------------------------------ | ------------------- |
|
||||
| GET | `/tasks` | 状态为 active 的所有 task |
|
||||
| GET | `/responses?task_id=` | 某 task 的所有 group 回答 |
|
||||
| POST | `/response` `{task_id, group_id, answers}` | upsert |
|
||||
| PUT | `/response/{rid}` `{answers}` | 更新已有 |
|
||||
| POST | `/task` | 占位,功能预留 |
|
||||
|
||||
#### 6.6.7 /api/v1/feedback
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
| ---- | ------------------------------------ | --------- | --------------------------------------------- |
|
||||
| GET | `""` | ❌ 无 | 读所有 json 文件,按 unsolved/solved 倒序 |
|
||||
| POST | `""` `{name?, description?, image?}` | 否 | 写一个 4 位 hex id 的 JSON,可附 data:image base64 图片 |
|
||||
| GET | `/file/{name}` | 否 | 直读图片 |
|
||||
| POST | `/resolve?id=&token=` | ADMIN_KEY | 标记为 solved |
|
||||
|
||||
> ⚠ 已知问题:`/feedback` GET 当前无鉴权,任何人能看所有反馈(**安全风险**)
|
||||
|
||||
#### 6.6.8 /api/v1/course_vote
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
| ---- | ------------------------------------------------ | --- | ------------------------------ |
|
||||
| POST | `/course-vote` `{votes[3-6], rating?, comment?}` | 是 | 写入 `COURSE_VOTES_FILE` JSON 文件 |
|
||||
| GET | `/course-votes` | 是 | 聚合(总票数 / 评论均分)+ 我的投票 |
|
||||
|
||||
> 用 JSON 文件存,无数据库,便于迁移自老 Flask。
|
||||
|
||||
#### 6.6.9 /api/v1/yijing
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
| ---- | ------------------------------------------------------- | --- | ---------------------------------------------------------------- |
|
||||
| GET | `/catalog` | 否 | 八卦 + 六十四卦元数据 |
|
||||
| GET | `/progress` | 是 | 5 个阶段(1..5)的学习进度 |
|
||||
| POST | `/progress` `{stage[1-5], item_type, item_id, status?}` | 是 | upsert |
|
||||
| POST | `/quiz` `{stage, item_type, item_id, correct, answer?}` | 是 | 记一次尝试,对应进度 score++;score ≥ 2 → mastered;写入 yijing_reviews 用于复习排期 |
|
||||
|
||||
### 6.7 后端非业务模块
|
||||
|
||||
| 模块 | 用途 |
|
||||
| ------------------------------ | ----------------------- |
|
||||
| `start.sh` / `stop.sh` | 启停脚本(开发环境) |
|
||||
| `start_prd.sh` / `stop_prd.sh` | 生产环境启停(pid 文件管理) |
|
||||
| `index.html` | 部署目录下的 health 静态页 |
|
||||
| `requirements.txt` | 依赖清单 |
|
||||
| `.env` | **曾被 git 跟踪,含密钥**(已知问题) |
|
||||
| `uvicorn.log` | 运行日志 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
> 由 `auth_users / auth_sessions / classes / groups / class_groups / group_history / checkin_records_old / plan_checkins / yijing_trigrams / yijing_hexagrams / yijing_progress / yijing_attempts / yijing_reviews / survey_tasks / survey_responses` 等表名 / SQL 反推;具体 DDL 不在 `server/` 内。
|
||||
|
||||
### 7.1 主业务库(mys_info_v3)
|
||||
|
||||
| 表 | 主键 | 关键字段 | 用途 |
|
||||
| --------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `auth_users` | `id` | `student_id, name, phone_encrypted(HMAC-SHA256), wechat_openid(唯一键), wechat_avatar, login_type, role, \`group\`` | 用户账号 + 资料 |
|
||||
| `auth_sessions` | `id` | `user_id, token(唯一), expires_at` | 会话(90 天) |
|
||||
| `classes` | `id` | `code, name` | 班级(期/班次) |
|
||||
| `groups` | `id` | `group_code, sort` | 小组 |
|
||||
| `class_groups` | (复合) | `class_id, group_id, user_id, role_in_group` | 班级 × 小组 × 学员 多对多 |
|
||||
| `group_history` | (复合) | `user_id, class_id, old_group_id, changed_at` | 学员所在小组变迁 |
|
||||
| `checkin_records_old` | (复合) `uk_checkin_user_date(user_id, class_date)` | `user_id, class_date, checked_at, status, note` | **打卡记录**(注:表名保留 `_old` 后缀,迁移按 user_id,详见 migration 003) |
|
||||
| `plan_checkins` | (复合) `uk_plan_user_period(user_id, plan_type, period_key)` | `user_id, plan_type, period_key, status(已完成/未完成), checked_at, content, attachments(JSON), created_at, updated_at` + 索引 `idx_plan_period_user` | 计划记录 |
|
||||
| `yijing_trigrams` | `id` | `name, symbol, nature, de, family, direction, xiantian, houtian, binary_code, class_xiang, summary` | 八卦(教材化元数据) |
|
||||
| `yijing_hexagrams` | `id` | `name, full_name, symbol, upper_trigram, lower_trigram, binary_code, judgment, gua_explanation, bagong, gong_wuxing` | 六十四卦 |
|
||||
| `yijing_progress` | (复合 stage, item_type, item_id) | `user_id, stage, item_type, item_id, status(learning/mastered), score, attempts, correct` | 学习进度 |
|
||||
| `yijing_attempts` | `id` | `user_id, stage, item_type, item_id, correct, answer` | 尝试明细 |
|
||||
| `yijing_reviews` | (复合) | `user_id, item_type, item_id, due_at` | 复习到期时间 |
|
||||
|
||||
### 7.2 问卷库(myxhxc)
|
||||
|
||||
| 表 | 关键字段 |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| `survey_tasks` | `task_no, status(active), ...` |
|
||||
| `survey_responses` | `task_id, group_id, answers(JSON)` |
|
||||
|
||||
### 7.3 文件存储
|
||||
|
||||
| 路径 | 用途 |
|
||||
| ---------------------------------------------------------------------- | -------------- |
|
||||
| `/home/static/avatars/{uid}/avatar{ext}` | 个人头像(nginx 直读) |
|
||||
| `/home/static/plan/attachments/{user_id}/{plan_type}/{period_key}/...` | 计划附件 |
|
||||
| `/home/introduction/feedback/*.json` + `images/*.png` | 反馈 JSON + 图片 |
|
||||
| `/home/introduction/test/course_votes.json` | 课程投票 JSON |
|
||||
|
||||
### 7.4 迁移状态(migrations/)
|
||||
|
||||
- **001**:给 `auth_users` 加 `wechat_openid` 唯一键(绑微信前提)
|
||||
- **002**:给 `plan_checkins` 加 `(plan_type, period_key, student_id)` 索引(性能,已被 004 替换)
|
||||
- **003**:`checkin_records_old` 改按 `user_id` 关联,加唯一键 `(user_id, class_date)`
|
||||
- **004**:`plan_checkins` 改按 `user_id` 关联,加 `(user_id, plan_type, period_key)` 唯一键 + 替换索引;附件相对路径从 student_id 改写为 user_id
|
||||
|
||||
---
|
||||
|
||||
## 8. 部署与配置
|
||||
|
||||
### 8.1 启动命令(开发)
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# 配置 server/.env:WECHAT_APPID / WECHAT_SECRET / MYSQL_* / PHONE_HMAC_KEY / ADMIN_KEY / H5_BASE_URL
|
||||
python -m uvicorn server.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### 8.2 启动命令(生产)
|
||||
|
||||
```bash
|
||||
# 启动
|
||||
bash server/start.sh
|
||||
# 停止
|
||||
bash server/stop.sh
|
||||
```
|
||||
|
||||
### 8.3 nginx 转发示例(推断)
|
||||
|
||||
```
|
||||
# /api/v1/* → :8000 (uvicorn)
|
||||
# /clock/static/* → /home/static/ (nginx 直读)
|
||||
# /clock/api/v1/webview/* → :8000 (cookie 桥接)
|
||||
# / → upstream muguaclub.cn / 8001 (H5 主站)
|
||||
```
|
||||
|
||||
### 8.4 域名与端口
|
||||
|
||||
| 角色 | 域名 | 后端端口 |
|
||||
| --- | ------------------- | -------------------------- |
|
||||
| 正式 | `m.muguaclub.cn` | 8000 (FastAPI) + 8001 (H5) |
|
||||
| 测试 | `test.muguaclub.cn` | 同上 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全与合规
|
||||
|
||||
### 9.1 鉴权
|
||||
|
||||
- 单 token 体系:所有受保护接口统一用 `get_current_user`(`Authorization` 或 cookie)
|
||||
- 退出登录:删 session + 解绑 openid(下次需重新授权手机号)
|
||||
- WebView 桥接:URL 上短期 token → cookie,再 302 跳转同站域(防开放重定向)
|
||||
|
||||
### 9.2 传输安全
|
||||
|
||||
- 头像直链强制 https(`secure=request.url.scheme == "https"`)
|
||||
- Cookie `HttpOnly + SameSite=Lax`
|
||||
- OpenAPI 全 HTTPS(微信 API 仅 https)
|
||||
|
||||
### 9.3 数据安全
|
||||
|
||||
- 手机号 HMAC-SHA256 存 `phone_encrypted`,原号不出库
|
||||
- 反馈、计划内容、明文入库(小程序端有 maxlength 1000 / 500 限制)
|
||||
|
||||
### 9.4 隐私合规
|
||||
|
||||
- 「我的」页强制要求勾选隐私政策 + 用户协议才能调 `getPhoneNumber`
|
||||
- 隐私协议 8 节;用户协议 8 节;最后更新 2026 年 9 月(占位)
|
||||
- 客服邮箱 `[此处填写客服邮箱]` 占位需补充
|
||||
|
||||
---
|
||||
|
||||
## 10. 非功能性需求
|
||||
|
||||
### 10.1 性能
|
||||
|
||||
| 维度 | 要求 |
|
||||
| ------- | ----------------------------------- |
|
||||
| 首屏冷启动 | 幂等请求(GET/DELETE)网络错误自动 800ms 重试 1 次 |
|
||||
| 单实例 QPS | 与连接池 `maxconnections=10` 匹配;超出排队 |
|
||||
| 单次连接 | connect_timeout=5s,read_timeout=5s |
|
||||
| 列表分页 | 打卡默认 10,最大 50;计划默认 10,最大 50 |
|
||||
| 客户端压缩 | 计划图片 >1MB 自动 80→60→45 三级降质压缩 |
|
||||
| 上传上限 | 单文件 10MB(计划)/ 4MB(头像) |
|
||||
|
||||
### 10.2 可用性
|
||||
|
||||
- 单会话 90 天有效,过期即失效(前端需重新登录)
|
||||
- 计划历史周期只读(can_submit=false)
|
||||
- 学员只能查自己所在小组
|
||||
- 切换 tab / 周选择器 / 周期选择器都有 fallback(数据为空时不刷)
|
||||
|
||||
### 10.3 可观测性(缺)
|
||||
|
||||
代码中未发现业务埋点 / 性能监控 / 错误聚合;`uvicorn.log` 仅记录关键 warn/error(如 `微信登录 code 换取 openid 失败`)。
|
||||
|
||||
### 10.4 兼容性
|
||||
|
||||
- 小程序基础库 `>=3.0.0`(`wx.getAccountInfoSync`)
|
||||
- Python ≥3.9(FastAPI 最低版本)
|
||||
|
||||
---
|
||||
|
||||
## 11. 错误约定
|
||||
|
||||
- HTTP 状态码:
|
||||
- `200` 业务正常
|
||||
- `400` 参数错误(含业务校验:「仅可填写当前周期」「已过打卡时间」等)
|
||||
- `401` 缺 token / token 无效或过期
|
||||
- `403` 无权限(学员查他组 / 删他人附件)
|
||||
- `404` 资源不存在
|
||||
- `500` 未捕获的运行时错误(FastAPI 默认)
|
||||
- 错误体:
|
||||
- FastAPI 默认:`{"detail": "..."}`
|
||||
- 业务自定义:`{"error": "..."}` 或 `{"success": false, "error": "..."}` 或 `{"need_phone": true, "error": "..."}`
|
||||
- 前端解析顺序(`pages/checkin/checkin.js`):`err.detail → err.error → err.message`
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> 文档结束。如有功能/接口改动,请同步更新本文件章节 5/6 并在文末「更新日志」追加条目。
|
||||
@@ -1,18 +1,134 @@
|
||||
package com.mingyuan.android_client.feature.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
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.MingyuanButton
|
||||
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.PageBackground
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
|
||||
|
||||
/** Navigation route for this feature, referenced by the app-level NavHost. */
|
||||
const val AUTH_ROUTE = "auth"
|
||||
|
||||
private val PHONE_PATTERN = Regex("^1\\d{10}$")
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Auth")
|
||||
fun AuthScreen(
|
||||
onLoggedIn: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AuthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var privacyAgreed by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loginSuccess.collect { onLoggedIn() }
|
||||
}
|
||||
|
||||
val phoneValid = PHONE_PATTERN.matches(phone)
|
||||
val canSendCode = phoneValid && uiState.countdownSeconds == 0 && !uiState.isSendingCode
|
||||
val canSubmit = phoneValid && code.isNotBlank() && privacyAgreed && !uiState.isSubmitting
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(PageBackground)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(48.dp))
|
||||
Text("手机号登录", color = PrimaryText, fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("手机号仅用于账号绑定,不会公开展示", color = FaintText, fontSize = 13.sp)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it.filter(Char::isDigit).take(11) },
|
||||
label = { Text("手机号") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter(Char::isDigit).take(6) },
|
||||
label = { Text("验证码") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
MingyuanButton(
|
||||
text = when {
|
||||
uiState.countdownSeconds > 0 -> "${uiState.countdownSeconds}s 后重发"
|
||||
uiState.isSendingCode -> "发送中"
|
||||
else -> "获取验证码"
|
||||
},
|
||||
onClick = { viewModel.requestSmsCode(phone) },
|
||||
enabled = canSendCode,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = privacyAgreed,
|
||||
onCheckedChange = { privacyAgreed = it },
|
||||
)
|
||||
Text(
|
||||
text = "我已阅读并同意《用户协议》和《隐私政策》",
|
||||
color = FaintText,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
uiState.errorMessage?.let { message ->
|
||||
Text(message, color = Danger, fontSize = 13.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MingyuanButton(
|
||||
text = if (uiState.isSubmitting) "登录中..." else "登录",
|
||||
onClick = { viewModel.login(phone, code) },
|
||||
enabled = canSubmit,
|
||||
fullWidth = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,74 @@
|
||||
package com.mingyuan.android_client.feature.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.LoginResult
|
||||
import com.mingyuan.android_client.core.domain.repository.SessionRepository
|
||||
import com.mingyuan.android_client.core.domain.repository.SmsCodeResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AuthUiState(
|
||||
val isSendingCode: Boolean = false,
|
||||
val countdownSeconds: Int = 0,
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor() : ViewModel()
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val sessionRepository: SessionRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _loginSuccess = MutableSharedFlow<Unit>()
|
||||
val loginSuccess: SharedFlow<Unit> = _loginSuccess
|
||||
|
||||
private var countdownJob: Job? = null
|
||||
|
||||
fun requestSmsCode(phone: String) {
|
||||
if (_uiState.value.isSendingCode || _uiState.value.countdownSeconds > 0) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSendingCode = true, errorMessage = null) }
|
||||
when (val result = sessionRepository.requestSmsCode(phone)) {
|
||||
is SmsCodeResult.Success -> startCountdown()
|
||||
is SmsCodeResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
|
||||
}
|
||||
_uiState.update { it.copy(isSendingCode = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun login(phone: String, code: String) {
|
||||
if (_uiState.value.isSubmitting) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSubmitting = true, errorMessage = null) }
|
||||
when (val result = sessionRepository.login(phone, code)) {
|
||||
is LoginResult.Success -> _loginSuccess.emit(Unit)
|
||||
is LoginResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
|
||||
}
|
||||
_uiState.update { it.copy(isSubmitting = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCountdown() {
|
||||
countdownJob?.cancel()
|
||||
countdownJob = viewModelScope.launch {
|
||||
for (seconds in 60 downTo 1) {
|
||||
_uiState.update { it.copy(countdownSeconds = seconds) }
|
||||
delay(1000)
|
||||
}
|
||||
_uiState.update { it.copy(countdownSeconds = 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.checkin"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,311 @@
|
||||
package com.mingyuan.android_client.feature.checkin
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.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.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. */
|
||||
const val CHECKIN_ROUTE = "checkin"
|
||||
|
||||
@Composable
|
||||
fun CheckinScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Check-in")
|
||||
fun CheckinScreen(
|
||||
onNavigateToAuth: () -> Unit,
|
||||
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) {
|
||||
onNavigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,67 @@
|
||||
package com.mingyuan.android_client.feature.checkin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
|
||||
import com.mingyuan.android_client.core.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 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
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
feature/home/build.gradle.kts
Normal file
11
feature/home/build.gradle.kts
Normal 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)
|
||||
}
|
||||
2
feature/home/src/main/AndroidManifest.xml
Normal file
2
feature/home/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -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,
|
||||
onNavigateToAuth: () -> 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
|
||||
onNavigateToAuth()
|
||||
},
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.plan"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,396 @@
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.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.TextButton
|
||||
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.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. */
|
||||
const val PLAN_ROUTE = "plan"
|
||||
|
||||
@Composable
|
||||
fun PlanScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Plan")
|
||||
fun PlanScreen(
|
||||
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
|
||||
onNavigateToAuth: () -> 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) {
|
||||
onNavigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,60 @@
|
||||
package com.mingyuan.android_client.feature.plan
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.profile"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,225 @@
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.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.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.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. */
|
||||
const val PROFILE_ROUTE = "profile"
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Profile")
|
||||
fun ProfileScreen(
|
||||
onNavigateToAuth: () -> Unit,
|
||||
onLoggedOut: () -> Unit,
|
||||
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 = onNavigateToAuth, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
package com.mingyuan.android_client.feature.profile
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
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 logout() {
|
||||
viewModelScope.launch {
|
||||
sessionRepository.logout()
|
||||
_logoutEvents.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||
|
||||
# AndroidX
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
|
||||
|
||||
19
secrets.properties.example
Normal file
19
secrets.properties.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# 服务器凭据模板 —— 复制为 secrets.properties 并填入真实值
|
||||
# secrets.properties 已被 .gitignore 忽略(绝不提交);本 example 文件可安全提交,供 clone 后照着填。
|
||||
#
|
||||
# ---- SSH(生产机)----
|
||||
SSH_HOST=
|
||||
SSH_PORT=22
|
||||
SSH_USER=
|
||||
SSH_PASSWORD=
|
||||
|
||||
# ---- MySQL 生产库 ----
|
||||
MYSQL_PROD_HOST=
|
||||
MYSQL_PROD_PORT=3306
|
||||
MYSQL_PROD_USER=
|
||||
MYSQL_PROD_PASSWORD=
|
||||
MYSQL_PROD_DB=mys_info_v3
|
||||
|
||||
# ---- 后端 API ----
|
||||
API_BASE_URL=https://m.muguaclub.cn/clock
|
||||
API_TOKEN=
|
||||
@@ -36,6 +36,7 @@ include(":core:database")
|
||||
include(":core:datastore")
|
||||
include(":core:data")
|
||||
include(":feature:auth")
|
||||
include(":feature:home")
|
||||
include(":feature:checkin")
|
||||
include(":feature:plan")
|
||||
include(":feature:course")
|
||||
|
||||
Reference in New Issue
Block a user