Compare commits

1 Commits
dev ... main

Author SHA1 Message Date
AndyChen1024
559844a4a4 Merge branch 'dev' into main 2026-09-14 21:16:17 +08:00
47 changed files with 128 additions and 2584 deletions

View File

@@ -2,9 +2,7 @@
## 项目概览 ## 项目概览
SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/计划管理」客户端,功能对齐 `wechat-clock-mini`(小程序,生产参照)与 `android-client`(Android 端,原生简化参照)。架构对齐 Android NIA(`core/*` + `feature/*`)。 SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/习惯追踪」客户端,架构对齐 Android NIA(`core/*` + `feature/*`)。
四个主入口 Tab(顺序):**首页 / 打卡 / 计划 / 我的**;另有独立**登录页**(手机号 + 短信验证码)与**提交计划页**(计划 Tab 内推入)。
## 目录结构 ## 目录结构
@@ -19,11 +17,7 @@ SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/计划
- `Sources/CoreDatabase`:SwiftData(`@Model` + `ModelContainer`) - `Sources/CoreDatabase`:SwiftData(`@Model` + `ModelContainer`)
- `Sources/DesignSystem`:设计 tokens - `Sources/DesignSystem`:设计 tokens
- `Sources/CoreUI`:可复用 SwiftUI 组件 - `Sources/CoreUI`:可复用 SwiftUI 组件
- `Sources/FeatureHome`:首页(落地页:Hero + 模块卡 + 产品介绍) - `Sources/FeatureHome` / `Sources/FeatureAuth`:Feature 模块
- `Sources/FeatureCheckin`:打卡(状态卡 + 状态 pill + 小组/个人记录)
- `Sources/FeaturePlan`:计划(周/月/年 + 小组统计 + 提交计划页)
- `Sources/FeatureProfile`:我的(资料 + 小组分组 + 退出登录)
- `Sources/FeatureAuth`:登录(手机号 + 短信验证码 + 60s 倒计时)
## 依赖方向(硬约束,违反即编译不过) ## 依赖方向(硬约束,违反即编译不过)
@@ -50,14 +44,7 @@ SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/计划
| Hilt `@Inject` / AppContainer | 手写 `AppContainer` | | Hilt `@Inject` / AppContainer | 手写 `AppContainer` |
| Retrofit + kotlinx.serialization | URLSession + Codable | | Retrofit + kotlinx.serialization | URLSession + Codable |
| Room | SwiftData | | Room | SwiftData |
| DataStore(会话持久化) | Combine `CurrentValueSubject`(进程内 Fake 会话)| | DataStore | `@AppStorage` / UserDefaults |
## 会话 / 登录态
- `SessionRepository` 协议(CoreDomain)+ `FakeSessionRepository`(CoreRepository,`final class` + Combine `CurrentValueSubject<Bool, Never>`,默认未登录,**AppContainer 持单例共享**)。
- 登录态驱动页面:`HomeViewModel` 双源切换(已登录 `observeHome()` / 游客 `observeGuestHome()`);checkin/plan/profile 四态含 `loggedOut`。
- flatMapLatest 的 iOS 等价:外层 `Task` 观察 `observeIsLoggedIn()`,登录态变化时取消内层数据 `Task` 并重新订阅。
- 登录 mock 凭据:手机号 `13812341234` + 验证码 `123456`。
## 代码风格 ## 代码风格
@@ -92,11 +79,3 @@ swiftlint lint --strict
2. 新建 `Sources/FeatureXxx/`:Screen + ViewModel + UiState(enum)。 2. 新建 `Sources/FeatureXxx/`:Screen + ViewModel + UiState(enum)。
3. UseCase 放 `CoreDomain`,Repository 实现放 `CoreRepository`。 3. UseCase 放 `CoreDomain`,Repository 实现放 `CoreRepository`。
4. 在 `App` 的 `AppContainer` 装配依赖、`AppRootView` 接入导航。 4. 在 `App` 的 `AppContainer` 装配依赖、`AppRootView` 接入导航。
## 文档地图
| 主题 | 文档 |
|---|---|
| 架构与数据流 | [docs/architecture.md](docs/architecture.md) |
| 工作状态与遗留项 | [docs/progress.md](docs/progress.md) |
| 贡献指南 | [CONTRIBUTING.md](CONTRIBUTING.md) |

View File

@@ -12,12 +12,11 @@
- 跨模块符号 `public` + `///` 文档注释。 - 跨模块符号 `public` + `///` 文档注释。
- 领域模型标记 `Sendable`。 - 领域模型标记 `Sendable`。
- ViewModel:`@MainActor @Observable`,UiState 用 `enum`。 - ViewModel:`@MainActor @Observable`,UiState 用 `enum`。
- 登录态经 `SessionRepository` 驱动,不要在各页面各自维护登录态副本。
- 引入任何第三方依赖前,需在 PR 说明必要性(项目默认零第三方框架)。 - 引入任何第三方依赖前,需在 PR 说明必要性(项目默认零第三方框架)。
## 提交流程 ## 提交流程
1. 从 `dev` 新建分支:`feat/xxx`、`fix/xxx`(简化 GitFlow:feature 从 `dev` 拉、merge 回 `dev`,`dev` 稳定后再 merge 到 `main`)。 1. 从 `main` 新建分支:`feat/xxx`、`fix/xxx`。
2. 本地跑通: 2. 本地跑通:
```bash ```bash
swiftlint lint swiftlint lint

View File

@@ -38,26 +38,12 @@ let package = Package(
dependencies: ["CoreDomain", "CoreUI", "CoreModel", "CoreCommon"] dependencies: ["CoreDomain", "CoreUI", "CoreModel", "CoreCommon"]
), ),
.target(name: "FeatureAuth", dependencies: ["CoreDomain", "CoreUI", "CoreModel"]), .target(name: "FeatureAuth", dependencies: ["CoreDomain", "CoreUI", "CoreModel"]),
.target(
name: "FeaturePlan",
dependencies: ["CoreDomain", "CoreUI", "CoreModel", "CoreCommon"]
),
.target(
name: "FeatureCheckin",
dependencies: ["CoreDomain", "CoreUI", "CoreModel", "CoreCommon"]
),
.target(
name: "FeatureProfile",
dependencies: ["CoreDomain", "CoreUI", "CoreModel", "CoreCommon"]
),
// 组合根(依赖一切,用于 DI 装配) // 组合根(依赖一切,用于 DI 装配)
.target( .target(
name: "App", name: "App",
dependencies: [ dependencies: [
"FeatureHome", "FeatureAuth", "FeatureHome", "FeatureAuth", "CoreRepository",
"FeaturePlan", "FeatureCheckin", "FeatureProfile",
"CoreRepository",
"CoreUI", "DesignSystem", "CoreDomain", "CoreModel", "CoreUI", "DesignSystem", "CoreDomain", "CoreModel",
] ]
), ),

View File

@@ -1,49 +1,14 @@
import CoreDomain import CoreDomain
import CoreModel
import CoreRepository import CoreRepository
import FeatureAuth
import FeatureCheckin
import FeatureHome import FeatureHome
import FeaturePlan
import FeatureProfile
/// 手写组合根 / DI 容器(对应 Android Hilt 的 AppContainer)。 /// 手写组合根 / DI 容器(对应 Android Hilt 的 AppContainer)。
@MainActor @MainActor
public enum AppContainer { public enum AppContainer {
// 共享的有状态仓库(登录态、打卡、计划可提交,需跨页面共享单例)。 /// 装配 FeatureHome 依赖并返回 ViewModel。
private static let sessionRepository: SessionRepository = FakeSessionRepository()
private static let checkinRepository: CheckinRepository = FakeCheckinRepository()
private static let planRepository: PlanRepository = FakePlanRepository()
private static let sessionUseCase = SessionUseCase(repository: sessionRepository)
private static let checkinUseCase = CheckinUseCase(repository: checkinRepository)
private static let planUseCase = PlanUseCase(repository: planRepository)
public static func makeHomeViewModel() -> HomeViewModel { public static func makeHomeViewModel() -> HomeViewModel {
let repository: HomeRepository = FakeHomeRepository() let repository: HomeRepository = FakeHomeRepository()
let useCase = HomeUseCase(repository: repository) let useCase = HomeUseCase(repository: repository)
return HomeViewModel(homeUseCase: useCase, sessionUseCase: sessionUseCase) return HomeViewModel(homeUseCase: useCase)
}
public static func makeCheckinViewModel() -> CheckinViewModel {
CheckinViewModel(checkinUseCase: checkinUseCase, sessionUseCase: sessionUseCase)
}
public static func makePlanViewModel() -> PlanViewModel {
PlanViewModel(planUseCase: planUseCase, sessionUseCase: sessionUseCase)
}
public static func makeProfileViewModel() -> ProfileViewModel {
let repository: ProfileRepository = FakeProfileRepository()
let useCase = ProfileUseCase(repository: repository)
return ProfileViewModel(profileUseCase: useCase, sessionUseCase: sessionUseCase)
}
public static func makeAuthViewModel() -> AuthViewModel {
AuthViewModel(sessionUseCase: sessionUseCase)
}
public static func makePlanSubmitViewModel(type: PlanType, periodKey: String) -> PlanSubmitViewModel {
PlanSubmitViewModel(planUseCase: planUseCase, type: type, periodKey: periodKey)
} }
} }

View File

@@ -1,73 +1,36 @@
import SwiftUI import SwiftUI
import CoreUI
import FeatureAuth import FeatureAuth
import FeatureCheckin
import FeatureHome import FeatureHome
import FeaturePlan
import FeatureProfile
public struct AppRootView: View { public struct AppRootView: View {
@State private var homeViewModel: HomeViewModel @State private var homeViewModel: HomeViewModel
@State private var checkinViewModel: CheckinViewModel
@State private var planViewModel: PlanViewModel
@State private var profileViewModel: ProfileViewModel
@State private var authViewModel: AuthViewModel
@State private var selectedTab: AppTab = .home
@State private var isAuthPresented = false
public init() { public init() {
_homeViewModel = State(initialValue: AppContainer.makeHomeViewModel()) _homeViewModel = State(initialValue: AppContainer.makeHomeViewModel())
_checkinViewModel = State(initialValue: AppContainer.makeCheckinViewModel())
_planViewModel = State(initialValue: AppContainer.makePlanViewModel())
_profileViewModel = State(initialValue: AppContainer.makeProfileViewModel())
_authViewModel = State(initialValue: AppContainer.makeAuthViewModel())
} }
public var body: some View { public var body: some View {
TabView(selection: $selectedTab) { TabView {
NavigationStack { NavigationStack {
HomeScreen( HomeScreen(viewModel: homeViewModel)
viewModel: homeViewModel,
onNavigateToCheckin: { selectedTab = .checkin },
onNavigateToPlan: { selectedTab = .plan },
onNavigateToAuth: { isAuthPresented = true }
)
} }
.tabItem { Label("首页", systemImage: "house.fill") } .tabItem { Label("首页", systemImage: "house.fill") }
.tag(AppTab.home)
NavigationStack { NavigationStack {
CheckinScreen(viewModel: checkinViewModel, onNavigateToAuth: { isAuthPresented = true }) PlaceholderView("计划", systemImage: "calendar")
}
.tabItem { Label("打卡", systemImage: "checkmark.seal.fill") }
.tag(AppTab.checkin)
NavigationStack {
PlanScreen(
viewModel: planViewModel,
onNavigateToAuth: { isAuthPresented = true },
makePlanSubmitViewModel: { type, periodKey in
AppContainer.makePlanSubmitViewModel(type: type, periodKey: periodKey)
}
)
} }
.tabItem { Label("计划", systemImage: "calendar") } .tabItem { Label("计划", systemImage: "calendar") }
.tag(AppTab.plan)
NavigationStack { NavigationStack {
ProfileScreen(viewModel: profileViewModel, onNavigateToAuth: { isAuthPresented = true }) PlaceholderView("打卡", systemImage: "checkmark.seal.fill")
}
.tabItem { Label("打卡", systemImage: "checkmark.seal.fill") }
NavigationStack {
AuthScreen()
} }
.tabItem { Label("我的", systemImage: "person.crop.circle") } .tabItem { Label("我的", systemImage: "person.crop.circle") }
.tag(AppTab.profile)
}
.sheet(isPresented: $isAuthPresented) {
AuthScreen(viewModel: authViewModel, onLoggedIn: { isAuthPresented = false })
} }
} }
} }
private enum AppTab: Hashable {
case home
case checkin
case plan
case profile
}

