Compare commits

..

22 Commits

Author SHA1 Message Date
AndyChen1024
a5c6a6ddfd docs: 更新项目文档与架构说明 2026-09-15 14:35:52 +08:00
AndyChen1024
547ce92050 feat: 组合根装配会话与导航(Tab + 登录 + 提交页) 2026-09-15 14:35:52 +08:00
AndyChen1024
affe326e90 feat: 手机号 + 验证码登录 2026-09-15 14:35:52 +08:00
AndyChen1024
7f6cbb3190 feat: 我的页对齐 android 2026-09-15 14:35:52 +08:00
AndyChen1024
4769aef59e feat: 计划页对齐 android 并新增提交页 2026-09-15 14:35:52 +08:00
AndyChen1024
3e90f8edee feat: 打卡页对齐 android 2026-09-15 14:35:52 +08:00
AndyChen1024
2986340ad8 feat: 首页改为落地页并接入登录态 2026-09-15 14:35:52 +08:00
AndyChen1024
542f08c0a8 feat: 新增未登录占位组件 LoggedOutView 2026-09-15 14:35:52 +08:00
AndyChen1024
c39a324534 feat: 新增 Fake 仓库实现(含可提交的打卡/计划与会话) 2026-09-15 14:35:52 +08:00
AndyChen1024
ec1eed8f04 feat: 定义仓库协议与用例(含会话) 2026-09-15 14:35:52 +08:00
AndyChen1024
14eb5ad44d feat: 重造领域模型并对齐 android(home/checkin/plan/profile/session) 2026-09-15 14:35:52 +08:00
AndyChen1024
ac4492a8f1 feat: 对齐小程序品牌色与渐变设计 tokens 2026-09-15 14:35:52 +08:00
AndyChen1024
6fd1df0503 refactor: 迁移 App 入口并接入 IosClientKit 2026-09-14 21:10:44 +08:00
AndyChen1024
ecb6a82f88 chore: 添加 LICENSE 2026-09-14 21:10:44 +08:00
AndyChen1024
ae763ae3a1 docs: 项目文档与协作规范 2026-09-14 21:10:44 +08:00
AndyChen1024
ec19d3bf98 chore: 添加 SwiftLint 配置 2026-09-14 21:10:44 +08:00
AndyChen1024
653caa0b84 refactor: app 组合根与导航 2026-09-14 21:10:44 +08:00
AndyChen1024
b5de8ddfbd feat: feature 多模块骨架 2026-09-14 21:10:44 +08:00
AndyChen1024
4fe0a26432 chore: core 多模块骨架 2026-09-14 21:10:44 +08:00
AndyChen1024
295efac07c build: 引入 IosClientKit SPM 包清单 2026-09-14 21:10:43 +08:00
AndyChen1024
599f1c7f8e chore: 忽略构建产物与 AI 工具目录 2026-09-14 21:10:43 +08:00
AndyChen1024
e29994f49d chore(skills): 引入 agent skills 与锁文件 2026-09-14 21:10:43 +08:00
63 changed files with 3427 additions and 43 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# 临时目录(日志/截图等)
tmp/
# macOS
.DS_Store
# Xcode
xcuserdata/
*.xcuserstate
DerivedData/
build/
# Swift Package Manager
.build/
.swiftpm/
# AI 工具本地配置 / 安装的技能(可经 skills-lock.json 重装)
.claude/
.agents/

38
.swiftlint.yml Normal file
View File

@@ -0,0 +1,38 @@
# SwiftLint 配置
disabled_rules:
- trailing_whitespace
opt_in_rules:
- anyobject_protocol
- array_init
- closure_spacing
- empty_count
- empty_string
- redundant_nil_coalescing
- vertical_whitespace_closing_braces
excluded:
- .build
- .swiftpm
- DerivedData
- tmp
- ios-client/Assets.xcassets
line_length:
warning: 120
error: 160
ignores_comments: true
ignores_urls: true
type_name:
min_length: 2
max_length: 60
identifier_name:
min_length: 2
max_length: 60
file_length:
warning: 500
error: 1000

102
CLAUDE.md Normal file
View File

