From c80fb31eb1b1906024cb964e89f55b3e78177a8f Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Wed, 9 Sep 2026 19:57:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=8D=E5=88=BB=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E5=9B=9B=E5=A4=A7=E4=B8=BB=E9=A1=B5=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E5=AF=BC=E8=88=AA=EF=BC=88UI=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=EF=BC=8C=E6=9C=AC=E5=9C=B0=20mock=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + app/build.gradle.kts | 3 + .../com/mingyuan/android_client/AppNavHost.kt | 127 +++++- .../mingyuan/android_client/MainActivity.kt | 2 + .../android_client/TopLevelDestination.kt | 24 ++ .../convention/src/main/kotlin/RootPlugin.kt | 5 +- core/data/build.gradle.kts | 2 + .../android_client/core/data/di/DataModule.kt | 36 ++ .../data/repository/FakeCheckinRepository.kt | 63 +++ .../data/repository/FakeHomeRepository.kt | 49 +++ .../data/repository/FakePlanRepository.kt | 96 +++++ .../data/repository/FakeProfileRepository.kt | 51 +++ .../component/AmberProgressBar.kt | 33 ++ .../core/designsystem/component/Avatar.kt | 37 ++ .../core/designsystem/component/LabeledRow.kt | 36 ++ .../designsystem/component/MingyuanButton.kt | 74 ++++ .../designsystem/component/SectionCard.kt | 35 ++ .../component/SegmentedControl.kt | 54 +++ .../core/designsystem/component/Skeleton.kt | 39 ++ .../core/designsystem/component/StateViews.kt | 67 ++++ .../core/designsystem/theme/Color.kt | 50 ++- .../core/designsystem/theme/Gradients.kt | 16 + .../core/designsystem/theme/Theme.kt | 60 +-- core/domain/build.gradle.kts | 1 + .../domain/repository/CheckinRepository.kt | 11 + .../core/domain/repository/HomeRepository.kt | 8 + .../core/domain/repository/PlanRepository.kt | 19 + .../domain/repository/ProfileRepository.kt | 10 + .../core/model/CheckinModels.kt | 34 ++ .../android_client/core/model/HomeModels.kt | 28 ++ .../android_client/core/model/PlanModels.kt | 48 +++ .../core/model/ProfileModels.kt | 19 + .../core/ui/status/StatusBadge.kt | 37 ++ .../core/ui/status/StatusColor.kt | 17 + .../core/ui/status/StatusPill.kt | 60 +++ docs/architecture.md | 2 +- feature/checkin/build.gradle.kts | 4 + .../feature/checkin/CheckinScreen.kt | 289 +++++++++++++- .../feature/checkin/CheckinViewModel.kt | 49 ++- feature/home/build.gradle.kts | 11 + feature/home/src/main/AndroidManifest.xml | 2 + .../android_client/feature/home/HomeScreen.kt | 216 ++++++++++ .../feature/home/HomeViewModel.kt | 36 ++ feature/plan/build.gradle.kts | 4 + .../android_client/feature/plan/PlanScreen.kt | 368 +++++++++++++++++- .../feature/plan/PlanSubmitScreen.kt | 257 ++++++++++++ .../feature/plan/PlanSubmitViewModel.kt | 63 +++ .../feature/plan/PlanViewModel.kt | 42 +- feature/profile/build.gradle.kts | 4 + .../feature/profile/ProfileScreen.kt | 204 +++++++++- .../feature/profile/ProfileViewModel.kt | 37 +- gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + 53 files changed, 2777 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/mingyuan/android_client/TopLevelDestination.kt create mode 100644 core/data/src/main/kotlin/com/mingyuan/android_client/core/data/di/DataModule.kt create mode 100644 core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeCheckinRepository.kt create mode 100644 core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeHomeRepository.kt create mode 100644 core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakePlanRepository.kt create mode 100644 core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeProfileRepository.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/AmberProgressBar.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Avatar.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/LabeledRow.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/MingyuanButton.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SectionCard.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SegmentedControl.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Skeleton.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/StateViews.kt create mode 100644 core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Gradients.kt create mode 100644 core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/CheckinRepository.kt create mode 100644 core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/HomeRepository.kt create mode 100644 core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/PlanRepository.kt create mode 100644 core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/ProfileRepository.kt create mode 100644 core/model/src/main/kotlin/com/mingyuan/android_client/core/model/CheckinModels.kt create mode 100644 core/model/src/main/kotlin/com/mingyuan/android_client/core/model/HomeModels.kt create mode 100644 core/model/src/main/kotlin/com/mingyuan/android_client/core/model/PlanModels.kt create mode 100644 core/model/src/main/kotlin/com/mingyuan/android_client/core/model/ProfileModels.kt create mode 100644 core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusBadge.kt create mode 100644 core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusColor.kt create mode 100644 core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusPill.kt create mode 100644 feature/home/build.gradle.kts create mode 100644 feature/home/src/main/AndroidManifest.xml create mode 100644 feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeScreen.kt create mode 100644 feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeViewModel.kt create mode 100644 feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitScreen.kt create mode 100644 feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitViewModel.kt diff --git a/.gitignore b/.gitignore index d2c94c0..df2577b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ out/ # Agent skills(安装产生的陈旧副本,规范源见 .agents/skills + skills-lock.json) agent/ + +# 临时文件(截图、日志等本地草稿) +tmp/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a5e0bd..9250c17 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/mingyuan/android_client/AppNavHost.kt b/app/src/main/java/com/mingyuan/android_client/AppNavHost.kt index 06e5798..864a9c8 100644 --- a/app/src/main/java/com/mingyuan/android_client/AppNavHost.kt +++ b/app/src/main/java/com/mingyuan/android_client/AppNavHost.kt @@ -1,41 +1,126 @@ 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 com.mingyuan.android_client.feature.auth.AUTH_ROUTE -import com.mingyuan.android_client.feature.auth.AuthScreen +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.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) }, + ) + } + composable(CHECKIN_ROUTE) { CheckinScreen() } + composable(PLAN_ROUTE) { + PlanScreen( + onNavigateToPlanSubmit = { type, periodKey -> + navController.navigate(planSubmitRoute(type, periodKey)) + }, + ) + } + composable( + route = PLAN_SUBMIT_ROUTE, + arguments = listOf( + navArgument("planType") { type = NavType.StringType }, + navArgument("periodKey") { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { + PlanSubmitScreen(onBack = { navController.popBackStack() }) + } + composable(PROFILE_ROUTE) { ProfileScreen() } + } + } +} + +@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, + ), + ) + } } } diff --git a/app/src/main/java/com/mingyuan/android_client/MainActivity.kt b/app/src/main/java/com/mingyuan/android_client/MainActivity.kt index 8c023a7..9cecb90 100644 --- a/app/src/main/java/com/mingyuan/android_client/MainActivity.kt +++ b/app/src/main/java/com/mingyuan/android_client/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/mingyuan/android_client/TopLevelDestination.kt b/app/src/main/java/com/mingyuan/android_client/TopLevelDestination.kt new file mode 100644 index 0000000..3094286 --- /dev/null +++ b/app/src/main/java/com/mingyuan/android_client/TopLevelDestination.kt @@ -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), +} diff --git a/build-logic/convention/src/main/kotlin/RootPlugin.kt b/build-logic/convention/src/main/kotlin/RootPlugin.kt index 8ecc872..e3eb4f8 100644 --- a/build-logic/convention/src/main/kotlin/RootPlugin.kt +++ b/build-logic/convention/src/main/kotlin/RootPlugin.kt @@ -25,8 +25,8 @@ class RootPlugin : Plugin { ":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 { ":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, diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 341bdcb..df5cb04 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -13,4 +13,6 @@ dependencies { implementation(project(":core:network")) implementation(project(":core:database")) implementation(project(":core:datastore")) + + implementation(libs.kotlinx.coroutines.core) } diff --git a/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/di/DataModule.kt new file mode 100644 index 0000000..319a5bc --- /dev/null +++ b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/di/DataModule.kt @@ -0,0 +1,36 @@ +package com.mingyuan.android_client.core.data.di + +import com.mingyuan.android_client.core.data.repository.FakeCheckinRepository +import com.mingyuan.android_client.core.data.repository.FakeHomeRepository +import com.mingyuan.android_client.core.data.repository.FakePlanRepository +import com.mingyuan.android_client.core.data.repository.FakeProfileRepository +import com.mingyuan.android_client.core.domain.repository.CheckinRepository +import com.mingyuan.android_client.core.domain.repository.HomeRepository +import com.mingyuan.android_client.core.domain.repository.PlanRepository +import com.mingyuan.android_client.core.domain.repository.ProfileRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + + @Binds + @Singleton + abstract fun bindHomeRepository(impl: FakeHomeRepository): HomeRepository + + @Binds + @Singleton + abstract fun bindCheckinRepository(impl: FakeCheckinRepository): CheckinRepository + + @Binds + @Singleton + abstract fun bindPlanRepository(impl: FakePlanRepository): PlanRepository + + @Binds + @Singleton + abstract fun bindProfileRepository(impl: FakeProfileRepository): ProfileRepository +} diff --git a/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeCheckinRepository.kt b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeCheckinRepository.kt new file mode 100644 index 0000000..60085c6 --- /dev/null +++ b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeCheckinRepository.kt @@ -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 = 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"), + ), + ) +} diff --git a/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeHomeRepository.kt b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeHomeRepository.kt new file mode 100644 index 0000000..6bcccff --- /dev/null +++ b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeHomeRepository.kt @@ -0,0 +1,49 @@ +package com.mingyuan.android_client.core.data.repository + +import com.mingyuan.android_client.core.domain.repository.HomeRepository +import com.mingyuan.android_client.core.model.HomeData +import com.mingyuan.android_client.core.model.HomeModule +import com.mingyuan.android_client.core.model.IntroCard +import com.mingyuan.android_client.core.model.ModuleDestination +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FakeHomeRepository @Inject constructor() : HomeRepository { + override fun observeHome(): Flow = flow { + delay(250) + emit(seedHome()) + } + + private fun seedHome() = HomeData( + greeting = "晚上好", + displayName = "张三", + encouragement = "坚持每周打卡,与小组共同进步。", + modules = listOf( + HomeModule("checkin", "📋", "打卡介绍", "每日打卡记录", ModuleDestination.CHECKIN), + HomeModule("plan", "📝", "计划介绍", "周/月/年计划", ModuleDestination.PLAN), + HomeModule("about", "ℹ️", "关于我们", "了解明缘云道场", ModuleDestination.ABOUT), + ), + intros = listOf( + IntroCard( + "📚 学习成长工具", + "明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯,实现持续进步。好好学习,天天向上。", + ), + IntroCard( + "✅ 周打卡", + "每周打卡,支持多种状态记录(正常、迟到、请假、早退、不参加讨论等)。查看小组打卡情况,与同学们互相监督、共同进步。", + ), + IntroCard( + "📝 计划管理", + "提供周计划、月计划、年计划三级管理,支持文字与图片上传。清晰追踪每个周期的目标完成情况,让成长有迹可循。", + ), + IntroCard( + "👥 小组共学", + "加入学习小组,查看组员的打卡与计划完成情况。在集体的氛围中保持学习动力,与志同道合的伙伴一起成长。", + ), + ), + ) +} diff --git a/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakePlanRepository.kt b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakePlanRepository.kt new file mode 100644 index 0000000..d50fa5d --- /dev/null +++ b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakePlanRepository.kt @@ -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>() + + override fun observePlan(type: PlanType): Flow = flow { + delay(250) + emitAll(plans.getValue(type)) + } + + override fun observePlanSubmit(type: PlanType, periodKey: String): Flow = flow { + delay(250) + emitAll(submitState(type, periodKey)) + } + + override suspend fun savePlan( + type: PlanType, + periodKey: String, + content: String, + attachments: List, + ) { + 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 = + 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), + ) +} diff --git a/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeProfileRepository.kt b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeProfileRepository.kt new file mode 100644 index 0000000..242d24a --- /dev/null +++ b/core/data/src/main/kotlin/com/mingyuan/android_client/core/data/repository/FakeProfileRepository.kt @@ -0,0 +1,51 @@ +package com.mingyuan.android_client.core.data.repository + +import com.mingyuan.android_client.core.domain.repository.ProfileRepository +import com.mingyuan.android_client.core.model.ClassGroup +import com.mingyuan.android_client.core.model.UserProfile +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FakeProfileRepository @Inject constructor() : ProfileRepository { + + private val profile = MutableStateFlow(seedProfile()) + + override fun observeProfile(): Flow = flow { + delay(250) + emitAll(profile) + } + + override suspend fun deleteAccount() { + delay(300) + profile.value = seedProfile().copy( + displayName = "游客", + name = "", + studentId = "", + phone = null, + group = null, + avatarUrl = null, + classGroups = emptyList(), + ) + } + + private fun seedProfile() = UserProfile( + displayName = "张三", + name = "张三", + studentId = "20260001", + phone = "138****1234", + role = "成员", + group = "进阶一组", + loginType = "手机号", + avatarUrl = null, + classGroups = listOf( + ClassGroup("2026 秋季", "进阶一组", "成员"), + ClassGroup("2026 春季", "进阶二组", "组长"), + ), + ) +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/AmberProgressBar.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/AmberProgressBar.kt new file mode 100644 index 0000000..71fc513 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/AmberProgressBar.kt @@ -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)), + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Avatar.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Avatar.kt new file mode 100644 index 0000000..ac05574 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Avatar.kt @@ -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, + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/LabeledRow.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/LabeledRow.kt new file mode 100644 index 0000000..18ab466 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/LabeledRow.kt @@ -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), + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/MingyuanButton.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/MingyuanButton.kt new file mode 100644 index 0000000..f7f7902 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/MingyuanButton.kt @@ -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, + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SectionCard.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SectionCard.kt new file mode 100644 index 0000000..cf0f169 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SectionCard.kt @@ -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, + ) +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SegmentedControl.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SegmentedControl.kt new file mode 100644 index 0000000..ea66398 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/SegmentedControl.kt @@ -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, + 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, + ) + } + } + } +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Skeleton.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Skeleton.kt new file mode 100644 index 0000000..68c0fe6 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/Skeleton.kt @@ -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)), + ) +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/StateViews.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/StateViews.kt new file mode 100644 index 0000000..227966b --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/component/StateViews.kt @@ -0,0 +1,67 @@ +package com.mingyuan.android_client.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mingyuan.android_client.core.designsystem.theme.Danger +import com.mingyuan.android_client.core.designsystem.theme.ErrorBackground +import com.mingyuan.android_client.core.designsystem.theme.FaintText +import com.mingyuan.android_client.core.designsystem.theme.MutedText +import com.mingyuan.android_client.core.designsystem.theme.NeutralChip + +@Composable +fun EmptyState( + emoji: String, + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier.size(72.dp).background(NeutralChip, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text(emoji, fontSize = 32.sp) + } + Spacer(Modifier.height(12.dp)) + Text(message, color = FaintText, fontSize = 14.sp) + } +} + +@Composable +fun ErrorState( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier.size(72.dp).background(ErrorBackground, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text("!", color = Danger, fontSize = 32.sp, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(12.dp)) + Text(message, color = MutedText, fontSize = 14.sp) + Spacer(Modifier.height(16.dp)) + MingyuanButton(text = "重新加载", onClick = onRetry) + } +} diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Color.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Color.kt index 97fe81e..59fce39 100644 --- a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Color.kt @@ -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) diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Gradients.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Gradients.kt new file mode 100644 index 0000000..e937153 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Gradients.kt @@ -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), +) diff --git a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Theme.kt index 8851eb8..2cf3113 100644 --- a/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/kotlin/com/mingyuan/android_client/core/designsystem/theme/Theme.kt @@ -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 +import androidx.compose.ui.graphics.Color -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, +private val LightColorScheme = lightColorScheme( + 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 LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40, +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, ) diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 4b59c5d..5aacd02 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -3,6 +3,7 @@ plugins { } dependencies { + implementation(project(":core:model")) implementation(libs.kotlinx.coroutines.core) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.truth) diff --git a/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/CheckinRepository.kt b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/CheckinRepository.kt new file mode 100644 index 0000000..8291cb5 --- /dev/null +++ b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/CheckinRepository.kt @@ -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 + + suspend fun submitCheckin(status: CheckinStatus) +} diff --git a/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/HomeRepository.kt b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/HomeRepository.kt new file mode 100644 index 0000000..d190442 --- /dev/null +++ b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/HomeRepository.kt @@ -0,0 +1,8 @@ +package com.mingyuan.android_client.core.domain.repository + +import com.mingyuan.android_client.core.model.HomeData +import kotlinx.coroutines.flow.Flow + +interface HomeRepository { + fun observeHome(): Flow +} diff --git a/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/PlanRepository.kt b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/PlanRepository.kt new file mode 100644 index 0000000..67a251a --- /dev/null +++ b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/PlanRepository.kt @@ -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 + + fun observePlanSubmit(type: PlanType, periodKey: String): Flow + + suspend fun savePlan( + type: PlanType, + periodKey: String, + content: String, + attachments: List, + ) +} diff --git a/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/ProfileRepository.kt b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/ProfileRepository.kt new file mode 100644 index 0000000..d31427a --- /dev/null +++ b/core/domain/src/main/kotlin/com/mingyuan/android_client/core/domain/repository/ProfileRepository.kt @@ -0,0 +1,10 @@ +package com.mingyuan.android_client.core.domain.repository + +import com.mingyuan.android_client.core.model.UserProfile +import kotlinx.coroutines.flow.Flow + +interface ProfileRepository { + fun observeProfile(): Flow + + suspend fun deleteAccount() +} diff --git a/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/CheckinModels.kt b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/CheckinModels.kt new file mode 100644 index 0000000..0ff1b25 --- /dev/null +++ b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/CheckinModels.kt @@ -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, + val personalHistory: List, +) diff --git a/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/HomeModels.kt b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/HomeModels.kt new file mode 100644 index 0000000..178844e --- /dev/null +++ b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/HomeModels.kt @@ -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, + val intros: List, +) diff --git a/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/PlanModels.kt b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/PlanModels.kt new file mode 100644 index 0000000..4291e21 --- /dev/null +++ b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/PlanModels.kt @@ -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, +) + +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, +) + +data class PlanRecord( + val type: PlanType, + val periodKey: String, + val canSubmit: Boolean, + val message: String?, + val myChecked: Boolean, + val myRecord: MyPlanRecord?, + val groups: List, + val unassignedCount: Int, +) + +data class PlanSubmitData( + val content: String, + val attachments: List, + val checked: Boolean, + val canSubmit: Boolean, + val message: String?, + val history: List, +) diff --git a/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/ProfileModels.kt b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/ProfileModels.kt new file mode 100644 index 0000000..7976864 --- /dev/null +++ b/core/model/src/main/kotlin/com/mingyuan/android_client/core/model/ProfileModels.kt @@ -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, +) diff --git a/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusBadge.kt b/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusBadge.kt new file mode 100644 index 0000000..8641a81 --- /dev/null +++ b/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusBadge.kt @@ -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) + } +} diff --git a/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusColor.kt b/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusColor.kt new file mode 100644 index 0000000..d4fc9f2 --- /dev/null +++ b/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusColor.kt @@ -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 +} diff --git a/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusPill.kt b/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusPill.kt new file mode 100644 index 0000000..6b57503 --- /dev/null +++ b/core/ui/src/main/kotlin/com/mingyuan/android_client/core/ui/status/StatusPill.kt @@ -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) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index b74df64..bec319d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) | ## 分层与依赖方向 diff --git a/feature/checkin/build.gradle.kts b/feature/checkin/build.gradle.kts index 6a4620a..b93851a 100644 --- a/feature/checkin/build.gradle.kts +++ b/feature/checkin/build.gradle.kts @@ -5,3 +5,7 @@ plugins { android { namespace = "com.mingyuan.android_client.feature.checkin" } + +dependencies { + implementation(libs.kotlinx.coroutines.core) +} diff --git a/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinScreen.kt b/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinScreen.kt index 43e26b7..58b5a4f 100644 --- a/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinScreen.kt +++ b/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinScreen.kt @@ -1,18 +1,301 @@ 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.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.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( + modifier: Modifier = Modifier, + viewModel: CheckinViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val selectedStatus by viewModel.selectedStatus.collectAsStateWithLifecycle() + val submitting by viewModel.submitting.collectAsStateWithLifecycle() + + when (val state = uiState) { + is CheckinUiState.Loading -> CheckinSkeleton(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) { + 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) + } } } diff --git a/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinViewModel.kt b/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinViewModel.kt index ec6ab51..ba32800 100644 --- a/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinViewModel.kt +++ b/feature/checkin/src/main/kotlin/com/mingyuan/android_client/feature/checkin/CheckinViewModel.kt @@ -1,8 +1,55 @@ 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.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.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 class Error(val message: String) : CheckinUiState +} + @HiltViewModel -class CheckinViewModel @Inject constructor() : ViewModel() +class CheckinViewModel @Inject constructor( + private val checkinRepository: CheckinRepository, +) : ViewModel() { + + private val _selectedStatus = MutableStateFlow(CheckinStatus.NORMAL) + val selectedStatus: StateFlow = _selectedStatus.asStateFlow() + + private val _submitting = MutableStateFlow(false) + val submitting: StateFlow = _submitting.asStateFlow() + + val uiState: StateFlow = checkinRepository.observeCheckin() + .map(CheckinUiState::Success) + .onStart { emit(CheckinUiState.Loading) } + .catch { emit(CheckinUiState.Error(it.message ?: "加载失败,请稍后再试")) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = CheckinUiState.Loading, + ) + + fun submit(status: CheckinStatus) { + _selectedStatus.value = status + viewModelScope.launch { + _submitting.value = true + checkinRepository.submitCheckin(status) + _submitting.value = false + } + } +} diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts new file mode 100644 index 0000000..97032f4 --- /dev/null +++ b/feature/home/build.gradle.kts @@ -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) +} diff --git a/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/feature/home/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeScreen.kt b/feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeScreen.kt new file mode 100644 index 0000000..90ee75e --- /dev/null +++ b/feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeScreen.kt @@ -0,0 +1,216 @@ +package com.mingyuan.android_client.feature.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mingyuan.android_client.core.designsystem.component.ErrorState +import com.mingyuan.android_client.core.designsystem.component.SectionCard +import com.mingyuan.android_client.core.designsystem.component.SkeletonBox +import com.mingyuan.android_client.core.designsystem.theme.BrandAmber +import com.mingyuan.android_client.core.designsystem.theme.HeroGradient +import com.mingyuan.android_client.core.designsystem.theme.LightAmber +import com.mingyuan.android_client.core.designsystem.theme.MutedText +import com.mingyuan.android_client.core.designsystem.theme.NeutralChip +import com.mingyuan.android_client.core.designsystem.theme.PageBackground +import com.mingyuan.android_client.core.designsystem.theme.PrimaryText +import com.mingyuan.android_client.core.designsystem.theme.SecondaryText +import com.mingyuan.android_client.core.model.HomeData +import com.mingyuan.android_client.core.model.HomeModule +import com.mingyuan.android_client.core.model.IntroCard +import com.mingyuan.android_client.core.model.ModuleDestination + +/** Navigation route for this feature, referenced by the app-level NavHost. */ +const val HOME_ROUTE = "home" + +@Composable +fun HomeScreen( + onNavigateToCheckin: () -> Unit, + onNavigateToPlan: () -> Unit, + modifier: Modifier = Modifier, + viewModel: HomeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + is HomeUiState.Loading -> HomeSkeleton(modifier) + is HomeUiState.Error -> ErrorState( + message = state.message, + onRetry = {}, + modifier = modifier.padding(16.dp), + ) + + is HomeUiState.Success -> HomeContent( + data = state.data, + onModuleClick = { module -> + when (module.destination) { + ModuleDestination.CHECKIN -> onNavigateToCheckin() + ModuleDestination.PLAN -> onNavigateToPlan() + ModuleDestination.ABOUT -> Unit + } + }, + modifier = modifier, + ) + } +} + +@Composable +private fun HomeSkeleton(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SkeletonBox(Modifier.fillMaxWidth().height(140.dp), cornerRadius = 24) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(3) { + SkeletonBox(Modifier.weight(1f).height(104.dp), cornerRadius = 22) + } + } + } +} + +@Composable +private fun HomeContent( + data: HomeData, + onModuleClick: (HomeModule) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize().background(PageBackground), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { HeroCard(data) } + item { ModuleGrid(data.modules, onModuleClick) } + item { IntroSection(data.intros) } + } +} + +@Composable +private fun HeroCard(data: HomeData) { + Column( + modifier = Modifier + .fillMaxWidth() + .shadow( + elevation = 6.dp, + shape = RoundedCornerShape(24.dp), + spotColor = Color(0x2E2C1810), + ambientColor = Color(0x2E2C1810), + ) + .background(HeroGradient, RoundedCornerShape(24.dp)) + .padding(horizontal = 20.dp, vertical = 24.dp), + ) { + Box( + modifier = Modifier + .background(Color(0x2EC67B30), RoundedCornerShape(50)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text(data.greeting, color = LightAmber, fontSize = 11.sp) + } + Spacer(Modifier.height(12.dp)) + Text( + text = data.displayName, + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(6.dp)) + Text(data.encouragement, color = Color(0xFFE8DCCB), fontSize = 13.sp) + } +} + +@Composable +private fun ModuleGrid( + modules: List, + 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) { + 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) + } +} diff --git a/feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeViewModel.kt b/feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..b604664 --- /dev/null +++ b/feature/home/src/main/kotlin/com/mingyuan/android_client/feature/home/HomeViewModel.kt @@ -0,0 +1,36 @@ +package com.mingyuan.android_client.feature.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mingyuan.android_client.core.domain.repository.HomeRepository +import com.mingyuan.android_client.core.model.HomeData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +sealed interface HomeUiState { + data object Loading : HomeUiState + data class Success(val data: HomeData) : HomeUiState + data class Error(val message: String) : HomeUiState +} + +@HiltViewModel +class HomeViewModel @Inject constructor( + homeRepository: HomeRepository, +) : ViewModel() { + + val uiState: StateFlow = homeRepository.observeHome() + .map(HomeUiState::Success) + .onStart { emit(HomeUiState.Loading) } + .catch { emit(HomeUiState.Error(it.message ?: "加载失败,请稍后再试")) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = HomeUiState.Loading, + ) +} diff --git a/feature/plan/build.gradle.kts b/feature/plan/build.gradle.kts index 7d8af3c..696b53d 100644 --- a/feature/plan/build.gradle.kts +++ b/feature/plan/build.gradle.kts @@ -5,3 +5,7 @@ plugins { android { namespace = "com.mingyuan.android_client.feature.plan" } + +dependencies { + implementation(libs.kotlinx.coroutines.core) +} diff --git a/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanScreen.kt b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanScreen.kt index e1e92cf..a2cb346 100644 --- a/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanScreen.kt +++ b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanScreen.kt @@ -1,18 +1,380 @@ 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.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.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, + modifier: Modifier = Modifier, + viewModel: PlanViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() + + 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, + ) + } + } +} + +@Composable +private fun PlanContent( + record: PlanRecord, + selectedType: PlanType, + onNavigateToPlanSubmit: (PlanType, String) -> Unit, +) { + var selectedGroup by remember { mutableStateOf(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) } } } diff --git a/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitScreen.kt b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitScreen.kt new file mode 100644 index 0000000..dd58912 --- /dev/null +++ b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitScreen.kt @@ -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) { + 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) + } +} diff --git a/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitViewModel.kt b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitViewModel.kt new file mode 100644 index 0000000..557cab5 --- /dev/null +++ b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanSubmitViewModel.kt @@ -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("planType") + ?.let { runCatching { PlanType.valueOf(it) }.getOrNull() } + ?: PlanType.WEEKLY + + val periodKey: String = savedStateHandle.get("periodKey").orEmpty() + + private val _saving = MutableStateFlow(false) + val saving: StateFlow = _saving.asStateFlow() + + private val _saved = MutableStateFlow(false) + val saved: StateFlow = _saved.asStateFlow() + + val uiState: StateFlow = planRepository.observePlanSubmit(planType, periodKey) + .map(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 + } + } +} diff --git a/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanViewModel.kt b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanViewModel.kt index 5a6ad24..d44b315 100644 --- a/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanViewModel.kt +++ b/feature/plan/src/main/kotlin/com/mingyuan/android_client/feature/plan/PlanViewModel.kt @@ -1,8 +1,48 @@ 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.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.flatMapLatest +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 class Error(val message: String) : PlanUiState +} + @HiltViewModel -class PlanViewModel @Inject constructor() : ViewModel() +class PlanViewModel @Inject constructor( + private val planRepository: PlanRepository, +) : ViewModel() { + + private val _selectedType = MutableStateFlow(PlanType.WEEKLY) + val selectedType: StateFlow = _selectedType.asStateFlow() + + val uiState: StateFlow = _selectedType + .flatMapLatest { type -> planRepository.observePlan(type) } + .map(PlanUiState::Success) + .onStart { emit(PlanUiState.Loading) } + .catch { emit(PlanUiState.Error(it.message ?: "加载失败,请稍后再试")) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PlanUiState.Loading, + ) + + fun selectType(type: PlanType) { + _selectedType.value = type + } +} diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index d1123e0..0ce9444 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -5,3 +5,7 @@ plugins { android { namespace = "com.mingyuan.android_client.feature.profile" } + +dependencies { + implementation(libs.kotlinx.coroutines.core) +} diff --git a/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileScreen.kt index 262ffb0..3a9bb87 100644 --- a/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileScreen.kt @@ -1,18 +1,216 @@ 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.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.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( + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showDeleteDialog by remember { mutableStateOf(false) } + + when (val state = uiState) { + is ProfileUiState.Loading -> ProfileSkeleton(modifier) + is ProfileUiState.Error -> ErrorState( + message = state.message, + onRetry = {}, + modifier = modifier.padding(16.dp), + ) + + is ProfileUiState.Success -> ProfileContent( + profile = state.profile, + onDelete = { showDeleteDialog = true }, + modifier = modifier, + ) + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("注销账号") }, + text = { Text("确定要注销账号吗?此操作不可恢复。") }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + viewModel.deleteAccount() + }, + ) { + Text("确定", color = Danger) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("取消") } + }, + ) + } +} + +@Composable +private fun ProfileContent( + profile: UserProfile, + onDelete: () -> 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 = onDelete, + 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) { + 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) } } diff --git a/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileViewModel.kt index a755662..f226b3a 100644 --- a/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/com/mingyuan/android_client/feature/profile/ProfileViewModel.kt @@ -1,8 +1,43 @@ 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.model.UserProfile import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject +sealed interface ProfileUiState { + data object Loading : ProfileUiState + data class Success(val profile: UserProfile) : ProfileUiState + data class Error(val message: String) : ProfileUiState +} + @HiltViewModel -class ProfileViewModel @Inject constructor() : ViewModel() +class ProfileViewModel @Inject constructor( + private val profileRepository: ProfileRepository, +) : ViewModel() { + + val uiState: StateFlow = profileRepository.observeProfile() + .map(ProfileUiState::Success) + .onStart { emit(ProfileUiState.Loading) } + .catch { emit(ProfileUiState.Error(it.message ?: "加载失败,请稍后再试")) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ProfileUiState.Loading, + ) + + fun deleteAccount() { + viewModelScope.launch { + profileRepository.deleteAccount() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8dc9284..cbf6eca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8c8e641..25436b6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")