View File

@@ -1,9 +0,0 @@
import CoreModel
/// 打卡数据仓库协议(定义在 domain 层,实现放 CoreRepository)。
public protocol CheckinRepository: Sendable {
/// 观察打卡数据。
func observeCheckin() -> AsyncThrowingStream<CheckinRecord, Error>
/// 提交打卡状态。
func submitCheckin(status: CheckinStatus) async throws
}

View File

@@ -1,18 +0,0 @@
import CoreModel
/// 获取/提交打卡数据的用例。
public struct CheckinUseCase: Sendable {
private let repository: CheckinRepository
public init(repository: CheckinRepository) {
self.repository = repository
}
public func observeCheckin() -> AsyncThrowingStream<CheckinRecord, Error> {
repository.observeCheckin()
}
public func submitCheckin(status: CheckinStatus) async throws {
try await repository.submitCheckin(status: status)
}
}

View File

@@ -1,9 +1,8 @@
import CoreModel import CoreModel
/// Home 数据仓库协议(定义在 domain 层,实现放 CoreRepository)。 /// Home 数据仓库协议(定义在 domain 层,实现放 CoreRepository)。
/// Kotlin: interface HomeRepository { fun observeHome(): Flow<HomeData> }
public protocol HomeRepository: Sendable { public protocol HomeRepository: Sendable {
/// 已登录首页。 /// Kotlin: Flow<HomeData> → Swift: AsyncThrowingStream<HomeData, Error>
func observeHome() -> AsyncThrowingStream<HomeData, Error> func observeHome() -> AsyncThrowingStream<HomeData, Error>
/// 游客首页。
func observeGuestHome() -> AsyncThrowingStream<HomeData, Error>
} }

View File

@@ -1,6 +1,7 @@
import CoreModel import CoreModel
/// 获取 Home 数据的用例。 /// 获取 Home 数据的用例。
/// Kotlin: class HomeUseCase(...) { suspend operator fun invoke(): HomeData }
public struct HomeUseCase: Sendable { public struct HomeUseCase: Sendable {
private let repository: HomeRepository private let repository: HomeRepository
@@ -8,13 +9,8 @@ public struct HomeUseCase: Sendable {
self.repository = repository self.repository = repository
} }
/// 已登录首页。 /// Kotlin: Flow<HomeData> → Swift: AsyncThrowingStream<HomeData, Error>
public func observeHome() -> AsyncThrowingStream<HomeData, Error> { public func observeHome() -> AsyncThrowingStream<HomeData, Error> {
repository.observeHome() repository.observeHome()
} }
/// 游客首页。
public func observeGuestHome() -> AsyncThrowingStream<HomeData, Error> {
repository.observeGuestHome()
}
} }

View File

@@ -1,8 +0,0 @@
import CoreModel
/// 计划数据仓库协议(定义在 domain 层,实现放 CoreRepository)。
public protocol PlanRepository: Sendable {
func observePlan(type: PlanType) -> AsyncThrowingStream<PlanRecord, Error>
func observePlanSubmit(type: PlanType, periodKey: String) -> AsyncThrowingStream<PlanSubmitData, Error>
func savePlan(type: PlanType, periodKey: String, content: String, attachments: [String]) async throws
}

View File

@@ -1,22 +0,0 @@
import CoreModel
/// 获取/提交计划数据的用例。
public struct PlanUseCase: Sendable {
private let repository: PlanRepository
public init(repository: PlanRepository) {
self.repository = repository
}
public func observePlan(type: PlanType) -> AsyncThrowingStream<PlanRecord, Error> {
repository.observePlan(type: type)
}
public func observePlanSubmit(type: PlanType, periodKey: String) -> AsyncThrowingStream<PlanSubmitData, Error> {
repository.observePlanSubmit(type: type, periodKey: periodKey)
}
public func savePlan(type: PlanType, periodKey: String, content: String, attachments: [String]) async throws {
try await repository.savePlan(type: type, periodKey: periodKey, content: content, attachments: attachments)
}
}

View File

@@ -1,7 +0,0 @@
import CoreModel
/// 个人资料仓库协议(定义在 domain 层,实现放 CoreRepository)。
public protocol ProfileRepository: Sendable {
/// Kotlin: Flow<UserProfile> → Swift: AsyncThrowingStream<UserProfile, Error>
func observeProfile() -> AsyncThrowingStream<UserProfile, Error>
}

View File

@@ -1,14 +0,0 @@
import CoreModel
/// 获取个人资料的用例。
public struct ProfileUseCase: Sendable {
private let repository: ProfileRepository
public init(repository: ProfileRepository) {
self.repository = repository
}
public func observeProfile() -> AsyncThrowingStream<UserProfile, Error> {
repository.observeProfile()
}
}

View File

@@ -1,28 +0,0 @@
import Foundation
/// 会话相关错误。
public enum SessionError: Error, Sendable, LocalizedError {
case invalidPhone
case wrongCode
case phoneNotMatched
public var errorDescription: String? {
switch self {
case .invalidPhone: return "请输入正确的手机号"
case .wrongCode: return "验证码错误"
case .phoneNotMatched: return "手机号未匹配,无法登录"
}
}
}
/// 会话仓库协议(登录态 / 手机号登录)。
public protocol SessionRepository: Sendable {
/// 观察登录态。
func observeIsLoggedIn() -> AsyncThrowingStream<Bool, Error>
/// 请求短信验证码。
func requestSmsCode(phone: String) async throws
/// 手机号 + 验证码登录。
func login(phone: String, code: String) async throws
/// 退出登录。
func logout() async throws
}

View File

@@ -1,24 +0,0 @@
/// 会话用例。
public struct SessionUseCase: Sendable {
private let repository: SessionRepository
public init(repository: SessionRepository) {
self.repository = repository
}
public func observeIsLoggedIn() -> AsyncThrowingStream<Bool, Error> {
repository.observeIsLoggedIn()
}
public func requestSmsCode(phone: String) async throws {
try await repository.requestSmsCode(phone: phone)
}
public func login(phone: String, code: String) async throws {
try await repository.login(phone: phone, code: code)
}
public func logout() async throws {
try await repository.logout()
}
}

View File

@@ -1,10 +0,0 @@
import Foundation
/// 会话(极简,仅 token)。
public struct Session: Hashable, Sendable {
public let token: String
public init(token: String) {
self.token = token
}
}

View File

@@ -1,72 +0,0 @@
import Foundation
/// 打卡状态(对应小程序 STATUSES / android CheckinStatus)。
public enum CheckinStatus: String, CaseIterable, Hashable, Sendable {
case normal = "正常"
case late = "迟到"
case leave = "请假"
case absent = "不参加讨论"
case earlyLeave = "早退"
}
/// 小组成员(打卡视角)。
public struct GroupMember: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let checkedIn: Bool
public let checkinTime: String?
public let status: CheckinStatus?
public init(id: String, name: String, checkedIn: Bool, checkinTime: String?, status: CheckinStatus?) {
self.id = id
self.name = name
self.checkedIn = checkedIn
self.checkinTime = checkinTime
self.status = status
}
}
/// 个人打卡历史条目。
public struct PersonalCheckinRecord: Hashable, Sendable {
public let date: String
public let status: CheckinStatus
public let time: String
public init(date: String, status: CheckinStatus, time: String) {
self.date = date
self.status = status
self.time = time
}
}
/// 打卡页聚合数据。
public struct CheckinRecord: Hashable, Sendable {
public let classDate: String
public let weekday: String
public let dayNum: Int
public let checked: Bool
public let status: CheckinStatus?
public let groupName: String
public let groupMembers: [GroupMember]
public let personalHistory: [PersonalCheckinRecord]
public init(
classDate: String,
weekday: String,
dayNum: Int,
checked: Bool,
status: CheckinStatus?,
groupName: String,
groupMembers: [GroupMember],
personalHistory: [PersonalCheckinRecord]
) {
self.classDate = classDate
self.weekday = weekday
self.dayNum = dayNum
self.checked = checked
self.status = status
self.groupName = groupName
self.groupMembers = groupMembers
self.personalHistory = personalHistory
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
/// 一个打卡习惯(对应 Android 端 Habit)。
public struct Habit: Identifiable, Hashable, Sendable {
public let id: String
public let title: String
/// 备注 / 目标描述。
public let detail: String
/// 今日是否已打卡。
public let isCheckedInToday: Bool
/// 连续打卡天数。
public let streakDays: Int
public init(
id: String,
title: String,
detail: String,
isCheckedInToday: Bool,
streakDays: Int
) {
self.id = id
self.title = title
self.detail = detail
self.isCheckedInToday = isCheckedInToday
self.streakDays = streakDays
}
}

View File

@@ -1,65 +1,19 @@
import Foundation import Foundation
/// 首页模块跳转目标。 /// Home 页面的聚合数据(对应 Android 端 HomeData)。
public enum ModuleDestination: Hashable, Sendable { public struct HomeData: Identifiable, Hashable, Sendable {
case checkin
case plan
case about
}
/// 首页功能模块卡片(打卡介绍 / 计划介绍 / 关于我们)。
public struct HomeModule: Identifiable, Hashable, Sendable {
public let id: String public let id: String
public let emoji: String /// 顶部问候语,例如「早上好,Andy」。
public let title: String
public let description: String
public let destination: ModuleDestination
public init(
id: String,
emoji: String,
title: String,
description: String,
destination: ModuleDestination
) {
self.id = id
self.emoji = emoji
self.title = title
self.description = description
self.destination = destination
}
}
/// 产品介绍卡片。
public struct IntroCard: Hashable, Sendable {
public let title: String
public let body: String
public init(title: String, body: String) {
self.title = title
self.body = body
}
}
/// 首页聚合数据(落地页)。
public struct HomeData: Hashable, Sendable {
public let greeting: String public let greeting: String
public let displayName: String /// 今日打卡摘要,例如「已完成 2 / 4」。
public let encouragement: String public let summary: String
public let modules: [HomeModule] /// 今日待打卡的习惯列表。
public let intros: [IntroCard] public let habits: [Habit]
public init( public init(id: String, greeting: String, summary: String, habits: [Habit]) {
greeting: String, self.id = id
displayName: String,
encouragement: String,
modules: [HomeModule],
intros: [IntroCard]
) {
self.greeting = greeting self.greeting = greeting
self.displayName = displayName self.summary = summary
self.encouragement = encouragement self.habits = habits
self.modules = modules
self.intros = intros
} }
} }

View File

@@ -1,119 +0,0 @@
import Foundation
/// 计划类型(周/月/年)。
public enum PlanType: String, CaseIterable, Hashable, Sendable {
case weekly
case monthly
case yearly
public var label: String {
switch self {
case .weekly: return "周计划"
case .monthly: return "月计划"
case .yearly: return "年计划"
}
}
}
/// 我提交的计划记录。
public struct MyPlanRecord: Hashable, Sendable {
public let status: String
public let submittedTime: String
public let content: String
public let attachments: [String]
public init(status: String, submittedTime: String, content: String, attachments: [String]) {
self.status = status
self.submittedTime = submittedTime
self.content = content
self.attachments = attachments
}
}
/// 小组成员(计划视角)。
public struct PlanMember: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let filled: Bool
public init(id: String, name: String, filled: Bool) {
self.id = id
self.name = name
self.filled = filled
}
}
/// 小组计划统计(含成员)。
public struct PlanGroup: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let checkedCount: Int
public let memberCount: Int
public let members: [PlanMember]
public init(id: String, name: String, checkedCount: Int, memberCount: Int, members: [PlanMember]) {
self.id = id
self.name = name
self.checkedCount = checkedCount
self.memberCount = memberCount
self.members = members
}
}
/// 计划页聚合数据。
public struct PlanRecord: Hashable, Sendable {
public let type: PlanType
public let periodKey: String
public let canSubmit: Bool
public let message: String?
public let myChecked: Bool
public let myRecord: MyPlanRecord?
public let groups: [PlanGroup]
public let unassignedCount: Int
public init(
type: PlanType,
periodKey: String,
canSubmit: Bool,
message: String?,
myChecked: Bool,
myRecord: MyPlanRecord?,
groups: [PlanGroup],
unassignedCount: Int
) {
self.type = type
self.periodKey = periodKey
self.canSubmit = canSubmit
self.message = message
self.myChecked = myChecked
self.myRecord = myRecord
self.groups = groups
self.unassignedCount = unassignedCount
}
}
/// 提交计划页数据。
public struct PlanSubmitData: Hashable, Sendable {
public let content: String
public let attachments: [String]
public let checked: Bool
public let canSubmit: Bool
public let message: String?
public let history: [MyPlanRecord]
public init(
content: String,
attachments: [String],
checked: Bool,
canSubmit: Bool,
message: String?,
history: [MyPlanRecord]
) {
self.content = content
self.attachments = attachments
self.checked = checked
self.canSubmit = canSubmit
self.message = message
self.history = history
}
}