@@ -0,0 +1,102 @@
# CLAUDE.md — iOS 客户端项目 AI 协作规范
## 项目概览
SwiftUI + MVVM 单向数据流 + SPM 多 target 模块化的 iOS「打卡/计划管理」客户端,功能对齐 `wechat-clock-mini`(小程序,生产参照)与 `android-client`(Android 端,原生简化参照)。架构对齐 Android NIA(`core/*` + `feature/*`)。
四个主入口 Tab(顺序):**首页 / 打卡 / 计划 / 我的**;另有独立**登录页**(手机号 + 短信验证码)与**提交计划页**(计划 Tab 内推入)。
## 目录结构
- `ios-client.xcodeproj` / `ios-client/`:App 宿主(薄壳,只含 `@main` 挂载 `AppRootView`)
- `IosClientKit/`:本地 SPM 包,承载所有模块
- `Sources/App`:组合根(`AppContainer` DI + `AppRootView` 导航)
- `Sources/CoreModel`:领域模型(`Sendable` 值类型,叶子)
- `Sources/CoreCommon`:通用工具(叶子)
- `Sources/CoreDomain`:Repository 协议 + UseCase(纯 Swift,不 import SwiftUI)
- `Sources/CoreRepository`:Repository 实现(Fake + 未来 OfflineFirst)
- `Sources/CoreNetwork`:DTO + APIClient(URLSession)
- `Sources/CoreDatabase`:SwiftData(`@Model` + `ModelContainer`)
- `Sources/DesignSystem`:设计 tokens
- `Sources/CoreUI`:可复用 SwiftUI 组件
- `Sources/FeatureHome`:首页(落地页:Hero + 模块卡 + 产品介绍)
- `Sources/FeatureCheckin`:打卡(状态卡 + 状态 pill + 小组/个人记录)
- `Sources/FeaturePlan`:计划(周/月/年 + 小组统计 + 提交计划页)
- `Sources/FeatureProfile`:我的(资料 + 小组分组 + 退出登录)
- `Sources/FeatureAuth`:登录(手机号 + 短信验证码 + 60s 倒计时)
## 依赖方向(硬约束,违反即编译不过)
单向无环:`App → feature → core`;`App` 额外 → `CoreRepository`(装配具体实现)。
- 领域模型只在 `CoreModel`,其余模块只能 import `CoreModel`。
- Repository 协议只定义在 `CoreDomain`,实现在 `CoreRepository`。
- `CoreDomain` 纯 Swift:禁止 `import SwiftUI` / 数据框架。
- DTO 只在 `CoreNetwork`,领域 Model 只在 `CoreModel`,二者分离。
- **禁止**命名任何模块为 `CoreData`(与苹果 `CoreData` 框架撞名,已统一为 `CoreRepository`)。
## 状态管理约定
- ViewModel:`@MainActor @Observable final class`,持有 `uiState`。
- UiState:`enum` 关联值(对应 Kotlin `sealed interface`)。
- Kotlin → Swift 映射:
| Android (Kotlin) | iOS (Swift) |
|---|---|
| `Flow<T>` | `AsyncThrowingStream<T, Error>` |
| `StateFlow` + `stateIn` | `@Observable` + `@MainActor` |
| `flatMapLatest` | `Task` 取消自动 switch-to-latest |
| `sealed interface UiState` | `enum UiState`(关联值)|
| Hilt `@Inject` / AppContainer | 手写 `AppContainer` |
| Retrofit + kotlinx.serialization | URLSession + Codable |
| Room | SwiftData |
| 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`。
## 代码风格
- 跨模块符号一律 `public`,并加 `///` 文档注释。
- 领域模型标记 `Sendable`。
- 命名:类型 PascalCase,变量/函数 camelCase;UI 文案与注释可用中文。
## 明确禁止
- ❌ TCA、Swinject 等 DI 框架、Moya 等网络封装、任何第三方大框架
- ❌ UIKit(除非 SwiftUI 确无法覆盖的极少数场景)
- ❌ 循环依赖 / 反向 import
## 构建 / 验证命令
```bash
# 编译包(同时做依赖环检测,SPM 遇环即报错)
swift build --package-path IosClientKit
# 模拟器编译 App
xcodebuild -project ios-client.xcodeproj -scheme ios-client \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO build
# Lint
swiftlint lint --strict
```
## 新增 Feature 清单
1. 在 `IosClientKit/Package.swift` 的 `targets` 加 `FeatureXxx` 及依赖。
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) |

