feat: 复刻小程序四大主页面与底部导航(UI 优先,本地 mock)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ out/
|
||||
|
||||
# Agent skills(安装产生的陈旧副本,规范源见 .agents/skills + skills-lock.json)
|
||||
agent/
|
||||
|
||||
# 临时文件(截图、日志等本地草稿)
|
||||
tmp/
|
||||
|
||||
@@ -15,9 +15,11 @@ android {
|
||||
dependencies {
|
||||
implementation(project(":core:designsystem"))
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
|
||||
implementation(project(":feature:auth"))
|
||||
implementation(project(":feature:home"))
|
||||
implementation(project(":feature:checkin"))
|
||||
implementation(project(":feature:plan"))
|
||||
implementation(project(":feature:course"))
|
||||
@@ -25,6 +27,7 @@ dependencies {
|
||||
implementation(project(":feature:feedback"))
|
||||
implementation(project(":feature:profile"))
|
||||
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
|
||||
@@ -1,41 +1,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()
|
||||
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 = CHECKIN_ROUTE,
|
||||
startDestination = HOME_ROUTE,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
) {
|
||||
composable(AUTH_ROUTE) { AuthScreen() }
|
||||
composable(HOME_ROUTE) {
|
||||
HomeScreen(
|
||||
onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) },
|
||||
onNavigateToPlan = { navigateToTab(PLAN_ROUTE) },
|
||||
)
|
||||
}
|
||||
composable(CHECKIN_ROUTE) { CheckinScreen() }
|
||||
composable(PLAN_ROUTE) { PlanScreen() }
|
||||
composable(COURSE_ROUTE) { CourseScreen() }
|
||||
composable(SURVEY_ROUTE) { SurveyScreen() }
|
||||
composable(FEEDBACK_ROUTE) { FeedbackScreen() }
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.mingyuan.android_client.core.designsystem.theme.MingyuanTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.mingyuan.android_client
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
|
||||
import com.mingyuan.android_client.feature.home.HOME_ROUTE
|
||||
import com.mingyuan.android_client.feature.plan.PLAN_ROUTE
|
||||
import com.mingyuan.android_client.feature.profile.PROFILE_ROUTE
|
||||
|
||||
/** The four top-level tabs shown in the bottom navigation bar. */
|
||||
enum class TopLevelDestination(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
HOME(HOME_ROUTE, "首页", Icons.Filled.Home),
|
||||
CHECKIN(CHECKIN_ROUTE, "打卡", Icons.Filled.CheckCircle),
|
||||
PLAN(PLAN_ROUTE, "计划", Icons.Filled.DateRange),
|
||||
PROFILE(PROFILE_ROUTE, "我的", Icons.Filled.Person),
|
||||
}
|
||||
@@ -25,8 +25,8 @@ class RootPlugin : Plugin<Project> {
|
||||
":app" to setOf(
|
||||
":core:common", ":core:model", ":core:domain", ":core:designsystem",
|
||||
":core:ui", ":core:data", ":core:network", ":core:database", ":core:datastore",
|
||||
":feature:auth", ":feature:checkin", ":feature:plan", ":feature:course",
|
||||
":feature:survey", ":feature:feedback", ":feature:profile",
|
||||
":feature:auth", ":feature:home", ":feature:checkin", ":feature:plan",
|
||||
":feature:course", ":feature:survey", ":feature:feedback", ":feature:profile",
|
||||
),
|
||||
":core:common" to emptySet(),
|
||||
":core:model" to emptySet(),
|
||||
@@ -40,6 +40,7 @@ class RootPlugin : Plugin<Project> {
|
||||
":core:domain", ":core:model", ":core:network", ":core:database", ":core:datastore",
|
||||
),
|
||||
":feature:auth" to featureAllowed,
|
||||
":feature:home" to featureAllowed,
|
||||
":feature:checkin" to featureAllowed,
|
||||
":feature:plan" to featureAllowed,
|
||||
":feature:course" to featureAllowed,
|
||||
|
||||
@@ -13,4 +13,6 @@ dependencies {
|
||||
implementation(project(":core:network"))
|
||||
implementation(project(":core:database"))
|
||||
implementation(project(":core:datastore"))
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.mingyuan.android_client.core.data.repository
|
||||
|
||||
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
|
||||
import com.mingyuan.android_client.core.model.CheckinRecord
|
||||
import com.mingyuan.android_client.core.model.CheckinStatus
|
||||
import com.mingyuan.android_client.core.model.GroupMember
|
||||
import com.mingyuan.android_client.core.model.PersonalCheckinRecord
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FakeCheckinRepository @Inject constructor() : CheckinRepository {
|
||||
|
||||
private val record = MutableStateFlow(seedCheckin())
|
||||
|
||||
override fun observeCheckin(): Flow<CheckinRecord> = flow {
|
||||
delay(250)
|
||||
emitAll(record)
|
||||
}
|
||||
|
||||
override suspend fun submitCheckin(status: CheckinStatus) {
|
||||
delay(300)
|
||||
record.update { current ->
|
||||
current.copy(
|
||||
checked = true,
|
||||
status = status,
|
||||
groupMembers = current.groupMembers.map { member ->
|
||||
if (member.id == "me") {
|
||||
member.copy(checkedIn = true, checkinTime = "现在", status = status)
|
||||
} else {
|
||||
member
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedCheckin() = CheckinRecord(
|
||||
classDate = "2026-09-09",
|
||||
weekday = "周三",
|
||||
dayNum = 9,
|
||||
checked = false,
|
||||
status = null,
|
||||
groupName = "进阶一组",
|
||||
groupMembers = listOf(
|
||||
GroupMember("m1", "张三", true, "19:02", CheckinStatus.NORMAL),
|
||||
GroupMember("m2", "李四", true, "19:30", CheckinStatus.LATE),
|
||||
GroupMember("me", "我", false, null, null),
|
||||
GroupMember("m3", "王五", true, "09:10", CheckinStatus.LEAVE),
|
||||
GroupMember("m4", "赵六", false, null, null),
|
||||
),
|
||||
personalHistory = listOf(
|
||||
PersonalCheckinRecord("2026-09-06", CheckinStatus.NORMAL, "19:05"),
|
||||
PersonalCheckinRecord("2026-09-02", CheckinStatus.LATE, "19:32"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.mingyuan.android_client.core.data.repository
|
||||
|
||||
import com.mingyuan.android_client.core.domain.repository.HomeRepository
|
||||
import com.mingyuan.android_client.core.model.HomeData
|
||||
import com.mingyuan.android_client.core.model.HomeModule
|
||||
import com.mingyuan.android_client.core.model.IntroCard
|
||||
import com.mingyuan.android_client.core.model.ModuleDestination
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FakeHomeRepository @Inject constructor() : HomeRepository {
|
||||
override fun observeHome(): Flow<HomeData> = flow {
|
||||
delay(250)
|
||||
emit(seedHome())
|
||||
}
|
||||
|
||||
private fun seedHome() = HomeData(
|
||||
greeting = "晚上好",
|
||||
displayName = "张三",
|
||||
encouragement = "坚持每周打卡,与小组共同进步。",
|
||||
modules = listOf(
|
||||
HomeModule("checkin", "📋", "打卡介绍", "每日打卡记录", ModuleDestination.CHECKIN),
|
||||
HomeModule("plan", "📝", "计划介绍", "周/月/年计划", ModuleDestination.PLAN),
|
||||
HomeModule("about", "ℹ️", "关于我们", "了解明缘云道场", ModuleDestination.ABOUT),
|
||||
),
|
||||
intros = listOf(
|
||||
IntroCard(
|
||||
"📚 学习成长工具",
|
||||
"明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯,实现持续进步。好好学习,天天向上。",
|
||||
),
|
||||
IntroCard(
|
||||
"✅ 周打卡",
|
||||
"每周打卡,支持多种状态记录(正常、迟到、请假、早退、不参加讨论等)。查看小组打卡情况,与同学们互相监督、共同进步。",
|
||||
),
|
||||
IntroCard(
|
||||
"📝 计划管理",
|
||||
"提供周计划、月计划、年计划三级管理,支持文字与图片上传。清晰追踪每个周期的目标完成情况,让成长有迹可循。",
|
||||
),
|
||||
IntroCard(
|
||||
"👥 小组共学",
|
||||
"加入学习小组,查看组员的打卡与计划完成情况。在集体的氛围中保持学习动力,与志同道合的伙伴一起成长。",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.mingyuan.android_client.core.data.repository
|
||||
|
||||
import com.mingyuan.android_client.core.domain.repository.PlanRepository
|
||||
import com.mingyuan.android_client.core.model.MyPlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanGroup
|
||||
import com.mingyuan.android_client.core.model.PlanMember
|
||||
import com.mingyuan.android_client.core.model.PlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanSubmitData
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FakePlanRepository @Inject constructor() : PlanRepository {
|
||||
|
||||
private val plans = PlanType.entries.associateWith { type -> MutableStateFlow(seedPlan(type)) }
|
||||
private val submits = mutableMapOf<String, MutableStateFlow<PlanSubmitData>>()
|
||||
|
||||
override fun observePlan(type: PlanType): Flow<PlanRecord> = flow {
|
||||
delay(250)
|
||||
emitAll(plans.getValue(type))
|
||||
}
|
||||
|
||||
override fun observePlanSubmit(type: PlanType, periodKey: String): Flow<PlanSubmitData> = flow {
|
||||
delay(250)
|
||||
emitAll(submitState(type, periodKey))
|
||||
}
|
||||
|
||||
override suspend fun savePlan(
|
||||
type: PlanType,
|
||||
periodKey: String,
|
||||
content: String,
|
||||
attachments: List<String>,
|
||||
) {
|
||||
delay(300)
|
||||
val submit = submitState(type, periodKey)
|
||||
submit.value = submit.value.copy(content = content, attachments = attachments, checked = true)
|
||||
val plan = plans.getValue(type)
|
||||
plan.value = plan.value.copy(
|
||||
myChecked = true,
|
||||
myRecord = MyPlanRecord("已完成", "现在", content, attachments),
|
||||
)
|
||||
}
|
||||
|
||||
private fun submitState(type: PlanType, periodKey: String): MutableStateFlow<PlanSubmitData> =
|
||||
submits.getOrPut("${type.name}:$periodKey") { MutableStateFlow(seedSubmit(type)) }
|
||||
|
||||
private fun seedPlan(type: PlanType) = PlanRecord(
|
||||
type = type,
|
||||
periodKey = periodKeyFor(type),
|
||||
canSubmit = true,
|
||||
message = if (type == PlanType.WEEKLY) "本周计划提交截止:周日晚 23:59" else null,
|
||||
myChecked = type == PlanType.WEEKLY,
|
||||
myRecord = if (type == PlanType.WEEKLY) {
|
||||
MyPlanRecord("已完成", "今天 18:30", "本周完成两章学习,整理笔记并复习错题。", listOf("图1", "图2"))
|
||||
} else {
|
||||
null
|
||||
},
|
||||
groups = listOf(
|
||||
PlanGroup("g1", "进阶一组", 4, 6, memberSeed()),
|
||||
PlanGroup("g2", "进阶二组", 5, 5, memberSeed()),
|
||||
PlanGroup("g3", "进阶三组", 2, 7, memberSeed()),
|
||||
),
|
||||
unassignedCount = 2,
|
||||
)
|
||||
|
||||
private fun seedSubmit(type: PlanType) = PlanSubmitData(
|
||||
content = if (type == PlanType.WEEKLY) "本周完成两章学习,整理笔记并复习错题。" else "",
|
||||
attachments = if (type == PlanType.WEEKLY) listOf("图1", "图2") else emptyList(),
|
||||
checked = type == PlanType.WEEKLY,
|
||||
canSubmit = true,
|
||||
message = null,
|
||||
history = if (type == PlanType.WEEKLY) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(MyPlanRecord("已完成", "2026-08 完成", "上月计划内容……", emptyList()))
|
||||
},
|
||||
)
|
||||
|
||||
private fun periodKeyFor(type: PlanType): String = when (type) {
|
||||
PlanType.WEEKLY -> "2026 第 37 周"
|
||||
PlanType.MONTHLY -> "2026 年 9 月"
|
||||
PlanType.YEARLY -> "2026 年"
|
||||
}
|
||||
|
||||
private fun memberSeed() = listOf(
|
||||
PlanMember("p1", "张三", true),
|
||||
PlanMember("p2", "李四", true),
|
||||
PlanMember("p3", "王五", false),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.mingyuan.android_client.core.data.repository
|
||||
|
||||
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
|
||||
import com.mingyuan.android_client.core.model.ClassGroup
|
||||
import com.mingyuan.android_client.core.model.UserProfile
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FakeProfileRepository @Inject constructor() : ProfileRepository {
|
||||
|
||||
private val profile = MutableStateFlow(seedProfile())
|
||||
|
||||
override fun observeProfile(): Flow<UserProfile> = flow {
|
||||
delay(250)
|
||||
emitAll(profile)
|
||||
}
|
||||
|
||||
override suspend fun deleteAccount() {
|
||||
delay(300)
|
||||
profile.value = seedProfile().copy(
|
||||
displayName = "游客",
|
||||
name = "",
|
||||
studentId = "",
|
||||
phone = null,
|
||||
group = null,
|
||||
avatarUrl = null,
|
||||
classGroups = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun seedProfile() = UserProfile(
|
||||
displayName = "张三",
|
||||
name = "张三",
|
||||
studentId = "20260001",
|
||||
phone = "138****1234",
|
||||
role = "成员",
|
||||
group = "进阶一组",
|
||||
loginType = "手机号",
|
||||
avatarUrl = null,
|
||||
classGroups = listOf(
|
||||
ClassGroup("2026 秋季", "进阶一组", "成员"),
|
||||
ClassGroup("2026 春季", "进阶二组", "组长"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.mingyuan.android_client.core.designsystem.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.AmberGradient
|
||||
import com.mingyuan.android_client.core.designsystem.theme.Divider
|
||||
|
||||
@Composable
|
||||
fun AmberProgressBar(
|
||||
progress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val fraction = progress.coerceIn(0f, 1f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.background(Divider, RoundedCornerShape(4.dp)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(8.dp)
|
||||
.background(AmberGradient, RoundedCornerShape(4.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.mingyuan.android_client.core.designsystem.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
initial: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 40.dp,
|
||||
backgroundColor: Color = BrandAmber,
|
||||
textColor: Color = Color.White,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.size(size).background(backgroundColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initial.take(1),
|
||||
color = textColor,
|
||||
fontSize = (size.value * 0.42f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mingyuan.android_client.core.designsystem.component
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.MutedText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
|
||||
|
||||
@Composable
|
||||
fun LabeledRow(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
) {
|
||||
Text(label, color = MutedText, fontSize = 13.sp)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = value,
|
||||
color = PrimaryText,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.mingyuan.android_client.core.designsystem.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.AmberGradient
|
||||
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
|
||||
import com.mingyuan.android_client.core.designsystem.theme.Danger
|
||||
import com.mingyuan.android_client.core.designsystem.theme.Disabled
|
||||
|
||||
enum class ButtonVariant { PRIMARY, DANGER, OUTLINE }
|
||||
|
||||
@Composable
|
||||
fun MingyuanButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
variant: ButtonVariant = ButtonVariant.PRIMARY,
|
||||
enabled: Boolean = true,
|
||||
fullWidth: Boolean = false,
|
||||
) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
|
||||
val backgroundModifier = when {
|
||||
!enabled -> Modifier.background(Disabled, shape)
|
||||
variant == ButtonVariant.PRIMARY -> Modifier.background(AmberGradient, shape)
|
||||
else -> Modifier.background(Color.White, shape)
|
||||
}
|
||||
|
||||
val borderModifier = when {
|
||||
!enabled -> Modifier
|
||||
variant == ButtonVariant.DANGER -> Modifier.border(1.dp, Danger, shape)
|
||||
variant == ButtonVariant.OUTLINE -> Modifier.border(1.dp, BrandAmber, shape)
|
||||
else -> Modifier
|
||||
}
|
||||
|
||||
val textColor = when {
|
||||
!enabled -> Color.White
|
||||
variant == ButtonVariant.PRIMARY -> Color.White
|
||||
variant == ButtonVariant.DANGER -> Danger
|
||||
else -> BrandAmber
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(if (fullWidth) Modifier.fillMaxWidth() else Modifier)
|
||||
.clip(shape)
|
||||
.then(backgroundModifier)
|
||||
.then(borderModifier)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.mingyuan.android_client.core.designsystem.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SoftShadow
|
||||
|
||||
@Composable
|
||||
fun SectionCard(
|
||||
modifier: Modifier = Modifier,
|
||||
cornerRadius: Int = 16,
|
||||
backgroundColor: Color = Color.White,
|
||||
contentPadding: Int = 16,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = RoundedCornerShape(cornerRadius.dp),
|
||||
ambientColor = SoftShadow,
|
||||
spotColor = SoftShadow,
|
||||
)
|
||||
.background(backgroundColor, RoundedCornerShape(cornerRadius.dp))
|
||||
.padding(contentPadding.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.mingyuan.android_client.core.designsystem.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SegmentedTrack
|
||||
|
||||
@Composable
|
||||
fun SegmentedControl(
|
||||
labels: List<String>,
|
||||
selectedIndex: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(SegmentedTrack, RoundedCornerShape(50))
|
||||
.padding(4.dp),
|
||||
) {
|
||||
labels.forEachIndexed { index, label ->
|
||||
val selected = index == selectedIndex
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (selected) Color.White else Color.Transparent)
|
||||
.clickable { onSelect(index) }
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (selected) BrandAmber else SecondaryText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.mingyuan.android_client.core.designsystem.component
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SkeletonColor
|
||||
|
||||
@Composable
|
||||
fun SkeletonBox(
|
||||
modifier: Modifier = Modifier,
|
||||
cornerRadius: Int = 12,
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "skeleton")
|
||||
val alpha by transition.animateFloat(
|
||||
initialValue = 0.4f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 700, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "skeleton-alpha",
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer { this.alpha = alpha }
|
||||
.background(SkeletonColor, RoundedCornerShape(cornerRadius.dp)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,48 @@ package com.mingyuan.android_client.core.designsystem.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
// Brand palette — ported from the WeChat mini-program design tokens.
|
||||
val PageBackground = Color(0xFFFDFBF7)
|
||||
val PlanBackground = Color(0xFFF6F2EC)
|
||||
|
||||
val Purple40 = Color(0xFF6650A4)
|
||||
val PurpleGrey40 = Color(0xFF625B71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
val PrimaryText = Color(0xFF2C1810)
|
||||
val SecondaryText = Color(0xFF5A4A3A)
|
||||
val MutedText = Color(0xFF8B7A6A)
|
||||
val MutedLightText = Color(0xFFA99A89)
|
||||
val FaintText = Color(0xFFB6A996)
|
||||
|
||||
val BrandAmber = Color(0xFFC67B30)
|
||||
val LightAmber = Color(0xFFE3B37C)
|
||||
val AmberMid = Color(0xFFE4A05E)
|
||||
val AmberBright = Color(0xFFEFA45D)
|
||||
|
||||
val HeroGradientStart = Color(0xFF2C1810)
|
||||
val HeroGradientMid = Color(0xFF4A2B1D)
|
||||
val HeroGradientEnd = Color(0xFF6B3F24)
|
||||
|
||||
val StatusNormal = Color(0xFF2E7D32)
|
||||
val StatusLate = Color(0xFFC67B30)
|
||||
val StatusLeave = Color(0xFF3A7BD5)
|
||||
val StatusEarlyLeave = Color(0xFF8B7A6A)
|
||||
val StatusAbsent = Color(0xFFC0392B)
|
||||
|
||||
val Danger = Color(0xFFC0392B)
|
||||
val ErrorBackground = Color(0xFFFDEAEA)
|
||||
val SuccessBackground = Color(0xFFE8F4EB)
|
||||
val WarningBackground = Color(0xFFFFF3E6)
|
||||
val WarningBorder = Color(0xFFF7D9B9)
|
||||
|
||||
val NeutralChip = Color(0xFFF6EFE6)
|
||||
val SegmentedTrack = Color(0xFFEFE7DC)
|
||||
val Divider = Color(0xFFF2EDE7)
|
||||
val PillIdleBorder = Color(0xFFD8C7B4)
|
||||
val TextareaBorder = Color(0xFFE4D9CB)
|
||||
val Disabled = Color(0xFFD8C7B4)
|
||||
|
||||
val TabBackground = Color(0xFFFFFFFF)
|
||||
val TabInactive = Color(0xFF5A4A3A)
|
||||
val TabActive = Color(0xFFC67B30)
|
||||
|
||||
val SoftShadow = Color(0x0D2C1810)
|
||||
val SkeletonColor = Color(0xFFEFE6D9)
|
||||
val SkeletonHighlight = Color(0xFFFAF4EC)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.mingyuan.android_client.core.designsystem.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
||||
// Brand gradients (135deg, top-left → bottom-right, matching the mini-program).
|
||||
val HeroGradient = Brush.linearGradient(
|
||||
colors = listOf(HeroGradientStart, HeroGradientMid, HeroGradientEnd),
|
||||
)
|
||||
|
||||
val AmberGradient = Brush.linearGradient(
|
||||
colors = listOf(BrandAmber, AmberMid),
|
||||
)
|
||||
|
||||
val AvatarGradient = Brush.linearGradient(
|
||||
colors = listOf(BrandAmber, LightAmber),
|
||||
)
|
||||
@@ -1,49 +1,59 @@
|
||||
package com.mingyuan.android_client.core.designsystem.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
)
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
primary = BrandAmber,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = LightAmber,
|
||||
onPrimaryContainer = PrimaryText,
|
||||
secondary = AmberMid,
|
||||
onSecondary = Color.White,
|
||||
tertiary = StatusLeave,
|
||||
background = PageBackground,
|
||||
onBackground = PrimaryText,
|
||||
surface = Color.White,
|
||||
onSurface = PrimaryText,
|
||||
surfaceVariant = NeutralChip,
|
||||
onSurfaceVariant = MutedText,
|
||||
error = Danger,
|
||||
onError = Color.White,
|
||||
outline = Divider,
|
||||
outlineVariant = PillIdleBorder,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = LightAmber,
|
||||
onPrimary = PrimaryText,
|
||||
primaryContainer = HeroGradientMid,
|
||||
onPrimaryContainer = LightAmber,
|
||||
background = HeroGradientStart,
|
||||
onBackground = Color(0xFFFDFBF7),
|
||||
surface = Color(0xFF2A1E18),
|
||||
onSurface = Color(0xFFFDFBF7),
|
||||
surfaceVariant = Color(0xFF3A2A20),
|
||||
onSurfaceVariant = MutedLightText,
|
||||
error = Color(0xFFE5736C),
|
||||
)
|
||||
|
||||
/**
|
||||
* The app's single Material theme. All UI entry points wrap their content in [MingyuanTheme].
|
||||
*
|
||||
* Dynamic color is deliberately disabled so the brand palette always renders as designed,
|
||||
* regardless of the device's wallpaper-based Material You scheme.
|
||||
*/
|
||||
@Composable
|
||||
fun MingyuanTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+.
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.truth)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.mingyuan.android_client.core.domain.repository
|
||||
|
||||
import com.mingyuan.android_client.core.model.CheckinRecord
|
||||
import com.mingyuan.android_client.core.model.CheckinStatus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface CheckinRepository {
|
||||
fun observeCheckin(): Flow<CheckinRecord>
|
||||
|
||||
suspend fun submitCheckin(status: CheckinStatus)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mingyuan.android_client.core.domain.repository
|
||||
|
||||
import com.mingyuan.android_client.core.model.HomeData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface HomeRepository {
|
||||
fun observeHome(): Flow<HomeData>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mingyuan.android_client.core.domain.repository
|
||||
|
||||
import com.mingyuan.android_client.core.model.PlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanSubmitData
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PlanRepository {
|
||||
fun observePlan(type: PlanType): Flow<PlanRecord>
|
||||
|
||||
fun observePlanSubmit(type: PlanType, periodKey: String): Flow<PlanSubmitData>
|
||||
|
||||
suspend fun savePlan(
|
||||
type: PlanType,
|
||||
periodKey: String,
|
||||
content: String,
|
||||
attachments: List<String>,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.mingyuan.android_client.core.domain.repository
|
||||
|
||||
import com.mingyuan.android_client.core.model.UserProfile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ProfileRepository {
|
||||
fun observeProfile(): Flow<UserProfile>
|
||||
|
||||
suspend fun deleteAccount()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.mingyuan.android_client.core.model
|
||||
|
||||
enum class CheckinStatus(val label: String) {
|
||||
NORMAL("正常"),
|
||||
LATE("迟到"),
|
||||
LEAVE("请假"),
|
||||
ABSENT("不参加讨论"),
|
||||
EARLY_LEAVE("早退"),
|
||||
}
|
||||
|
||||
data class GroupMember(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val checkedIn: Boolean,
|
||||
val checkinTime: String?,
|
||||
val status: CheckinStatus?,
|
||||
)
|
||||
|
||||
data class PersonalCheckinRecord(
|
||||
val date: String,
|
||||
val status: CheckinStatus,
|
||||
val time: String,
|
||||
)
|
||||
|
||||
data class CheckinRecord(
|
||||
val classDate: String,
|
||||
val weekday: String,
|
||||
val dayNum: Int,
|
||||
val checked: Boolean,
|
||||
val status: CheckinStatus?,
|
||||
val groupName: String,
|
||||
val groupMembers: List<GroupMember>,
|
||||
val personalHistory: List<PersonalCheckinRecord>,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.mingyuan.android_client.core.model
|
||||
|
||||
enum class ModuleDestination {
|
||||
CHECKIN,
|
||||
PLAN,
|
||||
ABOUT,
|
||||
}
|
||||
|
||||
data class HomeModule(
|
||||
val id: String,
|
||||
val emoji: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val destination: ModuleDestination,
|
||||
)
|
||||
|
||||
data class IntroCard(
|
||||
val title: String,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
data class HomeData(
|
||||
val greeting: String,
|
||||
val displayName: String,
|
||||
val encouragement: String,
|
||||
val modules: List<HomeModule>,
|
||||
val intros: List<IntroCard>,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.mingyuan.android_client.core.model
|
||||
|
||||
enum class PlanType(val label: String) {
|
||||
WEEKLY("周计划"),
|
||||
MONTHLY("月计划"),
|
||||
YEARLY("年计划"),
|
||||
}
|
||||
|
||||
data class MyPlanRecord(
|
||||
val status: String,
|
||||
val submittedTime: String,
|
||||
val content: String,
|
||||
val attachments: List<String>,
|
||||
)
|
||||
|
||||
data class PlanMember(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val filled: Boolean,
|
||||
)
|
||||
|
||||
data class PlanGroup(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val checkedCount: Int,
|
||||
val memberCount: Int,
|
||||
val members: List<PlanMember>,
|
||||
)
|
||||
|
||||
data class PlanRecord(
|
||||
val type: PlanType,
|
||||
val periodKey: String,
|
||||
val canSubmit: Boolean,
|
||||
val message: String?,
|
||||
val myChecked: Boolean,
|
||||
val myRecord: MyPlanRecord?,
|
||||
val groups: List<PlanGroup>,
|
||||
val unassignedCount: Int,
|
||||
)
|
||||
|
||||
data class PlanSubmitData(
|
||||
val content: String,
|
||||
val attachments: List<String>,
|
||||
val checked: Boolean,
|
||||
val canSubmit: Boolean,
|
||||
val message: String?,
|
||||
val history: List<MyPlanRecord>,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mingyuan.android_client.core.model
|
||||
|
||||
data class ClassGroup(
|
||||
val period: String,
|
||||
val group: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
data class UserProfile(
|
||||
val displayName: String,
|
||||
val name: String,
|
||||
val studentId: String,
|
||||
val phone: String?,
|
||||
val role: String,
|
||||
val group: String?,
|
||||
val loginType: String,
|
||||
val avatarUrl: String?,
|
||||
val classGroups: List<ClassGroup>,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.mingyuan.android_client.core.ui.status
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mingyuan.android_client.core.model.CheckinStatus
|
||||
|
||||
@Composable
|
||||
fun StatusBadge(
|
||||
status: CheckinStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val color = status.statusColor()
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(color.copy(alpha = 0.12f), RoundedCornerShape(50))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(6.dp).background(color, CircleShape))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(status.label, color = color, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.mingyuan.android_client.core.ui.status
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.mingyuan.android_client.core.designsystem.theme.StatusAbsent
|
||||
import com.mingyuan.android_client.core.designsystem.theme.StatusEarlyLeave
|
||||
import com.mingyuan.android_client.core.designsystem.theme.StatusLate
|
||||
import com.mingyuan.android_client.core.designsystem.theme.StatusLeave
|
||||
import com.mingyuan.android_client.core.designsystem.theme.StatusNormal
|
||||
import com.mingyuan.android_client.core.model.CheckinStatus
|
||||
|
||||
fun CheckinStatus.statusColor(): Color = when (this) {
|
||||
CheckinStatus.NORMAL -> StatusNormal
|
||||
CheckinStatus.LATE -> StatusLate
|
||||
CheckinStatus.LEAVE -> StatusLeave
|
||||
CheckinStatus.ABSENT -> StatusAbsent
|
||||
CheckinStatus.EARLY_LEAVE -> StatusEarlyLeave
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.mingyuan.android_client.core.ui.status
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PillIdleBorder
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
|
||||
import com.mingyuan.android_client.core.model.CheckinStatus
|
||||
|
||||
@Composable
|
||||
fun StatusPill(
|
||||
status: CheckinStatus,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val color = status.statusColor()
|
||||
val shape = RoundedCornerShape(50)
|
||||
|
||||
val background = if (selected) color else Color.White
|
||||
val textColor = if (selected) Color.White else SecondaryText
|
||||
val borderColor = if (selected) color else PillIdleBorder
|
||||
val dotColor = if (selected) Color.White else color
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.shadow(
|
||||
elevation = if (selected) 6.dp else 0.dp,
|
||||
shape = shape,
|
||||
spotColor = color.copy(alpha = 0.22f),
|
||||
ambientColor = color.copy(alpha = 0.22f),
|
||||
)
|
||||
.background(background, shape)
|
||||
.border(1.dp, borderColor, shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(8.dp).background(dotColor, CircleShape))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(status.label, color = textColor, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
| `core:datastore` | android library | DataStore 偏好存储 |
|
||||
| `core:designsystem` | android library + compose | 主题 / 颜色 / 字体 / 通用组件 |
|
||||
| `core:ui` | android library + compose + hilt | 共享 Compose UI 与导航辅助 |
|
||||
| `feature:*` | feature | 各业务功能(auth/checkin/plan/course/survey/feedback/profile) |
|
||||
| `feature:*` | feature | 各业务功能(auth/home/checkin/plan/course/survey/feedback/profile) |
|
||||
|
||||
## 分层与依赖方向
|
||||
|
||||
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.checkin"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -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<PersonalCheckinRecord>) {
|
||||
if (history.isEmpty()) {
|
||||
EmptyState("📒", "暂无个人打卡记录")
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
history.forEach { item ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = item.date,
|
||||
color = PrimaryText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusBadge(item.status)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(item.time, color = MutedText, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckinSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(120.dp), cornerRadius = 20)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(140.dp), cornerRadius = 16)
|
||||
repeat(4) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,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<CheckinStatus> = _selectedStatus.asStateFlow()
|
||||
|
||||
private val _submitting = MutableStateFlow(false)
|
||||
val submitting: StateFlow<Boolean> = _submitting.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<CheckinUiState> = checkinRepository.observeCheckin()
|
||||
.map<CheckinRecord, CheckinUiState>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
feature/home/build.gradle.kts
Normal file
11
feature/home/build.gradle.kts
Normal file
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
alias(libs.plugins.mcl.android.feature)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.home"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
2
feature/home/src/main/AndroidManifest.xml
Normal file
2
feature/home/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -0,0 +1,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<HomeModule>,
|
||||
onModuleClick: (HomeModule) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
modules.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
row.forEach { module ->
|
||||
ModuleItem(module, onModuleClick, Modifier.weight(1f))
|
||||
}
|
||||
repeat(3 - row.size) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleItem(
|
||||
module: HomeModule,
|
||||
onModuleClick: (HomeModule) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.shadow(
|
||||
elevation = 3.dp,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
spotColor = Color(0x0F2C1810),
|
||||
ambientColor = Color(0x0F2C1810),
|
||||
)
|
||||
.background(Color.White, RoundedCornerShape(22.dp))
|
||||
.clickable { onModuleClick(module) }
|
||||
.padding(vertical = 16.dp, horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(44.dp).background(NeutralChip, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(module.emoji, fontSize = 20.sp)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(module.title, color = PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(module.description, color = MutedText, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntroSection(intros: List<IntroCard>) {
|
||||
Column {
|
||||
Text("产品介绍", color = PrimaryText, fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
intros.forEach { intro ->
|
||||
IntroItem(intro)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntroItem(intro: IntroCard) {
|
||||
SectionCard {
|
||||
Text(intro.title, color = BrandAmber, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(intro.body, color = SecondaryText, fontSize = 13.sp, lineHeight = 22.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<HomeUiState> = homeRepository.observeHome()
|
||||
.map<HomeData, HomeUiState>(HomeUiState::Success)
|
||||
.onStart { emit(HomeUiState.Loading) }
|
||||
.catch { emit(HomeUiState.Error(it.message ?: "加载失败,请稍后再试")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = HomeUiState.Loading,
|
||||
)
|
||||
}
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.plan"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,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<PlanGroup?>(null) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { PlanHead(record, selectedType) }
|
||||
item { MyPlanStatusCard(record, selectedType, onNavigateToPlanSubmit) }
|
||||
val myRecord = record.myRecord
|
||||
if (record.myChecked && myRecord != null) {
|
||||
item { UploadedPlanCard(myRecord) }
|
||||
}
|
||||
val message = record.message
|
||||
if (message != null) {
|
||||
item { WindowMessage(message) }
|
||||
}
|
||||
if (record.groups.isEmpty()) {
|
||||
item { EmptyState("📭", "暂无小组") }
|
||||
} else {
|
||||
item {
|
||||
Text("小组打卡", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
items(record.groups, key = { it.id }) { group ->
|
||||
GroupCard(group) { selectedGroup = group }
|
||||
}
|
||||
}
|
||||
if (record.unassignedCount > 0) {
|
||||
item {
|
||||
Text(
|
||||
text = "另有未分组 ${record.unassignedCount} 人",
|
||||
color = FaintText,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedGroup?.let { group ->
|
||||
GroupDetailDialog(group, onDismiss = { selectedGroup = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanHead(record: PlanRecord, selectedType: PlanType) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(selectedType.label, color = PrimaryText, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text(record.periodKey, color = MutedLightText, fontSize = 12.sp)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.background(
|
||||
if (record.myChecked) SuccessBackground else WarningBackground,
|
||||
RoundedCornerShape(50),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (record.myChecked) "已提交" else "待提交",
|
||||
color = if (record.myChecked) StatusNormal else BrandAmber,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyPlanStatusCard(
|
||||
record: PlanRecord,
|
||||
selectedType: PlanType,
|
||||
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
|
||||
) {
|
||||
SectionCard(cornerRadius = 16) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
if (record.myChecked) {
|
||||
Brush.linearGradient(listOf(StatusNormal, Color(0xFF43A047)))
|
||||
} else {
|
||||
Brush.linearGradient(listOf(Danger, Color(0xFFE5736C)))
|
||||
},
|
||||
CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (record.myChecked) "✓" else "●",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (record.myChecked) "已完成" else "未完成",
|
||||
color = if (record.myChecked) StatusNormal else Danger,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text("我的${selectedType.label}", color = MutedText, fontSize = 12.sp)
|
||||
}
|
||||
MingyuanButton(
|
||||
text = if (record.myChecked) "修改" else "新增打卡",
|
||||
onClick = { onNavigateToPlanSubmit(selectedType, record.periodKey) },
|
||||
enabled = record.canSubmit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadedPlanCard(myRecord: MyPlanRecord) {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "我上传的计划",
|
||||
color = PrimaryText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.background(WarningBackground, RoundedCornerShape(50))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text("未完成", color = BrandAmber, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("${myRecord.submittedTime} 提交", color = MutedText, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(myRecord.content, color = SecondaryText, fontSize = 13.sp, lineHeight = 20.sp)
|
||||
if (myRecord.attachments.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(myRecord.attachments.size) {
|
||||
ThumbnailPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThumbnailPlaceholder() {
|
||||
Box(
|
||||
Modifier.size(64.dp).background(NeutralChip, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("🖼️", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WindowMessage(message: String) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(WarningBackground, RoundedCornerShape(10.dp))
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("⏰", fontSize = 14.sp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(message, color = Danger, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupCard(group: PlanGroup, onClick: () -> Unit) {
|
||||
val progress = if (group.memberCount > 0) {
|
||||
group.checkedCount.toFloat() / group.memberCount
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
SectionCard(cornerRadius = 14) {
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
Brush.linearGradient(listOf(Color(0xFFF3E3CF), Color(0xFFFBE7D1))),
|
||||
RoundedCornerShape(12.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(group.name.take(1), color = BrandAmber, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(group.name, color = PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
AmberProgressBar(progress)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "已打卡 ${group.checkedCount}",
|
||||
color = StatusNormal,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "${group.memberCount}人",
|
||||
color = BrandAmber,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("›", color = FaintText, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupDetailDialog(group: PlanGroup, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("关闭") }
|
||||
},
|
||||
title = { Text(group.name, fontSize = 16.sp, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
group.members.forEach { member ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
initial = member.name,
|
||||
size = 32.dp,
|
||||
backgroundColor = if (member.filled) SuccessBackground else NeutralChip,
|
||||
textColor = if (member.filled) StatusNormal else BrandAmber,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = member.name,
|
||||
color = PrimaryText,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = if (member.filled) "已完成" else "未完成",
|
||||
color = if (member.filled) StatusNormal else Danger,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanSkeleton() {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(40.dp), cornerRadius = 12)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(80.dp), cornerRadius = 16)
|
||||
repeat(3) { SkeletonBox(Modifier.fillMaxWidth().height(72.dp), cornerRadius = 14) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.mingyuan.android_client.feature.plan
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.mingyuan.android_client.core.designsystem.component.EmptyState
|
||||
import com.mingyuan.android_client.core.designsystem.component.ErrorState
|
||||
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
|
||||
import com.mingyuan.android_client.core.designsystem.component.SectionCard
|
||||
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
|
||||
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
|
||||
import com.mingyuan.android_client.core.designsystem.theme.Disabled
|
||||
import com.mingyuan.android_client.core.designsystem.theme.FaintText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.MutedText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
|
||||
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
|
||||
import com.mingyuan.android_client.core.designsystem.theme.TextareaBorder
|
||||
import com.mingyuan.android_client.core.model.MyPlanRecord
|
||||
import com.mingyuan.android_client.core.model.PlanSubmitData
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
|
||||
/** Navigation route for this feature, referenced by the app-level NavHost. */
|
||||
const val PLAN_SUBMIT_ROUTE = "plan_submit?planType={planType}&periodKey={periodKey}"
|
||||
|
||||
fun planSubmitRoute(planType: PlanType, periodKey: String): String =
|
||||
"plan_submit?planType=${planType.name}&periodKey=${Uri.encode(periodKey)}"
|
||||
|
||||
@Composable
|
||||
fun PlanSubmitScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlanSubmitViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val saving by viewModel.saving.collectAsStateWithLifecycle()
|
||||
val saved by viewModel.saved.collectAsStateWithLifecycle()
|
||||
|
||||
var content by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
val state = uiState
|
||||
if (state is PlanSubmitUiState.Success && content.isEmpty() && state.data.content.isNotEmpty()) {
|
||||
content = state.data.content
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(saved) {
|
||||
if (saved) {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().background(PageBackground)) {
|
||||
SubmitHeader(viewModel.planType, viewModel.periodKey, onBack)
|
||||
|
||||
when (val state = uiState) {
|
||||
is PlanSubmitUiState.Loading -> SubmitSkeleton()
|
||||
is PlanSubmitUiState.Error -> ErrorState(
|
||||
message = state.message,
|
||||
onRetry = {},
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
is PlanSubmitUiState.Success -> SubmitContent(
|
||||
data = state.data,
|
||||
content = content,
|
||||
onContentChange = { content = it },
|
||||
saving = saving,
|
||||
onSave = { viewModel.save(content) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitHeader(planType: PlanType, periodKey: String, onBack: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(Color.White).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "‹",
|
||||
color = PrimaryText,
|
||||
fontSize = 24.sp,
|
||||
modifier = Modifier.clickable(onClick = onBack).padding(end = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${planType.label} · $periodKey",
|
||||
color = PrimaryText,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitContent(
|
||||
data: PlanSubmitData,
|
||||
content: String,
|
||||
onContentChange: (String) -> Unit,
|
||||
saving: Boolean,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val message = data.message
|
||||
if (!data.canSubmit && message != null) {
|
||||
item { Text(message, color = MutedText, fontSize = 12.sp) }
|
||||
}
|
||||
item {
|
||||
PlanTextarea(
|
||||
content = content,
|
||||
onContentChange = onContentChange,
|
||||
enabled = data.canSubmit,
|
||||
)
|
||||
}
|
||||
item { UploadButton(attachmentsCount = data.attachments.size, enabled = data.canSubmit) }
|
||||
item {
|
||||
MingyuanButton(
|
||||
text = when {
|
||||
!data.canSubmit -> "不可提交"
|
||||
data.checked -> "保存修改"
|
||||
else -> "保存计划"
|
||||
},
|
||||
onClick = onSave,
|
||||
enabled = data.canSubmit && content.isNotBlank() && !saving,
|
||||
fullWidth = true,
|
||||
)
|
||||
}
|
||||
item { HistorySection(data.history) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanTextarea(
|
||||
content: String,
|
||||
onContentChange: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp)
|
||||
.border(1.dp, TextareaBorder, RoundedCornerShape(12.dp))
|
||||
.background(if (enabled) PageBackground else Color(0xFFF6F1EA), RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
if (content.isEmpty()) {
|
||||
Text("写下计划内容", color = FaintText, fontSize = 14.sp)
|
||||
}
|
||||
BasicTextField(
|
||||
value = content,
|
||||
onValueChange = { if (it.length <= 1000) onContentChange(it) },
|
||||
enabled = enabled,
|
||||
textStyle = TextStyle(color = PrimaryText, fontSize = 14.sp),
|
||||
cursorBrush = SolidColor(BrandAmber),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${content.length}/1000",
|
||||
color = FaintText,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadButton(attachmentsCount: Int, enabled: Boolean) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(2.dp, if (enabled) BrandAmber else Disabled, RoundedCornerShape(12.dp))
|
||||
.background(PageBackground, RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+ 添加图片($attachmentsCount/4)",
|
||||
color = if (enabled) BrandAmber else Disabled,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistorySection(history: List<MyPlanRecord>) {
|
||||
Column {
|
||||
Text("历史计划", color = PrimaryText, fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (history.isEmpty()) {
|
||||
EmptyState("📭", "暂无历史计划")
|
||||
} else {
|
||||
history.forEach { item ->
|
||||
SectionCard {
|
||||
Text("${item.submittedTime} · ${item.status}", color = BrandAmber, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(item.content, color = SecondaryText, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitSkeleton() {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(120.dp), cornerRadius = 12)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 12)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.mingyuan.android_client.feature.plan
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mingyuan.android_client.core.domain.repository.PlanRepository
|
||||
import com.mingyuan.android_client.core.model.PlanSubmitData
|
||||
import com.mingyuan.android_client.core.model.PlanType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface PlanSubmitUiState {
|
||||
data object Loading : PlanSubmitUiState
|
||||
data class Success(val data: PlanSubmitData) : PlanSubmitUiState
|
||||
data class Error(val message: String) : PlanSubmitUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class PlanSubmitViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val planRepository: PlanRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val planType: PlanType = savedStateHandle.get<String>("planType")
|
||||
?.let { runCatching { PlanType.valueOf(it) }.getOrNull() }
|
||||
?: PlanType.WEEKLY
|
||||
|
||||
val periodKey: String = savedStateHandle.get<String>("periodKey").orEmpty()
|
||||
|
||||
private val _saving = MutableStateFlow(false)
|
||||
val saving: StateFlow<Boolean> = _saving.asStateFlow()
|
||||
|
||||
private val _saved = MutableStateFlow(false)
|
||||
val saved: StateFlow<Boolean> = _saved.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<PlanSubmitUiState> = planRepository.observePlanSubmit(planType, periodKey)
|
||||
.map<PlanSubmitData, PlanSubmitUiState>(PlanSubmitUiState::Success)
|
||||
.onStart { emit(PlanSubmitUiState.Loading) }
|
||||
.catch { emit(PlanSubmitUiState.Error(it.message ?: "加载失败,请稍后再试")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = PlanSubmitUiState.Loading,
|
||||
)
|
||||
|
||||
fun save(content: String) {
|
||||
viewModelScope.launch {
|
||||
_saving.value = true
|
||||
planRepository.savePlan(planType, periodKey, content, emptyList())
|
||||
_saving.value = false
|
||||
_saved.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,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<PlanType> = _selectedType.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<PlanUiState> = _selectedType
|
||||
.flatMapLatest { type -> planRepository.observePlan(type) }
|
||||
.map<PlanRecord, PlanUiState>(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.mingyuan.android_client.feature.profile"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -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<ClassGroup>) {
|
||||
SectionCard {
|
||||
Text("小组分组", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
classGroups.forEach { group ->
|
||||
LabeledRow(group.period, "${group.group} · ${group.role}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SkeletonBox(Modifier.size(56.dp), cornerRadius = 28)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
SkeletonBox(Modifier.width(120.dp).height(20.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SkeletonBox(Modifier.width(80.dp).height(12.dp))
|
||||
}
|
||||
}
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(200.dp), cornerRadius = 16)
|
||||
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,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<ProfileUiState> = profileRepository.observeProfile()
|
||||
.map<UserProfile, ProfileUiState>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -36,6 +36,7 @@ include(":core:database")
|
||||
include(":core:datastore")
|
||||
include(":core:data")
|
||||
include(":feature:auth")
|
||||
include(":feature:home")
|
||||
include(":feature:checkin")
|
||||
include(":feature:plan")
|
||||
include(":feature:course")
|
||||
|
||||
Reference in New Issue
Block a user