View File

@@ -1,53 +0,0 @@
import Foundation
/// 用户所在的小组分组。
public struct ClassGroup: Identifiable, Hashable, Sendable {
public let period: String
public let group: String
public let role: String
public var id: String { period }
public init(period: String, group: String, role: String) {
self.period = period
self.group = group
self.role = role
}
}
/// 个人资料。
public struct UserProfile: Identifiable, Hashable, Sendable {
public let displayName: String
public let name: String
public let studentId: String
public let phone: String?
public let role: String
public let group: String?
public let loginType: String
public let avatarUrl: String?
public let classGroups: [ClassGroup]
public var id: String { studentId }
public init(
displayName: String,
name: String,
studentId: String,
phone: String?,
role: String,
group: String?,
loginType: String,
avatarUrl: String?,
classGroups: [ClassGroup]
) {
self.displayName = displayName
self.name = name
self.studentId = studentId
self.phone = phone
self.role = role
self.group = group
self.loginType = loginType
self.avatarUrl = avatarUrl
self.classGroups = classGroups
}
}

View File

@@ -1,64 +0,0 @@
import Combine
import CoreDomain
import CoreModel
/// Fake 打卡仓库(骨架阶段返回假数据,可提交)。
public final class FakeCheckinRepository: CheckinRepository, @unchecked Sendable {
private let record: CurrentValueSubject<CheckinRecord, Never>
public init() {
record = CurrentValueSubject(Self.seed())
}
public func observeCheckin() -> AsyncThrowingStream<CheckinRecord, Error> {
Self.stream(record)
}
public func submitCheckin(status: CheckinStatus) async throws {
try await Task.sleep(for: .milliseconds(300))
var current = record.value
current = CheckinRecord(
classDate: current.classDate,
weekday: current.weekday,
dayNum: current.dayNum,
checked: true,
status: status,
groupName: current.groupName,
groupMembers: current.groupMembers.map { member in
guard member.id == "me" else { return member }
return GroupMember(id: member.id, name: member.name, checkedIn: true, checkinTime: "现在", status: status)
},
personalHistory: current.personalHistory
)
record.value = current
}
private static func stream<T>(_ subject: CurrentValueSubject<T, Never>) -> AsyncThrowingStream<T, Error> {
AsyncThrowingStream { continuation in
let cancellable = subject.sink { continuation.yield($0) }
continuation.onTermination = { _ in cancellable.cancel() }
}
}
private static func seed() -> CheckinRecord {
CheckinRecord(
classDate: "2026-09-09",
weekday: "周三",
dayNum: 9,
checked: false,
status: nil,
groupName: "进阶一组",
groupMembers: [
GroupMember(id: "m1", name: "张三", checkedIn: true, checkinTime: "19:02", status: .normal),
GroupMember(id: "m2", name: "李四", checkedIn: true, checkinTime: "19:30", status: .late),
GroupMember(id: "me", name: "我", checkedIn: false, checkinTime: nil, status: nil),
GroupMember(id: "m3", name: "王五", checkedIn: true, checkinTime: "09:10", status: .leave),
GroupMember(id: "m4", name: "赵六", checkedIn: false, checkinTime: nil, status: nil),
],
personalHistory: [
PersonalCheckinRecord(date: "2026-09-06", status: .normal, time: "19:05"),
PersonalCheckinRecord(date: "2026-09-02", status: .late, time: "19:32"),
]
)
}
}

View File