32
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,32 @@
# 贡献指南
## 架构约束(提交前必须遵守)
- 依赖单向:`App → feature → core`,禁止反向 import。
- Repository 协议只在 `CoreDomain`,实现在 `CoreRepository`。
- DTO 与领域 Model 分离(`CoreNetwork` / `CoreModel`)。
- 禁止命名模块 `CoreData`(与苹果框架撞名,统一为 `CoreRepository`)。
## 代码规范
- 跨模块符号 `public` + `///` 文档注释。
- 领域模型标记 `Sendable`。
- ViewModel:`@MainActor @Observable`,UiState 用 `enum`。
- 登录态经 `SessionRepository` 驱动,不要在各页面各自维护登录态副本。
- 引入任何第三方依赖前,需在 PR 说明必要性(项目默认零第三方框架)。
## 提交流程
1. 从 `dev` 新建分支:`feat/xxx`、`fix/xxx`(简化 GitFlow:feature 从 `dev` 拉、merge 回 `dev`,`dev` 稳定后再 merge 到 `main`)。
2. 本地跑通:
```bash
swiftlint lint
xcodebuild -project ios-client.xcodeproj -scheme ios-client \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO build
```
3. 发起 PR,至少一位 reviewer 通过后合并。
## Commit 规范
采用 Conventional Commits:`feat:` / `fix:` / `refactor:` / `docs:` / `chore:`。

View File

@@ -0,0 +1,66 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "IosClientKit",
defaultLocalization: "en",
platforms: [
.iOS("17.0"),
.macOS(.v14),
],
products: [
// 组合根:App 宿主只链接这一个产品,其余模块经依赖传递带入。
.library(name: "App", targets: ["App"]),
],
targets: [
// 叶子模块
.target(name: "CoreModel"),
.target(name: "CoreCommon"),
.target(name: "DesignSystem"),
// 领域层(纯 Swift:不 import SwiftUI / 数据框架)
.target(name: "CoreDomain", dependencies: ["CoreModel"]),
// 基础设施层
.target(name: "CoreNetwork", dependencies: ["CoreModel", "CoreCommon"]),
.target(name: "CoreDatabase", dependencies: ["CoreModel"]),
.target(
name: "CoreRepository",
dependencies: ["CoreDomain", "CoreNetwork", "CoreDatabase", "CoreModel", "CoreCommon"]
),
// UI 层
.target(name: "CoreUI", dependencies: ["DesignSystem", "CoreCommon"]),
// Feature 层
.target(
name: "FeatureHome",
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",
"FeaturePlan", "FeatureCheckin", "FeatureProfile",
"CoreRepository",
"CoreUI", "DesignSystem", "CoreDomain", "CoreModel",
]
),
],
swiftLanguageModes: [.v5]
)

View File

@@ -0,0 +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 {
// 共享的有状态仓库(登录态、打卡、计划可提交,需跨页面共享单例)。
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, 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)
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
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(selection: $selectedTab) {
NavigationStack {
HomeScreen(
viewModel: homeViewModel,
onNavigateToCheckin: { selectedTab = .checkin },
onNavigateToPlan: { selectedTab = .plan },
onNavigateToAuth: { isAuthPresented = true }
)
}
.tabItem { Label("首页", systemImage: "house.fill") }
.tag(AppTab.home)
NavigationStack {
CheckinScreen(viewModel: checkinViewModel, onNavigateToAuth: { isAuthPresented = true })
}
.tabItem { Label("打卡", systemImage: "checkmark.seal.fill") }
.tag(AppTab.checkin)
NavigationStack {
PlanScreen(
viewModel: planViewModel,
onNavigateToAuth: { isAuthPresented = true },
makePlanSubmitViewModel: { type, periodKey in
AppContainer.makePlanSubmitViewModel(type: type, periodKey: periodKey)
}
)
}
.tabItem { Label("计划", systemImage: "calendar") }
.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
}

