Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5c6a6ddfd | ||
|
|
547ce92050 | ||
|
|
affe326e90 | ||
|
|
7f6cbb3190 | ||
|
|
4769aef59e | ||
|
|
3e90f8edee | ||
|
|
2986340ad8 | ||
|
|
542f08c0a8 | ||
|
|
c39a324534 | ||
|
|
ec1eed8f04 | ||
|
|
14eb5ad44d | ||
|
|
ac4492a8f1 |
27
CLAUDE.md
27
CLAUDE.md
@@ -2,7 +2,9 @@
|
||||
|
||||
## 项目概览
|
||||
|
||||
SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/习惯追踪」客户端,架构对齐 Android NIA(`core/*` + `feature/*`)。
|
||||
SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/计划管理」客户端,功能对齐 `wechat-clock-mini`(小程序,生产参照)与 `android-client`(Android 端,原生简化参照)。架构对齐 Android NIA(`core/*` + `feature/*`)。
|
||||
|
||||
四个主入口 Tab(顺序):**首页 / 打卡 / 计划 / 我的**;另有独立**登录页**(手机号 + 短信验证码)与**提交计划页**(计划 Tab 内推入)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -17,7 +19,11 @@ SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/习惯
|
||||
- `Sources/CoreDatabase`:SwiftData(`@Model` + `ModelContainer`)
|
||||
- `Sources/DesignSystem`:设计 tokens
|
||||
- `Sources/CoreUI`:可复用 SwiftUI 组件
|
||||
- `Sources/FeatureHome` / `Sources/FeatureAuth`:Feature 模块
|
||||
- `Sources/FeatureHome`:首页(落地页:Hero + 模块卡 + 产品介绍)
|
||||
- `Sources/FeatureCheckin`:打卡(状态卡 + 状态 pill + 小组/个人记录)
|
||||
- `Sources/FeaturePlan`:计划(周/月/年 + 小组统计 + 提交计划页)
|
||||
- `Sources/FeatureProfile`:我的(资料 + 小组分组 + 退出登录)
|
||||
- `Sources/FeatureAuth`:登录(手机号 + 短信验证码 + 60s 倒计时)
|
||||
|
||||
## 依赖方向(硬约束,违反即编译不过)
|
||||
|
||||
@@ -44,7 +50,14 @@ SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/习惯
|
||||
| Hilt `@Inject` / AppContainer | 手写 `AppContainer` |
|
||||
| Retrofit + kotlinx.serialization | URLSession + Codable |
|
||||
| Room | SwiftData |
|
||||
| DataStore | `@AppStorage` / UserDefaults |
|
||||
| DataStore(会话持久化) | Combine `CurrentValueSubject`(进程内 Fake 会话)|
|
||||
|
||||
## 会话 / 登录态
|
||||
|
||||
- `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`。
|
||||
|
||||
## 代码风格
|
||||
|
||||
@@ -79,3 +92,11 @@ swiftlint lint --strict
|
||||
2. 新建 `Sources/FeatureXxx/`:Screen + ViewModel + UiState(enum)。
|
||||
3. UseCase 放 `CoreDomain`,Repository 实现放 `CoreRepository`。
|
||||
4. 在 `App` 的 `AppContainer` 装配依赖、`AppRootView` 接入导航。
|
||||
|
||||
## 文档地图
|
||||
|
||||
| 主题 | 文档 |
|
||||
|---|---|
|
||||
| 架构与数据流 | [docs/architecture.md](docs/architecture.md) |
|
||||
| 工作状态与遗留项 | [docs/progress.md](docs/progress.md) |
|
||||
| 贡献指南 | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
- 跨模块符号 `public` + `///` 文档注释。
|
||||
- 领域模型标记 `Sendable`。
|
||||
- ViewModel:`@MainActor @Observable`,UiState 用 `enum`。
|
||||
- 登录态经 `SessionRepository` 驱动,不要在各页面各自维护登录态副本。
|
||||
- 引入任何第三方依赖前,需在 PR 说明必要性(项目默认零第三方框架)。
|
||||
|
||||
## 提交流程
|
||||
|
||||
1. 从 `main` 新建分支:`feat/xxx`、`fix/xxx`。
|
||||
1. 从 `dev` 新建分支:`feat/xxx`、`fix/xxx`(简化 GitFlow:feature 从 `dev` 拉、merge 回 `dev`,`dev` 稳定后再 merge 到 `main`)。
|
||||
2. 本地跑通:
|
||||
```bash
|
||||
swiftlint lint
|
||||
|
||||
@@ -38,12 +38,26 @@ let package = Package(
|
||||
dependencies: ["CoreDomain", "CoreUI", "CoreModel", "CoreCommon"]
|
||||
),
|
||||
.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 装配)
|
||||
.target(
|
||||
name: "App",
|
||||
dependencies: [
|
||||
"FeatureHome", "FeatureAuth", "CoreRepository",
|
||||
"FeatureHome", "FeatureAuth",
|
||||
"FeaturePlan", "FeatureCheckin", "FeatureProfile",
|
||||
"CoreRepository",
|
||||
"CoreUI", "DesignSystem", "CoreDomain", "CoreModel",
|
||||
]
|
||||
),
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
import CoreDomain
|
||||
import CoreModel
|
||||
import CoreRepository
|
||||
import FeatureAuth
|
||||
import FeatureCheckin
|
||||
import FeatureHome
|
||||
import FeaturePlan
|
||||
import FeatureProfile
|
||||
|
||||
/// 手写组合根 / DI 容器(对应 Android Hilt 的 AppContainer)。
|
||||
@MainActor
|
||||
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 {
|
||||
let repository: HomeRepository = FakeHomeRepository()
|
||||
let useCase = HomeUseCase(repository: repository)
|
||||
return HomeViewModel(homeUseCase: useCase)
|
||||
return HomeViewModel(homeUseCase: useCase, sessionUseCase: sessionUseCase)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,73 @@
|
||||
import SwiftUI
|
||||
import CoreUI
|
||||
import FeatureAuth
|
||||
import FeatureCheckin
|
||||
import FeatureHome
|
||||
import FeaturePlan
|
||||
import FeatureProfile
|
||||
|
||||
public struct AppRootView: View {
|
||||
@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() {
|
||||
_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 {
|
||||
TabView {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
HomeScreen(viewModel: homeViewModel)
|
||||
HomeScreen(
|
||||
viewModel: homeViewModel,
|
||||
onNavigateToCheckin: { selectedTab = .checkin },
|
||||
onNavigateToPlan: { selectedTab = .plan },
|
||||
onNavigateToAuth: { isAuthPresented = true }
|
||||
)
|
||||
}
|
||||
.tabItem { Label("首页", systemImage: "house.fill") }
|
||||
.tag(AppTab.home)
|
||||
|
||||
NavigationStack {
|
||||
PlaceholderView("计划", systemImage: "calendar")
|
||||
}
|
||||
.tabItem { Label("计划", systemImage: "calendar") }
|
||||
|
||||
NavigationStack {
|
||||
PlaceholderView("打卡", systemImage: "checkmark.seal.fill")
|
||||
CheckinScreen(viewModel: checkinViewModel, onNavigateToAuth: { isAuthPresented = true })
|
||||
}
|
||||
.tabItem { Label("打卡", systemImage: "checkmark.seal.fill") }
|
||||
.tag(AppTab.checkin)
|
||||
|
||||
NavigationStack {
|
||||
AuthScreen()
|
||||
PlanScreen(
|
||||
viewModel: planViewModel,
|
||||
onNavigateToAuth: { isAuthPresented = true },
|
||||
makePlanSubmitViewModel: { type, periodKey in
|
||||
AppContainer.makePlanSubmitViewModel(type: type, periodKey: periodKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
.tabItem { Label("计划", systemImage: "calendar") }
|
||||
.tag(AppTab.plan)
|
||||
|
||||
NavigationStack {
|
||||
ProfileScreen(viewModel: profileViewModel, onNavigateToAuth: { isAuthPresented = true })
|
||||
}
|
||||
.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
|
||||
}
|
||||
|
||||
9
IosClientKit/Sources/CoreDomain/CheckinRepository.swift
Normal file
9
IosClientKit/Sources/CoreDomain/CheckinRepository.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import CoreModel
|
||||
|
||||
/// 打卡数据仓库协议(定义在 domain 层,实现放 CoreRepository)。
|
||||
public protocol CheckinRepository: Sendable {
|
||||
/// 观察打卡数据。
|
||||
func observeCheckin() -> AsyncThrowingStream<CheckinRecord, Error>
|
||||
/// 提交打卡状态。
|
||||
func submitCheckin(status: CheckinStatus) async throws
|
||||
}
|
||||
18
IosClientKit/Sources/CoreDomain/CheckinUseCase.swift
Normal file
18
IosClientKit/Sources/CoreDomain/CheckinUseCase.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import CoreModel
|
||||
|
||||
/// Home 数据仓库协议(定义在 domain 层,实现放 CoreRepository)。
|
||||
/// Kotlin: interface HomeRepository { fun observeHome(): Flow<HomeData> }
|
||||
public protocol HomeRepository: Sendable {
|
||||
/// Kotlin: Flow<HomeData> → Swift: AsyncThrowingStream<HomeData, Error>
|
||||
/// 已登录首页。
|
||||
func observeHome() -> AsyncThrowingStream<HomeData, Error>
|
||||
/// 游客首页。
|
||||
func observeGuestHome() -> AsyncThrowingStream<HomeData, Error>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import CoreModel
|
||||
|
||||
/// 获取 Home 数据的用例。
|
||||
/// Kotlin: class HomeUseCase(...) { suspend operator fun invoke(): HomeData }
|
||||
public struct HomeUseCase: Sendable {
|
||||
private let repository: HomeRepository
|
||||
|
||||
@@ -9,8 +8,13 @@ public struct HomeUseCase: Sendable {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
/// Kotlin: Flow<HomeData> → Swift: AsyncThrowingStream<HomeData, Error>
|
||||
/// 已登录首页。
|
||||
public func observeHome() -> AsyncThrowingStream<HomeData, Error> {
|
||||
repository.observeHome()
|
||||
}
|
||||
|
||||
/// 游客首页。
|
||||
public func observeGuestHome() -> AsyncThrowingStream<HomeData, Error> {
|
||||
repository.observeGuestHome()
|
||||
}
|
||||
}
|
||||
|
||||
8
IosClientKit/Sources/CoreDomain/PlanRepository.swift
Normal file
8
IosClientKit/Sources/CoreDomain/PlanRepository.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
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
|
||||
}
|
||||
22
IosClientKit/Sources/CoreDomain/PlanUseCase.swift
Normal file
22
IosClientKit/Sources/CoreDomain/PlanUseCase.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
7
IosClientKit/Sources/CoreDomain/ProfileRepository.swift
Normal file
7
IosClientKit/Sources/CoreDomain/ProfileRepository.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import CoreModel
|
||||
|
||||
/// 个人资料仓库协议(定义在 domain 层,实现放 CoreRepository)。
|
||||
public protocol ProfileRepository: Sendable {
|
||||
/// Kotlin: Flow<UserProfile> → Swift: AsyncThrowingStream<UserProfile, Error>
|
||||
func observeProfile() -> AsyncThrowingStream<UserProfile, Error>
|
||||
}
|
||||
14
IosClientKit/Sources/CoreDomain/ProfileUseCase.swift
Normal file
14
IosClientKit/Sources/CoreDomain/ProfileUseCase.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
28
IosClientKit/Sources/CoreDomain/SessionRepository.swift
Normal file
28
IosClientKit/Sources/CoreDomain/SessionRepository.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
24
IosClientKit/Sources/CoreDomain/SessionUseCase.swift
Normal file
24
IosClientKit/Sources/CoreDomain/SessionUseCase.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
/// 会话用例。
|
||||
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()
|
||||
}
|
||||
}
|
||||
10
IosClientKit/Sources/CoreModel/Auth.swift
Normal file
10
IosClientKit/Sources/CoreModel/Auth.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// 会话(极简,仅 token)。
|
||||
public struct Session: Hashable, Sendable {
|
||||
public let token: String
|
||||
|
||||
public init(token: String) {
|
||||
self.token = token
|
||||
}
|
||||
}
|
||||
72
IosClientKit/Sources/CoreModel/Checkin.swift
Normal file
72
IosClientKit/Sources/CoreModel/Checkin.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Home 页面的聚合数据(对应 Android 端 HomeData)。
|
||||
public struct HomeData: Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
/// 顶部问候语,例如「早上好,Andy」。
|
||||
public let greeting: String
|
||||
/// 今日打卡摘要,例如「已完成 2 / 4」。
|
||||
public let summary: String
|
||||
/// 今日待打卡的习惯列表。
|
||||
public let habits: [Habit]
|
||||
/// 首页模块跳转目标。
|
||||
public enum ModuleDestination: Hashable, Sendable {
|
||||
case checkin
|
||||
case plan
|
||||
case about
|
||||
}
|
||||
|
||||
public init(id: String, greeting: String, summary: String, habits: [Habit]) {
|
||||
/// 首页功能模块卡片(打卡介绍 / 计划介绍 / 关于我们)。
|
||||
public struct HomeModule: Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let emoji: String
|
||||
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.greeting = greeting
|
||||
self.summary = summary
|
||||
self.habits = habits
|
||||
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 displayName: String
|
||||
public let encouragement: String
|
||||
public let modules: [HomeModule]
|
||||
public let intros: [IntroCard]
|
||||
|
||||
public init(
|
||||
greeting: String,
|
||||
displayName: String,
|
||||
encouragement: String,
|
||||
modules: [HomeModule],
|
||||
intros: [IntroCard]
|
||||
) {
|
||||
self.greeting = greeting
|
||||
self.displayName = displayName
|
||||
self.encouragement = encouragement
|
||||
self.modules = modules
|
||||
self.intros = intros
|
||||
}
|
||||
}
|
||||
|
||||
119
IosClientKit/Sources/CoreModel/Plan.swift
Normal file
119
IosClientKit/Sources/CoreModel/Plan.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
53
IosClientKit/Sources/CoreModel/Profile.swift
Normal file
53
IosClientKit/Sources/CoreModel/Profile.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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"),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,19 @@ import CoreCommon
|
||||
import CoreDomain
|
||||
import CoreModel
|
||||
|
||||
/// Fake 仓库实现(骨架阶段返回假数据,后续替换为 OfflineFirst 实现)。
|
||||
/// Fake 首页仓库(骨架阶段返回假数据,后续替换为 OfflineFirst 实现)。
|
||||
public struct FakeHomeRepository: HomeRepository {
|
||||
public init() {}
|
||||
|
||||
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
|
||||
let task = Task {
|
||||
do {
|
||||
@@ -14,7 +22,7 @@ public struct FakeHomeRepository: HomeRepository {
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
try Task.checkCancellation()
|
||||
Logger.info("FakeHomeRepository 返回假数据")
|
||||
continuation.yield(Self.fakeData)
|
||||
continuation.yield(data)
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
@@ -26,15 +34,32 @@ public struct FakeHomeRepository: HomeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private static let fakeData = HomeData(
|
||||
id: "home-1",
|
||||
greeting: "早上好,Andy",
|
||||
summary: "今日已完成 2 / 4",
|
||||
habits: [
|
||||
Habit(id: "habit-1", title: "晨跑 5 公里", detail: "保持运动习惯", isCheckedInToday: true, streakDays: 12),
|
||||
Habit(id: "habit-2", title: "阅读 30 分钟", detail: "每天进步一点", isCheckedInToday: true, streakDays: 8),
|
||||
Habit(id: "habit-3", title: "喝水 8 杯", detail: "健康补水", isCheckedInToday: false, streakDays: 0),
|
||||
Habit(id: "habit-4", title: "写日记", detail: "记录今日所思", isCheckedInToday: false, streakDays: 3),
|
||||
]
|
||||
private static let modules = [
|
||||
HomeModule(id: "checkin", emoji: "📋", title: "打卡介绍", description: "每日打卡记录", destination: .checkin),
|
||||
HomeModule(id: "plan", emoji: "📝", title: "计划介绍", description: "周/月/年计划", destination: .plan),
|
||||
HomeModule(id: "about", emoji: "ℹ️", title: "关于我们", description: "了解明缘云道场", destination: .about),
|
||||
]
|
||||
|
||||
private static let intros = [
|
||||
IntroCard(title: "📚 学习成长工具", body: "明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯,实现持续进步。好好学习,天天向上。"),
|
||||
IntroCard(title: "✅ 周打卡", body: "每周打卡,支持多种状态记录(正常、迟到、请假、早退、不参加讨论等)。查看小组打卡情况,与同学们互相监督、共同进步。"),
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
136
IosClientKit/Sources/CoreRepository/FakePlanRepository.swift
Normal file
136
IosClientKit/Sources/CoreRepository/FakePlanRepository.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
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),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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: "组长"),
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
20
IosClientKit/Sources/CoreUI/LoggedOutView.swift
Normal file
20
IosClientKit/Sources/CoreUI/LoggedOutView.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 设计 tokens(颜色 / 间距 / 圆角),对应 Android DesignSystem。
|
||||
/// 设计 tokens(颜色 / 渐变 / 间距 / 圆角),对应 Android DesignSystem。
|
||||
public enum DS {
|
||||
public enum Color {
|
||||
public static let accent = SwiftUI.Color.accentColor
|
||||
public static let success = SwiftUI.Color.green
|
||||
public static let danger = SwiftUI.Color.red
|
||||
public static let danger = SwiftUI.Color(hex: 0xC0392B)
|
||||
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 {
|
||||
@@ -23,3 +81,16 @@ public enum DS {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,100 @@
|
||||
import SwiftUI
|
||||
import DesignSystem
|
||||
|
||||
/// FeatureAuth 占位(后续实现登录 / 注册)。
|
||||
public struct AuthScreen: View {
|
||||
public init() {}
|
||||
private let viewModel: AuthViewModel
|
||||
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 {
|
||||
ContentUnavailableView("登录", systemImage: "person.crop.circle")
|
||||
NavigationStack {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
65
IosClientKit/Sources/FeatureAuth/AuthViewModel.swift
Normal file
65
IosClientKit/Sources/FeatureAuth/AuthViewModel.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
286
IosClientKit/Sources/FeatureCheckin/CheckinScreen.swift
Normal file
286
IosClientKit/Sources/FeatureCheckin/CheckinScreen.swift
Normal file
@@ -0,0 +1,286 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
15
IosClientKit/Sources/FeatureCheckin/CheckinUiState.swift
Normal file
15
IosClientKit/Sources/FeatureCheckin/CheckinUiState.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import CoreModel
|
||||
|
||||
/// 打卡页状态。
|
||||
public enum CheckinUiState {
|
||||
case loading
|
||||
case success(CheckinRecord)
|
||||
case loggedOut
|
||||
case error(String)
|
||||
}
|
||||
|
||||
/// 记录面板 tab。
|
||||
public enum CheckinTab: Hashable {
|
||||
case group
|
||||
case personal
|
||||
}
|
||||
68
IosClientKit/Sources/FeatureCheckin/CheckinViewModel.swift
Normal file
68
IosClientKit/Sources/FeatureCheckin/CheckinViewModel.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,22 @@ import DesignSystem
|
||||
|
||||
public struct HomeScreen: View {
|
||||
private let viewModel: HomeViewModel
|
||||
private let onNavigateToCheckin: () -> Void
|
||||
private let onNavigateToPlan: () -> Void
|
||||
private let onNavigateToAuth: () -> Void
|
||||
|
||||
public init(viewModel: HomeViewModel) {
|
||||
@State private var showLoginDialog = false
|
||||
|
||||
public init(
|
||||
viewModel: HomeViewModel,
|
||||
onNavigateToCheckin: @escaping () -> Void,
|
||||
onNavigateToPlan: @escaping () -> Void,
|
||||
onNavigateToAuth: @escaping () -> Void
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.onNavigateToCheckin = onNavigateToCheckin
|
||||
self.onNavigateToPlan = onNavigateToPlan
|
||||
self.onNavigateToAuth = onNavigateToAuth
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -21,57 +34,153 @@ public struct HomeScreen: View {
|
||||
switch viewModel.uiState {
|
||||
case .loading:
|
||||
LoadingView()
|
||||
case .empty:
|
||||
EmptyStateView(title: "今天还没有待打卡的习惯", systemImage: "checkmark.circle")
|
||||
case .success(let data):
|
||||
successContent(data)
|
||||
case .error(let message):
|
||||
ErrorView(message: message) { viewModel.load() }
|
||||
case .success(let data, let isGuest):
|
||||
successContent(data, isGuest: isGuest)
|
||||
}
|
||||
}
|
||||
|
||||
private func successContent(_ data: HomeData) -> some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
|
||||
Text(data.greeting)
|
||||
.font(.largeTitle.bold())
|
||||
Text(data.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
private func successContent(_ data: HomeData, isGuest: Bool) -> some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Spacing.md) {
|
||||
HeroCard(data: data)
|
||||
ModuleGrid(modules: data.modules) { module in
|
||||
handleModuleTap(module, isGuest: isGuest)
|
||||
}
|
||||
.padding(.vertical, DS.Spacing.sm)
|
||||
IntroSection(intros: data.intros)
|
||||
}
|
||||
.padding(DS.Spacing.md)
|
||||
}
|
||||
.background(DS.Color.brandPaper)
|
||||
.alert("登录提示", isPresented: $showLoginDialog) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("去登录") { onNavigateToAuth() }
|
||||
} message: {
|
||||
Text("登录后使用此功能,是否前往登录?")
|
||||
}
|
||||
}
|
||||
|
||||
Section("今日打卡") {
|
||||
ForEach(data.habits) { habit in
|
||||
HabitRow(habit: habit)
|
||||
}
|
||||
}
|
||||
private func handleModuleTap(_ module: HomeModule, isGuest: Bool) {
|
||||
if isGuest {
|
||||
showLoginDialog = true
|
||||
return
|
||||
}
|
||||
switch module.destination {
|
||||
case .checkin: onNavigateToCheckin()
|
||||
case .plan: onNavigateToPlan()
|
||||
case .about: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HabitRow: View {
|
||||
let habit: Habit
|
||||
// MARK: - Hero 卡
|
||||
|
||||
private struct HeroCard: View {
|
||||
let data: HomeData
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: habit.isCheckedInToday ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(habit.isCheckedInToday ? DS.Color.success : DS.Color.warning)
|
||||
.font(.title3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(habit.title)
|
||||
.font(.headline)
|
||||
Text(habit.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("🔥 \(habit.streakDays) 天")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
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(.vertical, DS.Spacing.xs)
|
||||
.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 {
|
||||
let module: HomeModule
|
||||
let onTap: (HomeModule) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: DS.Spacing.sm) {
|
||||
Text(module.emoji)
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(DS.Color.brandChip)
|
||||
.clipShape(Circle())
|
||||
Text(module.title)
|
||||
.font(.footnote.bold())
|
||||
.foregroundStyle(DS.Color.brandDark)
|
||||
Text(module.description)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import CoreModel
|
||||
|
||||
/// Kotlin: sealed interface HomeUiState → Swift: enum 关联值
|
||||
/// 首页状态(对应 Kotlin sealed interface UiState)。
|
||||
public enum HomeUiState {
|
||||
case loading
|
||||
case success(HomeData)
|
||||
case empty
|
||||
case success(HomeData, isGuest: Bool)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
@@ -8,24 +8,47 @@ public final class HomeViewModel {
|
||||
public private(set) var uiState: HomeUiState = .loading
|
||||
|
||||
private let homeUseCase: HomeUseCase
|
||||
private let sessionUseCase: SessionUseCase
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var dataTask: Task<Void, Never>?
|
||||
|
||||
public init(homeUseCase: HomeUseCase) {
|
||||
public init(homeUseCase: HomeUseCase, sessionUseCase: SessionUseCase) {
|
||||
self.homeUseCase = homeUseCase
|
||||
self.sessionUseCase = sessionUseCase
|
||||
}
|
||||
|
||||
public func load() {
|
||||
// flatMapLatest 的等价:新 load 前先取消旧的观察。
|
||||
// flatMapLatest 的等价:登录态变化时重新订阅对应数据源。
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
self.uiState = .loading
|
||||
do {
|
||||
for try await data in self.homeUseCase.observeHome() {
|
||||
self.uiState = data.habits.isEmpty ? .empty : .success(data)
|
||||
for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() {
|
||||
self.reload(isLoggedIn: isLoggedIn)
|
||||
}
|
||||
} 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 {
|
||||
// 被更新的 load() 取代,保持当前状态即可。
|
||||
} catch {
|
||||
self.uiState = .error(error.localizedDescription)
|
||||
}
|
||||
|
||||
298
IosClientKit/Sources/FeaturePlan/PlanScreen.swift
Normal file
298
IosClientKit/Sources/FeaturePlan/PlanScreen.swift
Normal file
@@ -0,0 +1,298 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
135
IosClientKit/Sources/FeaturePlan/PlanSubmitScreen.swift
Normal file
135
IosClientKit/Sources/FeaturePlan/PlanSubmitScreen.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
IosClientKit/Sources/FeaturePlan/PlanSubmitViewModel.swift
Normal file
56
IosClientKit/Sources/FeaturePlan/PlanSubmitViewModel.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
9
IosClientKit/Sources/FeaturePlan/PlanUiState.swift
Normal file
9
IosClientKit/Sources/FeaturePlan/PlanUiState.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import CoreModel
|
||||
|
||||
/// 计划页状态。
|
||||
public enum PlanUiState {
|
||||
case loading
|
||||
case success(PlanRecord)
|
||||
case loggedOut
|
||||
case error(String)
|
||||
}
|
||||
66
IosClientKit/Sources/FeaturePlan/PlanViewModel.swift
Normal file
66
IosClientKit/Sources/FeaturePlan/PlanViewModel.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
IosClientKit/Sources/FeatureProfile/ProfileScreen.swift
Normal file
168
IosClientKit/Sources/FeatureProfile/ProfileScreen.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
9
IosClientKit/Sources/FeatureProfile/ProfileUiState.swift
Normal file
9
IosClientKit/Sources/FeatureProfile/ProfileUiState.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import CoreModel
|
||||
|
||||
/// 我的页状态。
|
||||
public enum ProfileUiState {
|
||||
case loading
|
||||
case success(UserProfile)
|
||||
case loggedOut
|
||||
case error(String)
|
||||
}
|
||||
60
IosClientKit/Sources/FeatureProfile/ProfileViewModel.swift
Normal file
60
IosClientKit/Sources/FeatureProfile/ProfileViewModel.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
README.md
21
README.md
@@ -1,6 +1,10 @@
|
||||
# ios-client
|
||||
|
||||
SwiftUI + MVVM 单向数据流的 iOS「打卡/习惯追踪」客户端,采用 SPM 多 target 模块化,架构对齐 Android NIA(`core/*` + `feature/*`)。
|
||||
SwiftUI + MVVM 单向数据流的 iOS「打卡/计划管理」客户端,采用 SPM 多 target 模块化,架构对齐 Android NIA(`core/*` + `feature/*`),功能对齐 `wechat-clock-mini`(小程序)与 `android-client`(Android 端)。
|
||||
|
||||
## 功能
|
||||
|
||||
四个主入口 Tab:**首页 / 打卡 / 计划 / 我的**,另有独立**登录页**(手机号 + 短信验证码)与**提交计划页**。当前数据层为 Fake 实现(进程内 mock),尚未接真实后端。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -10,7 +14,7 @@ App(组合根)──► feature/* ──► core/*
|
||||
└──► CoreRepository ──► CoreNetwork / CoreDatabase / CoreDomain
|
||||
```
|
||||
|
||||
依赖单向无环,由 SPM target 依赖天然强制(import 不到即编不过)。
|
||||
依赖单向无环,由 SPM target 依赖天然强制(import 不到即编不过)。详见 [docs/architecture.md](docs/architecture.md)。
|
||||
|
||||
## 模块
|
||||
|
||||
@@ -22,10 +26,13 @@ App(组合根)──► feature/* ──► core/*
|
||||
| CoreRepository | Repository 实现(Fake / OfflineFirst)|
|
||||
| CoreNetwork | DTO + URLSession APIClient |
|
||||
| CoreDatabase | SwiftData(`@Model` + `ModelContainer`)|
|
||||
| DesignSystem | 设计 tokens(颜色/间距/圆角)|
|
||||
| DesignSystem | 设计 tokens(颜色/渐变/间距/圆角)|
|
||||
| CoreUI | 可复用 SwiftUI 组件 |
|
||||
| FeatureHome | 首页(打卡列表)|
|
||||
| FeatureAuth | 登录 / 我的(占位)|
|
||||
| FeatureHome | 首页(落地页:Hero + 模块卡 + 产品介绍)|
|
||||
| FeatureCheckin | 打卡(状态卡 + 状态 pill + 小组/个人记录)|
|
||||
| FeaturePlan | 计划(周/月/年 + 小组统计 + 提交计划页)|
|
||||
| FeatureProfile | 我的(资料 + 小组分组 + 退出登录)|
|
||||
| FeatureAuth | 登录(手机号 + 短信验证码)|
|
||||
| App | 组合根(DI + 导航)|
|
||||
|
||||
## 环境要求
|
||||
@@ -48,9 +55,9 @@ brew install swiftlint # 首次
|
||||
swiftlint lint
|
||||
```
|
||||
|
||||
## 目录与协作规范
|
||||
## 文档与协作规范
|
||||
|
||||
见 [CLAUDE.md](CLAUDE.md) 与 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
见 [CLAUDE.md](CLAUDE.md)、[docs/architecture.md](docs/architecture.md)、[docs/progress.md](docs/progress.md) 与 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
## License
|
||||
|
||||
|
||||
51
docs/architecture.md
Normal file
51
docs/architecture.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 架构
|
||||
|
||||
## 分层与依赖
|
||||
|
||||
依赖单向无环:`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。
|
||||
31
docs/progress.md
Normal file
31
docs/progress.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 工作状态
|
||||
|
||||
> 跨会话状态快照,变更后请更新。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- ✅ 四主入口 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`(本机未安装)。
|
||||
Reference in New Issue
Block a user