@@ -2,19 +2,11 @@ import CoreCommon
import CoreDomain import CoreDomain
import CoreModel import CoreModel
/// Fake 首页仓库(骨架阶段返回假数据,后续替换为 OfflineFirst 实现)。 /// Fake 仓库实现(骨架阶段返回假数据,后续替换为 OfflineFirst 实现)。
public struct FakeHomeRepository: HomeRepository { public struct FakeHomeRepository: HomeRepository {
public init() {} public init() {}
public func observeHome() -> AsyncThrowingStream<HomeData, Error> { public func observeHome() -> AsyncThrowingStream<HomeData, Error> {
stream(Self.loggedInData)
}
public func observeGuestHome() -> AsyncThrowingStream<HomeData, Error> {
stream(Self.guestData)
}
private func stream(_ data: HomeData) -> AsyncThrowingStream<HomeData, Error> {
AsyncThrowingStream { continuation in AsyncThrowingStream { continuation in
let task = Task { let task = Task {
do { do {
@@ -22,7 +14,7 @@ public struct FakeHomeRepository: HomeRepository {
try await Task.sleep(for: .milliseconds(400)) try await Task.sleep(for: .milliseconds(400))
try Task.checkCancellation() try Task.checkCancellation()
Logger.info("FakeHomeRepository 返回假数据") Logger.info("FakeHomeRepository 返回假数据")
continuation.yield(data) continuation.yield(Self.fakeData)
continuation.finish() continuation.finish()
} catch { } catch {
continuation.finish(throwing: error) continuation.finish(throwing: error)
@@ -34,32 +26,15 @@ public struct FakeHomeRepository: HomeRepository {
} }
} }
private static let modules = [ private static let fakeData = HomeData(
HomeModule(id: "checkin", emoji: "📋", title: "打卡介绍", description: "每日打卡记录", destination: .checkin), id: "home-1",
HomeModule(id: "plan", emoji: "📝", title: "计划介绍", description: "周/月/年计划", destination: .plan), greeting: "早上好,Andy",
HomeModule(id: "about", emoji: "ℹ️", title: "关于我们", description: "了解明缘云道场", destination: .about), summary: "今日已完成 2 / 4",
] habits: [
Habit(id: "habit-1", title: "晨跑 5 公里", detail: "保持运动习惯", isCheckedInToday: true, streakDays: 12),
private static let intros = [ Habit(id: "habit-2", title: "阅读 30 分钟", detail: "每天进步一点", isCheckedInToday: true, streakDays: 8),
IntroCard(title: "📚 学习成长工具", body: "明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯,实现持续进步。好好学习,天天向上。"), Habit(id: "habit-3", title: "喝水 8 杯", detail: "健康补水", isCheckedInToday: false, streakDays: 0),
IntroCard(title: "✅ 周打卡", body: "每周打卡,支持多种状态记录(正常、迟到、请假、早退、不参加讨论等)。查看小组打卡情况,与同学们互相监督、共同进步。"), Habit(id: "habit-4", title: "写日记", detail: "记录今日所思", isCheckedInToday: false, streakDays: 3),
IntroCard(title: "📝 计划管理", body: "提供周计划、月计划、年计划三级管理,支持文字与图片上传。清晰追踪每个周期的目标完成情况,让成长有迹可循。"), ]
IntroCard(title: "👥 小组共学", body: "加入学习小组,查看组员的打卡与计划完成情况。在集体的氛围中保持学习动力,与志同道合的伙伴一起成长。"),
]
private static let guestData = HomeData(
greeting: "欢迎来到明缘云道场",
displayName: "游客",
encouragement: "登录后即可打卡、提交计划",
modules: modules,
intros: intros
)
private static let loggedInData = HomeData(
greeting: "晚上好",
displayName: "张三",
encouragement: "坚持每周打卡,与小组共同进步。",
modules: modules,
intros: intros
) )
} }

View File

@@ -1,136 +0,0 @@
import Combine
import CoreDomain
import CoreModel
import Foundation
/// Fake 计划仓库(骨架阶段返回假数据,可提交)。
public final class FakePlanRepository: PlanRepository, @unchecked Sendable {
private let plans: [PlanType: CurrentValueSubject<PlanRecord, Never>]
private var submits: [String: CurrentValueSubject<PlanSubmitData, Never>] = [:]
private let lock = NSLock()
public init() {
var dict: [PlanType: CurrentValueSubject<PlanRecord, Never>] = [:]
for type in PlanType.allCases {
dict[type] = CurrentValueSubject<PlanRecord, Never>(Self.seedPlan(type))
}
plans = dict
}
public func observePlan(type: PlanType) -> AsyncThrowingStream<PlanRecord, Error> {
Self.stream(plans[type]!)
}
public func observePlanSubmit(type: PlanType, periodKey: String) -> AsyncThrowingStream<PlanSubmitData, Error> {
Self.stream(submitSubject(type: type, periodKey: periodKey))
}
public func savePlan(type: PlanType, periodKey: String, content: String, attachments: [String]) async throws {
try await Task.sleep(for: .milliseconds(300))
let submit = submitSubject(type: type, periodKey: periodKey)
let currentSubmit = submit.value
submit.value = PlanSubmitData(
content: content,
attachments: attachments,
checked: true,
canSubmit: currentSubmit.canSubmit,
message: currentSubmit.message,
history: currentSubmit.history
)
let plan = plans[type]!
let currentPlan = plan.value
plan.value = PlanRecord(
type: currentPlan.type,
periodKey: currentPlan.periodKey,
canSubmit: currentPlan.canSubmit,
message: currentPlan.message,
myChecked: true,
myRecord: MyPlanRecord(status: "已完成", submittedTime: "现在", content: content, attachments: attachments),
groups: currentPlan.groups,
unassignedCount: currentPlan.unassignedCount
)
}
private func submitSubject(type: PlanType, periodKey: String) -> CurrentValueSubject<PlanSubmitData, Never> {
let key = "\(type.rawValue):\(periodKey)"
lock.lock()
defer { lock.unlock() }
if let existing = submits[key] {
return existing
}
let subject = CurrentValueSubject<PlanSubmitData, Never>(Self.seedSubmit(type: type))
submits[key] = subject
return subject
}
private static func stream<T>(_ subject: CurrentValueSubject<T, Never>) -> AsyncThrowingStream<T, Error> {
AsyncThrowingStream { continuation in
let cancellable = subject.sink { continuation.yield($0) }
continuation.onTermination = { _ in cancellable.cancel() }
}
}
private static func seedPlan(_ type: PlanType) -> PlanRecord {
PlanRecord(
type: type,
periodKey: periodKey(for: type),
canSubmit: true,
message: type == .weekly ? "本周计划提交截止:周日晚 23:59" : nil,
myChecked: type == .weekly,
myRecord: type == .weekly
? MyPlanRecord(status: "已完成", submittedTime: "今天 18:30", content: "本周完成两章学习,整理笔记并复习错题。", attachments: ["图1", "图2"])
: nil,
groups: seedGroups(),
unassignedCount: 2
)
}
private static func seedSubmit(type: PlanType) -> PlanSubmitData {
if type == .weekly {
return PlanSubmitData(
content: "本周完成两章学习,整理笔记并复习错题。",
attachments: ["图1", "图2"],
checked: true,
canSubmit: true,
message: nil,
history: []
)
}
return PlanSubmitData(
content: "",
attachments: [],
checked: false,
canSubmit: true,
message: nil,
history: [
MyPlanRecord(status: "已完成", submittedTime: "2026-08 完成", content: "上月计划内容……", attachments: []),
]
)
}
private static func periodKey(for type: PlanType) -> String {
switch type {
case .weekly: return "2026 第 37 周"
case .monthly: return "2026 年 9 月"
case .yearly: return "2026 年"
}
}
private static func seedGroups() -> [PlanGroup] {
[
PlanGroup(id: "g1", name: "进阶一组", checkedCount: 4, memberCount: 6, members: seedMembers()),
PlanGroup(id: "g2", name: "进阶二组", checkedCount: 5, memberCount: 5, members: seedMembers()),
PlanGroup(id: "g3", name: "进阶三组", checkedCount: 2, memberCount: 7, members: seedMembers()),
]
}
private static func seedMembers() -> [PlanMember] {
[
PlanMember(id: "p1", name: "张三", filled: true),
PlanMember(id: "p2", name: "李四", filled: true),
PlanMember(id: "p3", name: "王五", filled: false),
]
}
}

View File

@@ -1,40 +0,0 @@
import CoreCommon
import CoreDomain
import CoreModel
/// Fake 个人资料仓库(骨架阶段返回假数据)。
public struct FakeProfileRepository: ProfileRepository {
public init() {}
public func observeProfile() -> AsyncThrowingStream<UserProfile, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
try await Task.sleep(for: .milliseconds(400))
try Task.checkCancellation()
Logger.info("FakeProfileRepository 返回假数据")
continuation.yield(Self.fakeData)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
private static let fakeData = UserProfile(
displayName: "张三",
name: "张三",
studentId: "20260001",
phone: "138****1234",
role: "成员",
group: "进阶一组",
loginType: "手机号",
avatarUrl: nil,
classGroups: [
ClassGroup(period: "2026 秋季", group: "进阶一组", role: "成员"),
ClassGroup(period: "2026 春季", group: "进阶二组", role: "组长"),
]
)
}

View File

@@ -1,39 +0,0 @@
import Combine
import CoreDomain
import Foundation
/// Fake 会话仓库(登录态在进程内共享,默认未登录)。
public final class FakeSessionRepository: SessionRepository, @unchecked Sendable {
private let isLoggedIn = CurrentValueSubject<Bool, Never>(false)
public init() {}
public func observeIsLoggedIn() -> AsyncThrowingStream<Bool, Error> {
AsyncThrowingStream { continuation in
let cancellable = self.isLoggedIn.sink { continuation.yield($0) }
continuation.onTermination = { _ in cancellable.cancel() }
}
}
public func requestSmsCode(phone: String) async throws {
try await Task.sleep(for: .milliseconds(500))
guard phone.range(of: #"^1\d{10}$"#, options: .regularExpression) != nil else {
throw SessionError.invalidPhone
}
}
public func login(phone: String, code: String) async throws {
try await Task.sleep(for: .milliseconds(500))
guard code == "123456" else {
throw SessionError.wrongCode
}
guard phone == "13812341234" else {
throw SessionError.phoneNotMatched
}
isLoggedIn.value = true
}
public func logout() async throws {
isLoggedIn.value = false
}
}

View File

@@ -1,20 +0,0 @@
import SwiftUI
/// 未登录占位视图(打卡/计划/我的页复用)。
public struct LoggedOutView: View {
private let onNavigateToAuth: () -> Void
public init(onNavigateToAuth: @escaping () -> Void) {
self.onNavigateToAuth = onNavigateToAuth
}
public var body: some View {
ContentUnavailableView {
Label("未登录", systemImage: "lock")
} description: {
Text("登录后即可查看")
} actions: {
Button("去登录", action: onNavigateToAuth)
}
}
}

View File

@@ -1,70 +1,12 @@
import SwiftUI import SwiftUI
/// 设计 tokens(颜色 / 渐变 / 间距 / 圆角),对应 Android DesignSystem。 /// 设计 tokens(颜色 / 间距 / 圆角),对应 Android DesignSystem。
public enum DS { public enum DS {
public enum Color { public enum Color {
public static let accent = SwiftUI.Color.accentColor public static let accent = SwiftUI.Color.accentColor
public static let success = SwiftUI.Color.green public static let success = SwiftUI.Color.green
public static let danger = SwiftUI.Color(hex: 0xC0392B) public static let danger = SwiftUI.Color.red
public static let warning = SwiftUI.Color.orange public static let warning = SwiftUI.Color.orange
// 品牌色(对齐「明缘云道场」小程序 / android-client designsystem)
public static let brandDark = SwiftUI.Color(hex: 0x2C1810)
public static let brandGold = SwiftUI.Color(hex: 0xC67B30)
public static let brandPaper = SwiftUI.Color(hex: 0xFDFBF7)
public static let planBackground = SwiftUI.Color(hex: 0xF6F2EC)
public static let brandBrown = SwiftUI.Color(hex: 0x5A4A3A)
public static let brandMuted = SwiftUI.Color(hex: 0x8B7A6A)
public static let brandLightAmber = SwiftUI.Color(hex: 0xE3B37C)
public static let amberMid = SwiftUI.Color(hex: 0xE4A05E)
public static let brandChip = SwiftUI.Color(hex: 0xF6EFE6)
public static let brandCream = SwiftUI.Color(hex: 0xE8DCCB)
public static let faintText = SwiftUI.Color(hex: 0xB6A996)
public static let disabled = SwiftUI.Color(hex: 0xD8C7B4)
// 打卡状态色
public static let statusNormal = SwiftUI.Color(hex: 0x2E7D32)
public static let statusNormalBright = SwiftUI.Color(hex: 0x43A047)
public static let statusLate = SwiftUI.Color(hex: 0xC67B30)
public static let statusLeave = SwiftUI.Color(hex: 0x3A7BD5)
public static let statusEarlyLeave = SwiftUI.Color(hex: 0x8B7A6A)
public static let statusAbsent = SwiftUI.Color(hex: 0xC0392B)
public static let dangerBright = SwiftUI.Color(hex: 0xE5736C)
// 完成 / 未完成底色
public static let fillDone = SwiftUI.Color(hex: 0xE8F4EB)
public static let fillUndone = SwiftUI.Color(hex: 0xFFF3E6)
}
public enum Gradient {
/// 首页顶部深咖啡色渐变(135°)。
public static let hero = LinearGradient(
colors: [
SwiftUI.Color(hex: 0x2C1810),
SwiftUI.Color(hex: 0x4A2B1D),
SwiftUI.Color(hex: 0x6B3F24),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
public static let amber = LinearGradient(
colors: [SwiftUI.Color(hex: 0xC67B30), SwiftUI.Color(hex: 0xE4A05E)],
startPoint: .top,
endPoint: .bottom
)
public static let avatar = LinearGradient(
colors: [SwiftUI.Color(hex: 0xC67B30), SwiftUI.Color(hex: 0xE3B37C)],
startPoint: .top,
endPoint: .bottom
)
public static let groupTile = LinearGradient(
colors: [SwiftUI.Color(hex: 0xF3E3CF), SwiftUI.Color(hex: 0xFBE7D1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} }
public enum Spacing { public enum Spacing {
@@ -81,16 +23,3 @@ public enum DS {
public static let lg: CGFloat = 16 public static let lg: CGFloat = 16
} }
} }
extension SwiftUI.Color {
/// 从 0xRRGGBB 十六进制值构造颜色。
init(hex: UInt32, alpha: Double = 1.0) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255.0,
green: Double((hex >> 8) & 0xFF) / 255.0,
blue: Double(hex & 0xFF) / 255.0,
opacity: alpha
)
}
}

View File

@@ -1,100 +1,10 @@
import SwiftUI import SwiftUI
import DesignSystem
/// FeatureAuth 占位(后续实现登录 / 注册)。
public struct AuthScreen: View { public struct AuthScreen: View {
private let viewModel: AuthViewModel public init() {}
private let onLoggedIn: () -> Void
@State private var phone = ""
@State private var code = ""
@State private var privacyAgreed = false
@Environment(\.dismiss) private var dismiss
public init(viewModel: AuthViewModel, onLoggedIn: @escaping () -> Void) {
self.viewModel = viewModel
self.onLoggedIn = onLoggedIn
}
public var body: some View { public var body: some View {
NavigationStack { ContentUnavailableView("登录", systemImage: "person.crop.circle")
ScrollView {
VStack(alignment: .leading, spacing: DS.Spacing.md) {
Text("手机号登录")
.font(.title2.bold())
.foregroundStyle(DS.Color.brandDark)
Text("手机号仅用于账号绑定,不会公开展示")
.font(.footnote)
.foregroundStyle(DS.Color.faintText)
TextField("手机号", text: Binding(get: { phone }, set: { phone = digits($0, max: 11) }))
.textFieldStyle(.roundedBorder)
HStack(spacing: DS.Spacing.sm) {
TextField("验证码", text: Binding(get: { code }, set: { code = digits($0, max: 6) }))
.textFieldStyle(.roundedBorder)
Button(getCodeTitle) { viewModel.requestSmsCode(phone: phone) }
.buttonStyle(.bordered)
.disabled(!canSendCode)
}
Button { privacyAgreed.toggle() } label: {
HStack(spacing: DS.Spacing.xs) {
Image(systemName: privacyAgreed ? "checkmark.square.fill" : "square")
.foregroundStyle(privacyAgreed ? DS.Color.brandGold : DS.Color.brandMuted)
Text("我已阅读并同意《用户协议》和《隐私政策》")
.font(.caption)
.foregroundStyle(DS.Color.faintText)
}
}
.buttonStyle(.plain)
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(DS.Color.danger)
}
Button(viewModel.isSubmitting ? "登录中..." : "登录") {
viewModel.login(phone: phone, code: code)
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.disabled(!canSubmit)
}
.padding(DS.Spacing.lg)
}
.navigationTitle("登录")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
}
}
.onChange(of: viewModel.didLogin) { _, loggedIn in
if loggedIn { onLoggedIn() }
}
}
private var phoneValid: Bool {
phone.range(of: #"^1\d{10}$"#, options: .regularExpression) != nil
}
private var canSendCode: Bool {
phoneValid && viewModel.countdownSeconds == 0 && !viewModel.isSendingCode
}
private var canSubmit: Bool {
phoneValid && !code.isEmpty && privacyAgreed && !viewModel.isSubmitting
}
private var getCodeTitle: String {
if viewModel.countdownSeconds > 0 { return "\(viewModel.countdownSeconds)s 后重发" }
if viewModel.isSendingCode { return "发送中" }
return "获取验证码"
}
private func digits(_ string: String, max: Int) -> String {
String(string.filter(\.isNumber).prefix(max))
} }
} }

View File

@@ -1,65 +0,0 @@
import CoreDomain
import Foundation
import Observation
@MainActor
@Observable
public final class AuthViewModel {
public var isSendingCode = false
public var countdownSeconds = 0
public var isSubmitting = false
public var errorMessage: String?
public var didLogin = false
private let sessionUseCase: SessionUseCase
private var countdownTask: Task<Void, Never>?
public init(sessionUseCase: SessionUseCase) {
self.sessionUseCase = sessionUseCase
}
public func requestSmsCode(phone: String) {
guard !isSendingCode, countdownSeconds == 0 else { return }
isSendingCode = true
errorMessage = nil
Task { [weak self] in
guard let self else { return }
do {
try await self.sessionUseCase.requestSmsCode(phone: phone)
self.startCountdown()
} catch {
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
self.isSendingCode = false
}
}
public func login(phone: String, code: String) {
guard !isSubmitting else { return }
isSubmitting = true
errorMessage = nil
Task { [weak self] in
guard let self else { return }
do {
try await self.sessionUseCase.login(phone: phone, code: code)
self.didLogin = true
} catch {
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
self.isSubmitting = false
}
}
private func startCountdown() {
countdownTask?.cancel()
countdownSeconds = 60
countdownTask = Task { [weak self] in
guard let self else { return }
while self.countdownSeconds > 0 {
try? await Task.sleep(for: .seconds(1))
guard !Task.isCancelled else { return }
self.countdownSeconds -= 1
}
}
}
}

View File

@@ -1,286 +0,0 @@
import SwiftUI
import CoreModel
import CoreUI
import DesignSystem
public struct CheckinScreen: View {
private let viewModel: CheckinViewModel
private let onNavigateToAuth: () -> Void
public init(viewModel: CheckinViewModel, onNavigateToAuth: @escaping () -> Void) {
self.viewModel = viewModel
self.onNavigateToAuth = onNavigateToAuth
}
public var body: some View {
content
.navigationTitle("打卡")
.task { viewModel.load() }
}
@ViewBuilder
private var content: some View {
switch viewModel.uiState {
case .loading:
LoadingView()
case .loggedOut:
LoggedOutView(onNavigateToAuth: onNavigateToAuth)
case .error(let message):
ErrorView(message: message) { viewModel.load() }
case .success(let record):
successContent(record)
}
}
private func successContent(_ record: CheckinRecord) -> some View {
ScrollView {
VStack(spacing: DS.Spacing.md) {
StatusTitleCard(record: record)
StatusSelector(
selected: viewModel.selectedStatus,
submitting: viewModel.submitting,
onSelect: { viewModel.submit($0) }
)
RecordPanel(record: record)
}
.padding(DS.Spacing.md)
}
.background(DS.Color.brandPaper)
}
}
// MARK: - 顶部状态卡
private struct StatusTitleCard: View {
let record: CheckinRecord
var body: some View {
HStack(spacing: DS.Spacing.md) {
VStack(spacing: 2) {
Text(record.weekday)
.font(.caption)
.foregroundStyle(DS.Color.brandCream)
Text("\(record.dayNum)")
.font(.title3.bold())
.foregroundStyle(.white)
}
.frame(width: 62)
.padding(.vertical, DS.Spacing.sm)
.background(DS.Color.brandDark)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
VStack(alignment: .leading, spacing: DS.Spacing.xs) {
Text(record.checked ? "已打卡" : "未打卡")
.font(.title3.bold())
.foregroundStyle(record.checked ? DS.Color.statusNormal : DS.Color.brandGold)
if record.checked, let status = record.status {
StatusBadge(status: status)
} else {
Text("请选择状态完成打卡")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
}
Spacer()
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
// MARK: - 状态选择器
private struct StatusSelector: View {
let selected: CheckinStatus
let submitting: Bool
let onSelect: (CheckinStatus) -> Void
var body: some View {
VStack(spacing: DS.Spacing.sm) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: DS.Spacing.sm) {
ForEach(CheckinStatus.allCases, id: \.self) { status in
StatusPill(status: status, isSelected: status == selected) {
onSelect(status)
}
}
}
if submitting {
Text("提交中…")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct StatusPill: View {
let status: CheckinStatus
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 4) {
Circle()
.fill(isSelected ? Color.white : status.color)
.frame(width: 8, height: 8)
Text(status.rawValue)
.font(.footnote)
.lineLimit(1)
}
.padding(.vertical, DS.Spacing.sm)
.frame(maxWidth: .infinity)
.background(isSelected ? status.color : Color.white)
.foregroundStyle(isSelected ? Color.white : DS.Color.brandBrown)
.clipShape(Capsule())
.overlay(Capsule().stroke(isSelected ? Color.clear : DS.Color.disabled, lineWidth: 1))
}
.buttonStyle(.plain)
}
}
// MARK: - 记录面板(小组/个人)
private struct RecordPanel: View {
let record: CheckinRecord
@State private var tab: CheckinTab = .group
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
HStack(spacing: DS.Spacing.lg) {
tabButton("小组", .group)
tabButton("个人", .personal)
}
if tab == .group {
groupList
} else {
personalList
}
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
private func tabButton(_ title: String, _ value: CheckinTab) -> some View {
Button {
tab = value
} label: {
VStack(spacing: 4) {
Text(title)
.font(.subheadline.bold())
.foregroundStyle(tab == value ? DS.Color.brandGold : DS.Color.brandBrown)
Rectangle()
.fill(tab == value ? DS.Color.brandGold : Color.clear)
.frame(height: 2)
}
}
.buttonStyle(.plain)
}
private var checkedCount: Int {
record.groupMembers.filter(\.checkedIn).count
}
private var groupList: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
HStack {
Text("\(record.groupName) · \(record.classDate)")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
Spacer()
Text("\(checkedCount)/\(record.groupMembers.count) 已打卡")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
ForEach(record.groupMembers) { member in
GroupMemberRow(member: member)
}
}
}
private var personalList: some View {
Group {
if record.personalHistory.isEmpty {
EmptyStateView(title: "暂无个人打卡记录", systemImage: "book")
} else {
VStack(spacing: DS.Spacing.sm) {
ForEach(record.personalHistory, id: \.date) { item in
HStack {
Text(item.date)
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
Spacer()
StatusBadge(status: item.status)
Text(item.time)
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
}
}
}
}
}
}
// MARK: - 成员行 / 状态徽标
private struct GroupMemberRow: View {
let member: GroupMember
var body: some View {
HStack(spacing: DS.Spacing.sm) {
Text(String(member.name.prefix(1)))
.font(.subheadline.bold())
.foregroundStyle(member.checkedIn ? DS.Color.statusNormal : DS.Color.brandGold)
.frame(width: 30, height: 30)
.background(member.checkedIn ? DS.Color.fillDone : DS.Color.brandChip)
.clipShape(Circle())
Text(member.name)
.font(.subheadline)
.foregroundStyle(DS.Color.brandDark)
Spacer()
if member.checkedIn, let status = member.status {
Text("打卡 \(member.checkinTime ?? "")")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
StatusBadge(status: status)
} else {
Text("未打卡")
.font(.caption)
.foregroundStyle(DS.Color.faintText)
}
}
.padding(.vertical, DS.Spacing.xs)
}
}
private struct StatusBadge: View {
let status: CheckinStatus
var body: some View {
Text(status.rawValue)
.font(.caption)
.foregroundStyle(status.color)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, 2)
.background(status.color.opacity(0.12))
.clipShape(Capsule())
}
}
private extension CheckinStatus {
var color: Color {
switch self {
case .normal: return DS.Color.statusNormal
case .late: return DS.Color.statusLate
case .leave: return DS.Color.statusLeave
case .earlyLeave: return DS.Color.statusEarlyLeave
case .absent: return DS.Color.statusAbsent
}
}
}

View File

@@ -1,15 +0,0 @@
import CoreModel
/// 打卡页状态。
public enum CheckinUiState {
case loading
case success(CheckinRecord)
case loggedOut
case error(String)
}
/// 记录面板 tab。
public enum CheckinTab: Hashable {
case group
case personal
}

View File

@@ -1,68 +0,0 @@
import CoreDomain
import CoreModel
import Observation
@MainActor
@Observable
public final class CheckinViewModel {
public private(set) var uiState: CheckinUiState = .loading
public var selectedStatus: CheckinStatus = .normal
public var submitting = false
private let checkinUseCase: CheckinUseCase
private let sessionUseCase: SessionUseCase
private var loadTask: Task<Void, Never>?
private var dataTask: Task<Void, Never>?
public init(checkinUseCase: CheckinUseCase, sessionUseCase: SessionUseCase) {
self.checkinUseCase = checkinUseCase
self.sessionUseCase = sessionUseCase
}
public func load() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
do {
for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() {
self.handleLoginState(isLoggedIn)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
/// 提交打卡(对齐 android:只即时高亮选中 pill,卡片/成员靠流重新发射刷新)。
public func submit(_ status: CheckinStatus) {
guard !submitting else { return }
selectedStatus = status
submitting = true
Task { [weak self] in
guard let self else { return }
try? await self.checkinUseCase.submitCheckin(status: status)
self.submitting = false
}
}
private func handleLoginState(_ isLoggedIn: Bool) {
dataTask?.cancel()
guard isLoggedIn else {
uiState = .loggedOut
return
}
uiState = .loading
dataTask = Task { [weak self] in
guard let self else { return }
do {
for try await record in self.checkinUseCase.observeCheckin() {
self.uiState = .success(record)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
}

View File

@@ -5,22 +5,9 @@ import DesignSystem
public struct HomeScreen: View { public struct HomeScreen: View {
private let viewModel: HomeViewModel private let viewModel: HomeViewModel
private let onNavigateToCheckin: () -> Void
private let onNavigateToPlan: () -> Void
private let onNavigateToAuth: () -> Void
@State private var showLoginDialog = false public init(viewModel: HomeViewModel) {
public init(
viewModel: HomeViewModel,
onNavigateToCheckin: @escaping () -> Void,
onNavigateToPlan: @escaping () -> Void,
onNavigateToAuth: @escaping () -> Void
) {
self.viewModel = viewModel self.viewModel = viewModel
self.onNavigateToCheckin = onNavigateToCheckin
self.onNavigateToPlan = onNavigateToPlan
self.onNavigateToAuth = onNavigateToAuth
} }
public var body: some View { public var body: some View {
@@ -34,153 +21,57 @@ public struct HomeScreen: View {
switch viewModel.uiState { switch viewModel.uiState {
case .loading: case .loading:
LoadingView() LoadingView()
case .empty:
EmptyStateView(title: "今天还没有待打卡的习惯", systemImage: "checkmark.circle")
case .success(let data):
successContent(data)
case .error(let message): case .error(let message):
ErrorView(message: message) { viewModel.load() } ErrorView(message: message) { viewModel.load() }
case .success(let data, let isGuest):
successContent(data, isGuest: isGuest)
} }
} }
private func successContent(_ data: HomeData, isGuest: Bool) -> some View { private func successContent(_ data: HomeData) -> some View {
ScrollView { List {
VStack(spacing: DS.Spacing.md) { Section {
HeroCard(data: data) VStack(alignment: .leading, spacing: DS.Spacing.sm) {
ModuleGrid(modules: data.modules) { module in Text(data.greeting)
handleModuleTap(module, isGuest: isGuest) .font(.largeTitle.bold())
Text(data.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
} }
IntroSection(intros: data.intros) .padding(.vertical, DS.Spacing.sm)
} }
.padding(DS.Spacing.md)
}
.background(DS.Color.brandPaper)
.alert("登录提示", isPresented: $showLoginDialog) {
Button("取消", role: .cancel) {}
Button("去登录") { onNavigateToAuth() }
} message: {
Text("登录后使用此功能,是否前往登录?")
}
}
private func handleModuleTap(_ module: HomeModule, isGuest: Bool) { Section("今日打卡") {
if isGuest { ForEach(data.habits) { habit in
showLoginDialog = true HabitRow(habit: habit)
return }
}
switch module.destination {
case .checkin: onNavigateToCheckin()
case .plan: onNavigateToPlan()
case .about: break
}
}
}
// MARK: - Hero 卡
private struct HeroCard: View {
let data: HomeData
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
Text(data.greeting)
.font(.caption.bold())
.foregroundStyle(DS.Color.brandLightAmber)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, DS.Spacing.xs)
.background(DS.Color.brandGold.opacity(0.18))
.clipShape(Capsule())
Text(data.displayName)
.font(.title2.bold())
.foregroundStyle(.white)
Text(data.encouragement)
.font(.subheadline)
.foregroundStyle(DS.Color.brandCream)
}
.padding(DS.Spacing.lg)
.frame(maxWidth: .infinity, alignment: .leading)
.background(DS.Gradient.hero)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}
// MARK: - 模块宫格
private struct ModuleGrid: View {
let modules: [HomeModule]
let onTap: (HomeModule) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: DS.Spacing.sm), count: 3)
var body: some View {
LazyVGrid(columns: columns, spacing: DS.Spacing.sm) {
ForEach(modules) { module in
ModuleItem(module: module, onTap: onTap)
} }
} }
} }
} }
private struct ModuleItem: View { private struct HabitRow: View {
let module: HomeModule let habit: Habit
let onTap: (HomeModule) -> Void
var body: some View { var body: some View {
VStack(spacing: DS.Spacing.sm) { HStack {
Text(module.emoji) Image(systemName: habit.isCheckedInToday ? "checkmark.circle.fill" : "circle")
.font(.title2) .foregroundStyle(habit.isCheckedInToday ? DS.Color.success : DS.Color.warning)
.frame(width: 44, height: 44) .font(.title3)
.background(DS.Color.brandChip) VStack(alignment: .leading, spacing: 2) {
.clipShape(Circle()) Text(habit.title)
Text(module.title) .font(.headline)
.font(.footnote.bold()) Text(habit.detail)
.foregroundStyle(DS.Color.brandDark) .font(.caption)
Text(module.description) .foregroundStyle(.secondary)
.font(.caption2)
.foregroundStyle(DS.Color.brandMuted)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.contentShape(Rectangle())
.onTapGesture { onTap(module) }
}
}
// MARK: - 产品介绍
private struct IntroSection: View {
let intros: [IntroCard]
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
Text("产品介绍")
.font(.title3.bold())
.foregroundStyle(DS.Color.brandDark)
ForEach(intros, id: \.title) { intro in
IntroItem(intro: intro)
} }
Spacer()
Text("🔥 \(habit.streakDays) 天")
.font(.caption)
.foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, DS.Spacing.xs)
}
}
private struct IntroItem: View {
let intro: IntroCard
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.xs) {
Text(intro.title)
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandGold)
Text(intro.body)
.font(.subheadline)
.foregroundStyle(DS.Color.brandBrown)
.lineSpacing(4)
}
.padding(DS.Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
} }

View File

@@ -1,8 +1,9 @@
import CoreModel import CoreModel
/// 首页状态(对应 Kotlin sealed interface UiState)。 /// Kotlin: sealed interface HomeUiState → Swift: enum 关联值
public enum HomeUiState { public enum HomeUiState {
case loading case loading
case success(HomeData, isGuest: Bool) case success(HomeData)
case empty
case error(String) case error(String)
} }

View File

@@ -8,47 +8,24 @@ public final class HomeViewModel {
public private(set) var uiState: HomeUiState = .loading public private(set) var uiState: HomeUiState = .loading
private let homeUseCase: HomeUseCase private let homeUseCase: HomeUseCase
private let sessionUseCase: SessionUseCase
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var dataTask: Task<Void, Never>?
public init(homeUseCase: HomeUseCase, sessionUseCase: SessionUseCase) { public init(homeUseCase: HomeUseCase) {
self.homeUseCase = homeUseCase self.homeUseCase = homeUseCase
self.sessionUseCase = sessionUseCase
} }
public func load() { public func load() {
// flatMapLatest 的等价:登录态变化时重新订阅对应数据源。 // flatMapLatest 的等价:新 load 前先取消旧的观察。
loadTask?.cancel() loadTask?.cancel()
loadTask = Task { [weak self] in loadTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
self.uiState = .loading
do { do {
for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() { for try await data in self.homeUseCase.observeHome() {
self.reload(isLoggedIn: isLoggedIn) self.uiState = data.habits.isEmpty ? .empty : .success(data)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
private func reload(isLoggedIn: Bool) {
dataTask?.cancel()
uiState = .loading
dataTask = Task { [weak self] in
guard let self else { return }
do {
if isLoggedIn {
for try await data in self.homeUseCase.observeHome() {
self.uiState = .success(data, isGuest: false)
}
} else {
for try await data in self.homeUseCase.observeGuestHome() {
self.uiState = .success(data, isGuest: true)
}
} }
} catch is CancellationError { } catch is CancellationError {
// 被更新的 load() 取代,保持当前状态即可。
} catch { } catch {
self.uiState = .error(error.localizedDescription) self.uiState = .error(error.localizedDescription)
} }

View File

@@ -1,298 +0,0 @@
import SwiftUI
import CoreModel
import CoreUI
import DesignSystem
public struct PlanScreen: View {
private let viewModel: PlanViewModel
private let onNavigateToAuth: () -> Void
private let makePlanSubmitViewModel: (PlanType, String) -> PlanSubmitViewModel
@State private var submitRoute: PlanSubmitRoute?
@State private var selectedGroup: PlanGroup?
public init(
viewModel: PlanViewModel,
onNavigateToAuth: @escaping () -> Void,
makePlanSubmitViewModel: @escaping (PlanType, String) -> PlanSubmitViewModel
) {
self.viewModel = viewModel
self.onNavigateToAuth = onNavigateToAuth
self.makePlanSubmitViewModel = makePlanSubmitViewModel
}
public var body: some View {
content
.navigationTitle("计划")
.task { viewModel.load() }
.navigationDestination(item: $submitRoute) { route in
PlanSubmitScreen(
viewModel: makePlanSubmitViewModel(route.type, route.periodKey),
type: route.type,
periodKey: route.periodKey
)
}
.sheet(item: $selectedGroup) { group in
GroupDetailSheet(group: group)
}
}
@ViewBuilder
private var content: some View {
switch viewModel.uiState {
case .loading:
LoadingView()
case .loggedOut:
LoggedOutView(onNavigateToAuth: onNavigateToAuth)
case .error(let message):
ErrorView(message: message) { viewModel.load() }
case .success(let record):
successContent(record)
}
}
private func successContent(_ record: PlanRecord) -> some View {
ScrollView {
VStack(spacing: DS.Spacing.md) {
Picker("类型", selection: Binding(get: { viewModel.selectedType }, set: { viewModel.selectType($0) })) {
ForEach(PlanType.allCases, id: \.self) { type in
Text(type.label).tag(type)
}
}
.pickerStyle(.segmented)
.labelsHidden()
PlanHead(record: record)
MyPlanStatusCard(record: record) {
submitRoute = PlanSubmitRoute(type: record.type, periodKey: record.periodKey)
}
if record.myChecked, let myRecord = record.myRecord {
UploadedPlanCard(record: myRecord)
}
if let message = record.message {
WindowMessage(message: message)
}
if record.groups.isEmpty {
EmptyStateView(title: "暂无小组", systemImage: "person.2")
} else {
Text("小组打卡")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(record.groups) { group in
GroupCard(group: group) { selectedGroup = group }
}
}
if record.unassignedCount > 0 {
Text("另有未分组 \(record.unassignedCount) 人")
.font(.caption)
.foregroundStyle(DS.Color.faintText)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(DS.Spacing.md)
}
.background(DS.Color.brandPaper)
}
}
private struct PlanSubmitRoute: Hashable {
let type: PlanType
let periodKey: String
}
// MARK: - 头部 / 状态卡
private struct PlanHead: View {
let record: PlanRecord
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(record.type.label)
.font(.title3.bold())
.foregroundStyle(DS.Color.brandDark)
Text(record.periodKey)
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
Spacer()
Text(record.myChecked ? "已提交" : "待提交")
.font(.caption)
.foregroundStyle(record.myChecked ? DS.Color.statusNormal : DS.Color.brandGold)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, 4)
.background(record.myChecked ? DS.Color.fillDone : DS.Color.fillUndone)
.clipShape(Capsule())
}
}
}
private struct MyPlanStatusCard: View {
let record: PlanRecord
let onSubmit: () -> Void
var body: some View {
HStack(spacing: DS.Spacing.md) {
Text(record.myChecked ? "✓" : "●")
.font(.headline)
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(
record.myChecked
? AnyShapeStyle(LinearGradient(colors: [DS.Color.statusNormal, DS.Color.statusNormalBright], startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(LinearGradient(colors: [DS.Color.danger, DS.Color.dangerBright], startPoint: .top, endPoint: .bottom))
)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(record.myChecked ? "已完成" : "未完成")
.font(.subheadline.bold())
.foregroundStyle(record.myChecked ? DS.Color.statusNormal : DS.Color.danger)
Text("我的\(record.type.label)")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
Spacer()
Button(record.myChecked ? "修改" : "新增打卡", action: onSubmit)
.buttonStyle(.bordered)
.disabled(!record.canSubmit)
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct UploadedPlanCard: View {
let record: MyPlanRecord
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
HStack {
Text("我上传的计划")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
Spacer()
Text(record.status)
.font(.caption)
.foregroundStyle(DS.Color.brandGold)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, 2)
.background(DS.Color.fillUndone)
.clipShape(Capsule())
}
Text("\(record.submittedTime) 提交")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
Text(record.content)
.font(.subheadline)
.foregroundStyle(DS.Color.brandBrown)
if !record.attachments.isEmpty {
HStack(spacing: DS.Spacing.sm) {
ForEach(record.attachments, id: \.self) { _ in
Text("🖼️")
.frame(width: 64, height: 64)
.background(DS.Color.brandChip)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
}
}
.padding(DS.Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct WindowMessage: View {
let message: String
var body: some View {
HStack(spacing: DS.Spacing.sm) {
Text("⏰")
Text(message)
.font(.caption)
.foregroundStyle(DS.Color.danger)
}
.padding(DS.Spacing.sm)
.frame(maxWidth: .infinity, alignment: .leading)
.background(DS.Color.fillUndone)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
// MARK: - 小组卡片 / 明细
private struct GroupCard: View {
let group: PlanGroup
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: DS.Spacing.sm) {
Text(String(group.name.prefix(1)))
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandGold)
.frame(width: 40, height: 40)
.background(DS.Gradient.groupTile)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: DS.Spacing.xs) {
Text(group.name)
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
ProgressView(value: Double(group.checkedCount), total: Double(max(group.memberCount, 1)))
.tint(DS.Color.brandGold)
}
Spacer()
Text("已打卡 \(group.checkedCount)")
.font(.caption2)
.foregroundStyle(DS.Color.statusNormal)
Text("\(group.memberCount)人")
.font(.caption2)
.foregroundStyle(DS.Color.brandGold)
Text("›")
.font(.headline)
.foregroundStyle(DS.Color.faintText)
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
}
}
private struct GroupDetailSheet: View {
let group: PlanGroup
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(group.members) { member in
HStack(spacing: DS.Spacing.sm) {
Text(String(member.name.prefix(1)))
.font(.subheadline.bold())
.foregroundStyle(member.filled ? DS.Color.statusNormal : DS.Color.brandGold)
.frame(width: 30, height: 30)
.background(member.filled ? DS.Color.fillDone : DS.Color.brandChip)
.clipShape(Circle())
Text(member.name)
.font(.subheadline)
.foregroundStyle(DS.Color.brandDark)
Spacer()
Text(member.filled ? "已完成" : "未完成")
.font(.caption)
.foregroundStyle(member.filled ? DS.Color.statusNormal : DS.Color.danger)
}
}
.navigationTitle(group.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("关闭") { dismiss() }
}
}
}
.presentationDetents([.medium])
}
}

View File

@@ -1,135 +0,0 @@
import SwiftUI
import CoreModel
import CoreUI
import DesignSystem
public struct PlanSubmitScreen: View {
private let viewModel: PlanSubmitViewModel
private let type: PlanType
private let periodKey: String
@State private var content = ""
@Environment(\.dismiss) private var dismiss
public init(viewModel: PlanSubmitViewModel, type: PlanType, periodKey: String) {
self.viewModel = viewModel
self.type = type
self.periodKey = periodKey
}
public var body: some View {
contentView
.navigationTitle("\(type.label) · \(periodKey)")
.task { viewModel.load() }
.onChange(of: viewModel.uiState) { _, newState in
if case .success(let data) = newState, content.isEmpty, !data.content.isEmpty {
content = data.content
}
}
.onChange(of: viewModel.saved) { _, saved in
if saved { dismiss() }
}
}
@ViewBuilder
private var contentView: some View {
switch viewModel.uiState {
case .loading:
LoadingView()
case .error(let message):
ErrorView(message: message) { viewModel.load() }
case .success(let data):
submitContent(data)
}
}
private func submitContent(_ data: PlanSubmitData) -> some View {
ScrollView {
VStack(spacing: DS.Spacing.md) {
if !data.canSubmit, let message = data.message {
Text(message)
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
.frame(maxWidth: .infinity, alignment: .leading)
}
VStack(alignment: .trailing, spacing: DS.Spacing.xs) {
TextEditor(text: $content)
.frame(minHeight: 120)
.scrollContentBackground(.hidden)
.padding(DS.Spacing.sm)
.background(data.canSubmit ? DS.Color.brandPaper : DS.Color.brandChip)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(DS.Color.disabled, lineWidth: 1)
)
Text("\(content.count)/1000")
.font(.caption)
.foregroundStyle(DS.Color.faintText)
}
HStack {
Text("+ 添加图片(\(data.attachments.count)/4)")
.font(.subheadline)
.foregroundStyle(data.canSubmit ? DS.Color.brandGold : DS.Color.disabled)
Spacer()
}
.padding(DS.Spacing.sm)
.frame(maxWidth: .infinity)
.background(Color.white)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(data.canSubmit ? DS.Color.brandGold : DS.Color.disabled, lineWidth: 1)
)
Button(saveTitle(data)) {
viewModel.save(content: content)
}
.buttonStyle(.borderedProminent)
.disabled(!(data.canSubmit && !content.isEmpty && !viewModel.saving))
HistorySection(history: data.history)
}
.padding(DS.Spacing.md)
}
.background(DS.Color.brandPaper)
}
private func saveTitle(_ data: PlanSubmitData) -> String {
if !data.canSubmit { return "不可提交" }
if data.checked { return "保存修改" }
return "保存计划"
}
}
private struct HistorySection: View {
let history: [MyPlanRecord]
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
Text("历史计划")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
if history.isEmpty {
EmptyStateView(title: "暂无历史计划", systemImage: "doc")
} else {
ForEach(history.indices, id: \.self) { index in
let item = history[index]
VStack(alignment: .leading, spacing: DS.Spacing.xs) {
Text("\(item.submittedTime) · \(item.status)")
.font(.caption)
.foregroundStyle(DS.Color.brandGold)
Text(item.content)
.font(.subheadline)
.foregroundStyle(DS.Color.brandBrown)
}
.padding(DS.Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
}
}

View File

@@ -1,56 +0,0 @@
import CoreDomain
import CoreModel
import Observation
/// 提交计划页状态。
public enum PlanSubmitUiState: Equatable {
case loading
case success(PlanSubmitData)
case error(String)
}
@MainActor
@Observable
public final class PlanSubmitViewModel {
public private(set) var uiState: PlanSubmitUiState = .loading
public var saving = false
public var saved = false
private let planUseCase: PlanUseCase
private let type: PlanType
private let periodKey: String
private var loadTask: Task<Void, Never>?
public init(planUseCase: PlanUseCase, type: PlanType, periodKey: String) {
self.planUseCase = planUseCase
self.type = type
self.periodKey = periodKey
}
public func load() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
self.uiState = .loading
do {
for try await data in self.planUseCase.observePlanSubmit(type: self.type, periodKey: self.periodKey) {
self.uiState = .success(data)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
public func save(content: String) {
guard !saving else { return }
saving = true
Task { [weak self] in
guard let self else { return }
try? await self.planUseCase.savePlan(type: self.type, periodKey: self.periodKey, content: content, attachments: [])
self.saving = false
self.saved = true
}
}
}

View File

@@ -1,9 +0,0 @@
import CoreModel
/// 计划页状态。
public enum PlanUiState {
case loading
case success(PlanRecord)
case loggedOut
case error(String)
}

View File

@@ -1,66 +0,0 @@
import CoreDomain
import CoreModel
import Observation
@MainActor
@Observable
public final class PlanViewModel {
public private(set) var uiState: PlanUiState = .loading
public var selectedType: PlanType = .weekly
private let planUseCase: PlanUseCase
private let sessionUseCase: SessionUseCase
private var loadTask: Task<Void, Never>?
private var dataTask: Task<Void, Never>?
public init(planUseCase: PlanUseCase, sessionUseCase: SessionUseCase) {
self.planUseCase = planUseCase
self.sessionUseCase = sessionUseCase
}
public func load() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
do {
for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() {
self.handleLoginState(isLoggedIn)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
public func selectType(_ type: PlanType) {
guard type != selectedType else { return }
selectedType = type
reloadData()
}
private func handleLoginState(_ isLoggedIn: Bool) {
dataTask?.cancel()
guard isLoggedIn else {
uiState = .loggedOut
return
}
reloadData()
}
private func reloadData() {
dataTask?.cancel()
uiState = .loading
dataTask = Task { [weak self] in
guard let self else { return }
do {
for try await record in self.planUseCase.observePlan(type: self.selectedType) {
self.uiState = .success(record)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
}

View File

@@ -1,168 +0,0 @@
import SwiftUI
import CoreModel
import CoreUI
import DesignSystem
public struct ProfileScreen: View {
private let viewModel: ProfileViewModel
private let onNavigateToAuth: () -> Void
@State private var showLogoutConfirm = false
public init(viewModel: ProfileViewModel, onNavigateToAuth: @escaping () -> Void) {
self.viewModel = viewModel
self.onNavigateToAuth = onNavigateToAuth
}
public var body: some View {
content
.navigationTitle("我的")
.task { viewModel.load() }
}
@ViewBuilder
private var content: some View {
switch viewModel.uiState {
case .loading:
LoadingView()
case .loggedOut:
LoggedOutView(onNavigateToAuth: onNavigateToAuth)
case .error(let message):
ErrorView(message: message) { viewModel.load() }
case .success(let profile):
successContent(profile)
}
}
private func successContent(_ profile: UserProfile) -> some View {
ScrollView {
VStack(spacing: DS.Spacing.md) {
ProfileHeaderCard(profile: profile)
InfoCard(profile: profile)
if !profile.classGroups.isEmpty {
ClassGroupCard(groups: profile.classGroups)
}
Button("退出登录", role: .destructive) {
showLogoutConfirm = true
}
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(DS.Spacing.md)
}
.background(DS.Color.brandPaper)
.alert("退出登录", isPresented: $showLogoutConfirm) {
Button("取消", role: .cancel) {}
Button("确定", role: .destructive) { viewModel.logout() }
} message: {
Text("确定要退出登录吗?退出后需要重新登录。")
}
}
}
// MARK: - 头像卡
private struct ProfileHeaderCard: View {
let profile: UserProfile
var body: some View {
HStack(spacing: DS.Spacing.md) {
ZStack(alignment: .bottomTrailing) {
Text(String(profile.displayName.prefix(1)))
.font(.title.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(DS.Color.brandGold)
.clipShape(Circle())
Text("📷")
.font(.caption2)
.frame(width: 20, height: 20)
.background(DS.Color.brandGold)
.clipShape(Circle())
.overlay(Circle().stroke(DS.Color.brandPaper, lineWidth: 2))
}
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: DS.Spacing.xs) {
Text(profile.displayName)
.font(.title3.bold())
.foregroundStyle(DS.Color.brandDark)
Text(profile.role)
.font(.caption.bold())
.foregroundStyle(DS.Color.brandGold)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, 2)
.background(DS.Color.brandChip)
.clipShape(Capsule())
}
Text(profile.studentId)
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
Spacer()
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
// MARK: - 信息卡
private struct InfoCard: View {
let profile: UserProfile
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
Text("个人信息")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
LabeledRow(label: "姓名", value: profile.name)
LabeledRow(label: "学号", value: profile.studentId)
LabeledRow(label: "手机号", value: profile.phone ?? "未绑定")
LabeledRow(label: "角色", value: profile.role)
LabeledRow(label: "小组", value: profile.group ?? "未分组")
LabeledRow(label: "登录方式", value: profile.loginType)
}
.padding(DS.Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct ClassGroupCard: View {
let groups: [ClassGroup]
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
Text("小组分组")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
ForEach(groups) { group in
LabeledRow(label: group.period, value: "\(group.group) · \(group.role)")
}
}
.padding(DS.Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct LabeledRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(DS.Color.brandMuted)
Spacer()
Text(value)
.font(.subheadline)
.foregroundStyle(DS.Color.brandDark)
}
.padding(.vertical, DS.Spacing.xs)
}
}

View File

@@ -1,9 +0,0 @@
import CoreModel
/// 我的页状态。
public enum ProfileUiState {
case loading
case success(UserProfile)
case loggedOut
case error(String)
}

View File

@@ -1,60 +0,0 @@
import CoreDomain
import CoreModel
import Observation
@MainActor
@Observable
public final class ProfileViewModel {
public private(set) var uiState: ProfileUiState = .loading
private let profileUseCase: ProfileUseCase
private let sessionUseCase: SessionUseCase
private var loadTask: Task<Void, Never>?
private var dataTask: Task<Void, Never>?
public init(profileUseCase: ProfileUseCase, sessionUseCase: SessionUseCase) {
self.profileUseCase = profileUseCase
self.sessionUseCase = sessionUseCase
}
public func load() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
do {
for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() {
self.handleLoginState(isLoggedIn)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
public func logout() {
Task { [weak self] in
try? await self?.sessionUseCase.logout()
}
}
private func handleLoginState(_ isLoggedIn: Bool) {
dataTask?.cancel()
guard isLoggedIn else {
uiState = .loggedOut
return
}
uiState = .loading
dataTask = Task { [weak self] in
guard let self else { return }
do {
for try await profile in self.profileUseCase.observeProfile() {
self.uiState = .success(profile)
}
} catch is CancellationError {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
}

View File

@@ -1,10 +1,6 @@
# ios-client # ios-client
SwiftUI + MVVM 单向数据流的 iOS「打卡/计划管理」客户端,采用 SPM 多 target 模块化,架构对齐 Android NIA(`core/*` + `feature/*`),功能对齐 `wechat-clock-mini`(小程序)与 `android-client`(Android 端)。 SwiftUI + MVVM 单向数据流的 iOS「打卡/习惯追踪」客户端,采用 SPM 多 target 模块化,架构对齐 Android NIA(`core/*` + `feature/*`)。
## 功能
四个主入口 Tab:**首页 / 打卡 / 计划 / 我的**,另有独立**登录页**(手机号 + 短信验证码)与**提交计划页**。当前数据层为 Fake 实现(进程内 mock),尚未接真实后端。
## 架构 ## 架构
@@ -14,7 +10,7 @@ App(组合根)──► feature/* ──► core/*
└──► CoreRepository ──► CoreNetwork / CoreDatabase / CoreDomain └──► CoreRepository ──► CoreNetwork / CoreDatabase / CoreDomain
``` ```
依赖单向无环,由 SPM target 依赖天然强制(import 不到即编不过)。详见 [docs/architecture.md](docs/architecture.md)。 依赖单向无环,由 SPM target 依赖天然强制(import 不到即编不过)。
## 模块 ## 模块
@@ -26,13 +22,10 @@ App(组合根)──► feature/* ──► core/*
| CoreRepository | Repository 实现(Fake / OfflineFirst)| | CoreRepository | Repository 实现(Fake / OfflineFirst)|
| CoreNetwork | DTO + URLSession APIClient | | CoreNetwork | DTO + URLSession APIClient |
| CoreDatabase | SwiftData(`@Model` + `ModelContainer`)| | CoreDatabase | SwiftData(`@Model` + `ModelContainer`)|
| DesignSystem | 设计 tokens(颜色/渐变/间距/圆角)| | DesignSystem | 设计 tokens(颜色/间距/圆角)|
| CoreUI | 可复用 SwiftUI 组件 | | CoreUI | 可复用 SwiftUI 组件 |
| FeatureHome | 首页(落地页:Hero + 模块卡 + 产品介绍)| | FeatureHome | 首页(打卡列表)|
| FeatureCheckin | 打卡(状态卡 + 状态 pill + 小组/个人记录)| | FeatureAuth | 登录 / 我的(占位)|
| FeaturePlan | 计划(周/月/年 + 小组统计 + 提交计划页)|
| FeatureProfile | 我的(资料 + 小组分组 + 退出登录)|
| FeatureAuth | 登录(手机号 + 短信验证码)|
| App | 组合根(DI + 导航)| | App | 组合根(DI + 导航)|
## 环境要求 ## 环境要求
@@ -55,9 +48,9 @@ brew install swiftlint # 首次
swiftlint lint swiftlint lint
``` ```
## 文档与协作规范 ## 目录与协作规范
见 [CLAUDE.md](CLAUDE.md)、[docs/architecture.md](docs/architecture.md)、[docs/progress.md](docs/progress.md) 与 [CONTRIBUTING.md](CONTRIBUTING.md)。 见 [CLAUDE.md](CLAUDE.md) 与 [CONTRIBUTING.md](CONTRIBUTING.md)。
## License ## License

View File

@@ -1,51 +0,0 @@
# 架构
## 分层与依赖
依赖单向无环:`App → feature/* → core/*`,`App` 额外依赖 `CoreRepository` 装配具体实现。
```
App ──► FeatureHome / FeatureCheckin / FeaturePlan / FeatureProfile / FeatureAuth
│
└──► CoreDomain(协议 + UseCase)──► CoreModel
▲
└── CoreRepository(Fake* 实现)
```
- **CoreModel**:领域模型,`Sendable` 值类型,叶子不依赖任何模块。
- **CoreDomain**:Repository 协议 + UseCase,纯 Swift(不 import SwiftUI / 数据框架)。
- **CoreRepository**:Fake 实现(当前),未来替换为 OfflineFirst(编排 CoreNetwork / CoreDatabase)。
## 数据流(MVVM 单向)
```
Screen(.task) ──► ViewModel.load() ──► UseCase ──► Repository(AsyncThrowingStream)
▲ │
└────────── uiState(@Observable)◄──── yield
```
- ViewModel:`@MainActor @Observable final class`,`private(set) var uiState`。
- UiState:`enum` 关联值(对应 Kotlin sealed interface)。
- 每次 `load()` 先 `loadTask?.cancel()` 再开新 `Task`,实现 `flatMapLatest` 语义。
## 会话 / 登录态
`SessionRepository` 持进程内登录态(Fake 用 Combine `CurrentValueSubject<Bool, Never>`),`AppContainer` 持单例注入所有 ViewModel。
- 外层 `Task` 观察 `observeIsLoggedIn()`;登录态变化 → 取消内层数据 `Task` → 重新订阅。
- **Home**:已登录 `observeHome()`(isGuest=false)/ 游客 `observeGuestHome()`(isGuest=true),无 `loggedOut` 态(游客落地页即未登录首页)。
- **checkin / plan / profile**:登录态 false → `.loggedOut`;true → `.loading` → 数据。
## Feature 一览
| Feature | UiState | 数据源(Repository)|
|---|---|---|
| FeatureHome | loading / success(HomeData, isGuest) / error | observeHome / observeGuestHome |
| FeatureCheckin | loading / success(CheckinRecord) / loggedOut / error | observeCheckin + submitCheckin |
| FeaturePlan | loading / success(PlanRecord) / loggedOut / error | observePlan(+ observePlanSubmit / savePlan)|
| FeatureProfile | loading / success(UserProfile) / loggedOut / error | observeProfile |
| FeatureAuth | 表单态(isSendingCode / countdownSeconds / isSubmitting / errorMessage)| requestSmsCode / login |
## 设计 tokens
品牌色 / 状态色 / 渐变定义在 `DesignSystem/DesignTokens.swift`(`DS.Color` / `DS.Gradient`),对齐 wechat-clock-mini / android-client 的 designsystem。

View File

@@ -1,31 +0,0 @@
# 工作状态
> 跨会话状态快照,变更后请更新。
## 当前状态
- ✅ 四主入口 Tab(首页 / 打卡 / 计划 / 我的)+ 登录页 + 提交计划页,全部 SwiftUI 实现。
- ✅ 数据层为 Fake 实现(进程内 mock),对齐 android-client 的简化形态。
- ✅ 登录态驱动:`SessionRepository`(Combine `CurrentValueSubject`)+ 各页 `loggedOut` 态。
- ✅ `swift build` 与模拟器 `xcodebuild` 均通过。
## 已完成
- [x] CoreModel 领域模型(Home / Checkin / Plan / Profile / Auth-Session)
- [x] CoreDomain Repository 协议 + UseCase(Home / Checkin / Plan / Profile / Session)
- [x] CoreRepository Fake 实现(含可提交的 checkin / plan、会话)
- [x] FeatureHome(落地页)/ FeatureCheckin / FeaturePlan(+提交页)/ FeatureProfile / FeatureAuth
- [x] DesignSystem 品牌 tokens;AppContainer DI + AppRootView 导航(Tab + sheet 登录 + 推入提交页)
## 下一步
1. 接入真实后端:CoreNetwork 定义 API + DTO(对应 wechat-backend `/api/v1/*`),CoreRepository 替换为 OfflineFirst。
2. CoreDatabase 落地 SwiftData schema(打卡 / 计划 / 用户)。
3. 真实图片上传(计划附件、头像)与隐私协议跳转页。
4. 单元测试(ViewModel / UseCase / Fake 仓库)。
## 遗留项(暂缓)
- 计划附件上传、头像上传仍是占位(与 android 一致)。
- `webview`(H5)能力未引入,首页为纯原生落地页。
- 未跑 `swiftlint`(本机未安装)。