View File

@@ -0,0 +1,14 @@
/// 全局日志工具(Android 端 Timber 的轻量替代)。
public enum Logger {
public static func info(_ message: @autoclosure () -> String) {
#if DEBUG
print("[INFO] \(message())")
#endif
}
public static func error(_ message: @autoclosure () -> String) {
#if DEBUG
print("[ERROR] \(message())")
#endif
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
import SwiftData
/// 打卡习惯的持久化实体(对应 Android Room 的 @Entity)。
@Model
public final class HabitEntity {
public var id: String
public var title: String
public var detail: String
public var streakDays: Int
public var createdAt: Date
public init(id: String, title: String, detail: String, streakDays: Int, createdAt: Date) {
self.id = id
self.title = title
self.detail = detail
self.streakDays = streakDays
self.createdAt = createdAt
}
}
/// SwiftData 容器工厂(对应 Android RoomDatabase)。
public enum PersistenceController {
public static func makeModelContainer(inMemory: Bool = false) throws -> ModelContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
return try ModelContainer(for: HabitEntity.self, configurations: configuration)
}
}

View File

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

View 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)
}
}

View File

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

View File

@@ -0,0 +1,20 @@
import CoreModel
/// 获取 Home 数据的用例。
public struct HomeUseCase: Sendable {
private let repository: HomeRepository
public init(repository: HomeRepository) {
self.repository = repository
}
/// 已登录首页。
public func observeHome() -> AsyncThrowingStream<HomeData, Error> {
repository.observeHome()
}
/// 游客首页。
public func observeGuestHome() -> AsyncThrowingStream<HomeData, Error> {
repository.observeGuestHome()
}
}

View 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
}

View 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)
}
}

View 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>
}

View 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()
}
}

View 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
}

View 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()
}
}

View File

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

View 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
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
/// 首页模块跳转目标。
public enum ModuleDestination: Hashable, Sendable {
case checkin
case plan
case about
}
/// 首页功能模块卡片(打卡介绍 / 计划介绍 / 关于我们)。
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.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
}
}

View 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
}
}

View 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
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
/// 网络错误。
public enum NetworkError: Error, Sendable {
case invalidResponse
case http(statusCode: Int)
case decoding(String)
}
/// 通用 HTTP 客户端(对应 Android Retrofit 的最小抽象)。
public protocol APIClient: Sendable {
func get<T: Decodable & Sendable>(_ url: URL) async throws -> T
}
/// 基于 URLSession 的 APIClient 实现。
public struct URLSessionAPIClient: APIClient {
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func get<T: Decodable & Sendable>(_ url: URL) async throws -> T {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw NetworkError.http(statusCode: http.statusCode)
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decoding(error.localizedDescription)
}
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
/// 示例网络 DTO(与领域 Model 分离,对应 Kotlin @Serializable 的 data class)。
/// 注意:DTO 只承载接口字段,不直接进入 UI;领域层用 CoreModel.HomeData。
public struct HomeDTO: Codable, Sendable {
public let id: String
public let greeting: String
public let habits: [HabitDTO]
}
public struct HabitDTO: Codable, Sendable {
public let id: String
public let title: String
public let checkedIn: Bool
}

View File

@@ -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"),
]
)
}
}

View File

@@ -0,0 +1,65 @@
import CoreCommon
import CoreDomain
import CoreModel
/// 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 {
// 模拟网络延迟。
try await Task.sleep(for: .milliseconds(400))
try Task.checkCancellation()
Logger.info("FakeHomeRepository 返回假数据")
continuation.yield(data)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
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
)
}

View 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),
]
}
}

View File

@@ -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: "组长"),
]
)
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
/// 空状态占位视图。
public struct EmptyStateView: View {
private let title: String
private let systemImage: String
public init(title: String, systemImage: String = "tray") {
self.title = title
self.systemImage = systemImage
}
public var body: some View {
ContentUnavailableView(title, systemImage: systemImage)
}
}

View File

@@ -0,0 +1,22 @@
import SwiftUI
/// 错误占位视图(带重试)。
public struct ErrorView: View {
private let message: String
private let retry: () -> Void
public init(message: String, retry: @escaping () -> Void) {
self.message = message
self.retry = retry
}
public var body: some View {
ContentUnavailableView {
Label("出错了", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("重试", action: retry)
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
/// 加载中占位视图。
public struct LoadingView: View {
public init() {}
public var body: some View {
ProgressView("加载中…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View 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)
}
}
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
/// 尚未实现的 Tab 占位视图。
public struct PlaceholderView: View {
private let title: String
private let systemImage: String
public init(_ title: String, systemImage: String) {
self.title = title
self.systemImage = systemImage
}
public var body: some View {
ContentUnavailableView(title, systemImage: systemImage)
}
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
/// 设计 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(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 {
public static let xs: CGFloat = 4
public static let sm: CGFloat = 8
public static let md: CGFloat = 16
public static let lg: CGFloat = 24
public static let xl: CGFloat = 32
}
public enum Radius {
public static let sm: CGFloat = 8
public static let md: CGFloat = 12
public static let lg: CGFloat = 16
}
}
extension SwiftUI.Color {
/// 从 0xRRGGBB 十六进制值构造颜色。
init(hex: UInt32, alpha: Double = 1.0) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255.0,
green: Double((hex >> 8) & 0xFF) / 255.0,
blue: Double(hex & 0xFF) / 255.0,
opacity: alpha
)
}
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
import DesignSystem
public struct AuthScreen: View {
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 {
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))
}
}

View 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
}
}
}
}

View 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
}
}
}

View 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
}

View 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)
}
}
}
}

View File

@@ -0,0 +1,186 @@
import SwiftUI
import CoreModel
import CoreUI
import DesignSystem
public struct HomeScreen: View {
private let viewModel: HomeViewModel
private let onNavigateToCheckin: () -> Void
private let onNavigateToPlan: () -> Void
private let onNavigateToAuth: () -> Void
@State private var showLoginDialog = false
public init(
viewModel: HomeViewModel,
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 {
content
.navigationTitle("首页")
.task { viewModel.load() }
}
@ViewBuilder
private var content: some View {
switch viewModel.uiState {
case .loading:
LoadingView()
case .error(let message):
ErrorView(message: message) { viewModel.load() }
case .success(let data, let isGuest):
successContent(data, isGuest: isGuest)
}
}
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)
}
IntroSection(intros: data.intros)
}
.padding(DS.Spacing.md)
}
.background(DS.Color.brandPaper)
.alert("登录提示", isPresented: $showLoginDialog) {
Button("取消", role: .cancel) {}
Button("去登录") { onNavigateToAuth() }
} message: {
Text("登录后使用此功能,是否前往登录?")
}
}
private func handleModuleTap(_ module: HomeModule, isGuest: Bool) {
if isGuest {
showLoginDialog = true
return
}
switch module.destination {
case .checkin: onNavigateToCheckin()
case .plan: onNavigateToPlan()
case .about: break
}
}
}
// MARK: - Hero 卡
private struct HeroCard: View {
let data: HomeData
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
Text(data.greeting)
.font(.caption.bold())
.foregroundStyle(DS.Color.brandLightAmber)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, DS.Spacing.xs)
.background(DS.Color.brandGold.opacity(0.18))
.clipShape(Capsule())
Text(data.displayName)
.font(.title2.bold())
.foregroundStyle(.white)
Text(data.encouragement)
.font(.subheadline)
.foregroundStyle(DS.Color.brandCream)
}
.padding(DS.Spacing.lg)
.frame(maxWidth: .infinity, alignment: .leading)
.background(DS.Gradient.hero)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}
// MARK: - 模块宫格
private struct ModuleGrid: View {
let modules: [HomeModule]
let onTap: (HomeModule) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: DS.Spacing.sm), count: 3)
var body: some View {
LazyVGrid(columns: columns, spacing: DS.Spacing.sm) {
ForEach(modules) { module in
ModuleItem(module: module, onTap: onTap)
}
}
}
}
private struct ModuleItem: View {
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))
}
}

View File

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

View File

@@ -0,0 +1,57 @@
import CoreDomain
import CoreModel
import Observation
@MainActor
@Observable
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, sessionUseCase: SessionUseCase) {
self.homeUseCase = homeUseCase
self.sessionUseCase = sessionUseCase
}
public func load() {
// flatMapLatest 的等价:登录态变化时重新订阅对应数据源。
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
do {
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 {
} catch {
self.uiState = .error(error.localizedDescription)
}
}
}
}

View 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])
}
}

View 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))
}
}
}
}
}

View 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
}
}
}

View File

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

View 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)
}
}
}
}

View 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)
}
}

View File

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

View 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
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andy Chen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# ios-client
SwiftUI + MVVM 单向数据流的 iOS「打卡/计划管理」客户端,采用 SPM 多 target 模块化,架构对齐 Android NIA(`core/*` + `feature/*`),功能对齐 `wechat-clock-mini`(小程序)与 `android-client`(Android 端)。
## 功能
四个主入口 Tab:**首页 / 打卡 / 计划 / 我的**,另有独立**登录页**(手机号 + 短信验证码)与**提交计划页**。当前数据层为 Fake 实现(进程内 mock),尚未接真实后端。
## 架构
```
App(组合根)──► feature/* ──► core/*
│
└──► CoreRepository ──► CoreNetwork / CoreDatabase / CoreDomain
```
依赖单向无环,由 SPM target 依赖天然强制(import 不到即编不过)。详见 [docs/architecture.md](docs/architecture.md)。
## 模块
| 模块 | 职责 |
|---|---|
| CoreModel | 领域模型(叶子,`Sendable` 值类型)|
| CoreCommon | 通用工具(日志等)|
| CoreDomain | Repository 协议 + UseCase(纯 Swift)|
| CoreRepository | Repository 实现(Fake / OfflineFirst)|
| CoreNetwork | DTO + URLSession APIClient |
| CoreDatabase | SwiftData(`@Model` + `ModelContainer`)|
| DesignSystem | 设计 tokens(颜色/渐变/间距/圆角)|
| CoreUI | 可复用 SwiftUI 组件 |
| FeatureHome | 首页(落地页:Hero + 模块卡 + 产品介绍)|
| FeatureCheckin | 打卡(状态卡 + 状态 pill + 小组/个人记录)|
| FeaturePlan | 计划(周/月/年 + 小组统计 + 提交计划页)|
| FeatureProfile | 我的(资料 + 小组分组 + 退出登录)|
| FeatureAuth | 登录(手机号 + 短信验证码)|
| App | 组合根(DI + 导航)|
## 环境要求
- Xcode 26.6+
- iOS 17.0+(`@Observable` / SwiftData 下限)
## 构建
```bash
xcodebuild -project ios-client.xcodeproj -scheme ios-client \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO build
```
## Lint
```bash
brew install swiftlint # 首次
swiftlint lint
```
## 文档与协作规范
见 [CLAUDE.md](CLAUDE.md)、[docs/architecture.md](docs/architecture.md)、[docs/progress.md](docs/progress.md) 与 [CONTRIBUTING.md](CONTRIBUTING.md)。
## License
[MIT](LICENSE)

51
docs/architecture.md Normal file
View 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
View 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`(本机未安装)。

View File

@@ -65,6 +65,7 @@
);
name = "ios-client";
packageProductDependencies = (
A10000000000000000000002 /* App */,
);
productName = "ios-client";
productReference = A1890A533057E82C003D5BE4 /* ios-client.app */;
@@ -94,6 +95,9 @@
);
mainGroup = A1890A4A3057E82C003D5BE4;
minimizedProjectReferenceProxies = 1;
packageReferences = (
A10000000000000000000001 /* XCLocalSwiftPackageReference "IosClientKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = A1890A543057E82C003D5BE4 /* Products */;
projectDirPath = "";
@@ -246,6 +250,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 972VPTZ33X;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
@@ -270,7 +275,7 @@
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -288,6 +293,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 972VPTZ33X;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
@@ -312,7 +318,7 @@
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -325,6 +331,20 @@
};
/* End XCBuildConfiguration section */
/* Begin XCLocalSwiftPackageReference section */
A10000000000000000000001 /* XCLocalSwiftPackageReference "IosClientKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = IosClientKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
A10000000000000000000002 /* App */ = {
isa = XCSwiftPackageProductDependency;
productName = App;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCConfigurationList section */
A1890A4E3057E82C003D5BE4 /* Build configuration list for PBXProject "ios-client" */ = {
isa = XCConfigurationList;

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1890A523057E82C003D5BE4"
BuildableName = "ios-client.app"
BlueprintName = "ios-client"
ReferencedContainer = "container:ios-client.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1890A523057E82C003D5BE4"
BuildableName = "ios-client.app"
BlueprintName = "ios-client"
ReferencedContainer = "container:ios-client.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1890A523057E82C003D5BE4"
BuildableName = "ios-client.app"
BlueprintName = "ios-client"
ReferencedContainer = "container:ios-client.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,24 +0,0 @@
//
// ContentView.swift
// ios-client
//
// Created by Andy on 2026/9/14.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
import App
@main
struct IOSClientApp: App {
var body: some Scene {
WindowGroup {
AppRootView()
}
}
}

View File

@@ -1,17 +0,0 @@
//
// ios_clientApp.swift
// ios-client
//
// Created by Andy on 2026/9/14.
//
import SwiftUI
@main
struct ios_clientApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

89
skills-lock.json Normal file
View File

@@ -0,0 +1,89 @@
{
"version": 1,
"skills": {
"brainstorming": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/brainstorming/SKILL.md",
"computedHash": "b58d55b64742ecd37923985ec7bdfbe678ffbcb9895562abd28c0a32dcacd7fb"
},
"dispatching-parallel-agents": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/dispatching-parallel-agents/SKILL.md",
"computedHash": "b011107f17188e0b2898bb1856b5bac8b49537cc86e737c82126558a235bd62a"
},
"executing-plans": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/executing-plans/SKILL.md",
"computedHash": "78a07800ee99a5861ae10be59d79fad085fdca10444e8aaeb2691597e357cff8"
},
"finishing-a-development-branch": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/finishing-a-development-branch/SKILL.md",
"computedHash": "4f571e7befb8b0946d9d7770232c818812412e9ab1dfb811f847edfba1fc6275"
},
"receiving-code-review": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/receiving-code-review/SKILL.md",
"computedHash": "59361179fc9225173e0c145ffd9bafbf9fdbf8b43ce3dc308497a0b4f83a6063"
},
"requesting-code-review": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/requesting-code-review/SKILL.md",
"computedHash": "004f340ae65811095d6a9bb0a759c87d0a8085c5e705dbb019b011626293d2e9"
},
"subagent-driven-development": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/subagent-driven-development/SKILL.md",
"computedHash": "367cc1d3216d6672b947c8c6583dbd6440ea407beb6da75a97d3a8ee1a23640a"
},
"systematic-debugging": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/systematic-debugging/SKILL.md",
"computedHash": "a2750dfbaed1690da73b120254892c4f926b4576cb82eb16fc877783941fe39b"
},
"test-driven-development": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/test-driven-development/SKILL.md",
"computedHash": "a4126a9c5f17be3c228aa9e5755898ade283448b86099f56f40852e8c14532be"
},
"using-git-worktrees": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/using-git-worktrees/SKILL.md",
"computedHash": "48c8a1040e378e0cb7208107294e3afa9067b9370c3f33b49a443eb6b7528b0d"
},
"using-superpowers": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/using-superpowers/SKILL.md",
"computedHash": "c7abe8bdc2099bbe4f245454d32c7563ccd2bb41dcc218b02b461c2acc3c6ae6"
},
"verification-before-completion": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/verification-before-completion/SKILL.md",
"computedHash": "b529822bdac4b197b180fe12225cff86b930b65910435103c10622df7f041651"
},
"writing-plans": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/writing-plans/SKILL.md",
"computedHash": "27c75044e444a4fc68798c3f799dc23eaff3a6bfabff32e6ad75bb20f881c094"
},
"writing-skills": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/writing-skills/SKILL.md",
"computedHash": "e5dfc1a942f56dc757421b0d87ae1f38ab693ebc71138e65268fd408bb5c93d3"
}
}
}