diff --git a/IosClientKit/Sources/CoreCommon/Logger.swift b/IosClientKit/Sources/CoreCommon/Logger.swift new file mode 100644 index 0000000..9543a53 --- /dev/null +++ b/IosClientKit/Sources/CoreCommon/Logger.swift @@ -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 + } +} diff --git a/IosClientKit/Sources/CoreDatabase/PersistenceController.swift b/IosClientKit/Sources/CoreDatabase/PersistenceController.swift new file mode 100644 index 0000000..ac17cda --- /dev/null +++ b/IosClientKit/Sources/CoreDatabase/PersistenceController.swift @@ -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) + } +} diff --git a/IosClientKit/Sources/CoreDomain/HomeRepository.swift b/IosClientKit/Sources/CoreDomain/HomeRepository.swift new file mode 100644 index 0000000..8644765 --- /dev/null +++ b/IosClientKit/Sources/CoreDomain/HomeRepository.swift @@ -0,0 +1,8 @@ +import CoreModel + +/// Home 数据仓库协议(定义在 domain 层,实现放 CoreRepository)。 +/// Kotlin: interface HomeRepository { fun observeHome(): Flow } +public protocol HomeRepository: Sendable { + /// Kotlin: Flow → Swift: AsyncThrowingStream + func observeHome() -> AsyncThrowingStream +} diff --git a/IosClientKit/Sources/CoreDomain/HomeUseCase.swift b/IosClientKit/Sources/CoreDomain/HomeUseCase.swift new file mode 100644 index 0000000..5b190cd --- /dev/null +++ b/IosClientKit/Sources/CoreDomain/HomeUseCase.swift @@ -0,0 +1,16 @@ +import CoreModel + +/// 获取 Home 数据的用例。 +/// Kotlin: class HomeUseCase(...) { suspend operator fun invoke(): HomeData } +public struct HomeUseCase: Sendable { + private let repository: HomeRepository + + public init(repository: HomeRepository) { + self.repository = repository + } + + /// Kotlin: Flow → Swift: AsyncThrowingStream + public func observeHome() -> AsyncThrowingStream { + repository.observeHome() + } +} diff --git a/IosClientKit/Sources/CoreModel/Habit.swift b/IosClientKit/Sources/CoreModel/Habit.swift new file mode 100644 index 0000000..a0dc84a --- /dev/null +++ b/IosClientKit/Sources/CoreModel/Habit.swift @@ -0,0 +1,27 @@ +import Foundation + +/// 一个打卡习惯(对应 Android 端 Habit)。 +public struct Habit: Identifiable, Hashable, Sendable { + public let id: String + public let title: String + /// 备注 / 目标描述。 + public let detail: String + /// 今日是否已打卡。 + public let isCheckedInToday: Bool + /// 连续打卡天数。 + public let streakDays: Int + + public init( + id: String, + title: String, + detail: String, + isCheckedInToday: Bool, + streakDays: Int + ) { + self.id = id + self.title = title + self.detail = detail + self.isCheckedInToday = isCheckedInToday + self.streakDays = streakDays + } +} diff --git a/IosClientKit/Sources/CoreModel/HomeData.swift b/IosClientKit/Sources/CoreModel/HomeData.swift new file mode 100644 index 0000000..a76e73f --- /dev/null +++ b/IosClientKit/Sources/CoreModel/HomeData.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Home 页面的聚合数据(对应 Android 端 HomeData)。 +public struct HomeData: Identifiable, Hashable, Sendable { + public let id: String + /// 顶部问候语,例如「早上好,Andy」。 + public let greeting: String + /// 今日打卡摘要,例如「已完成 2 / 4」。 + public let summary: String + /// 今日待打卡的习惯列表。 + public let habits: [Habit] + + public init(id: String, greeting: String, summary: String, habits: [Habit]) { + self.id = id + self.greeting = greeting + self.summary = summary + self.habits = habits + } +} diff --git a/IosClientKit/Sources/CoreNetwork/APIClient.swift b/IosClientKit/Sources/CoreNetwork/APIClient.swift new file mode 100644 index 0000000..e4017b3 --- /dev/null +++ b/IosClientKit/Sources/CoreNetwork/APIClient.swift @@ -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(_ 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(_ 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) + } + } +} diff --git a/IosClientKit/Sources/CoreNetwork/HomeDTO.swift b/IosClientKit/Sources/CoreNetwork/HomeDTO.swift new file mode 100644 index 0000000..a020b4b --- /dev/null +++ b/IosClientKit/Sources/CoreNetwork/HomeDTO.swift @@ -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 +} diff --git a/IosClientKit/Sources/CoreRepository/FakeHomeRepository.swift b/IosClientKit/Sources/CoreRepository/FakeHomeRepository.swift new file mode 100644 index 0000000..b822687 --- /dev/null +++ b/IosClientKit/Sources/CoreRepository/FakeHomeRepository.swift @@ -0,0 +1,40 @@ +import CoreCommon +import CoreDomain +import CoreModel + +/// Fake 仓库实现(骨架阶段返回假数据,后续替换为 OfflineFirst 实现)。 +public struct FakeHomeRepository: HomeRepository { + public init() {} + + public func observeHome() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + // 模拟网络延迟。 + try await Task.sleep(for: .milliseconds(400)) + try Task.checkCancellation() + Logger.info("FakeHomeRepository 返回假数据") + continuation.yield(Self.fakeData) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + + private static let fakeData = HomeData( + id: "home-1", + greeting: "早上好,Andy", + summary: "今日已完成 2 / 4", + habits: [ + Habit(id: "habit-1", title: "晨跑 5 公里", detail: "保持运动习惯", isCheckedInToday: true, streakDays: 12), + Habit(id: "habit-2", title: "阅读 30 分钟", detail: "每天进步一点", isCheckedInToday: true, streakDays: 8), + Habit(id: "habit-3", title: "喝水 8 杯", detail: "健康补水", isCheckedInToday: false, streakDays: 0), + Habit(id: "habit-4", title: "写日记", detail: "记录今日所思", isCheckedInToday: false, streakDays: 3), + ] + ) +} diff --git a/IosClientKit/Sources/CoreUI/EmptyStateView.swift b/IosClientKit/Sources/CoreUI/EmptyStateView.swift new file mode 100644 index 0000000..6ed9656 --- /dev/null +++ b/IosClientKit/Sources/CoreUI/EmptyStateView.swift @@ -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) + } +} diff --git a/IosClientKit/Sources/CoreUI/ErrorView.swift b/IosClientKit/Sources/CoreUI/ErrorView.swift new file mode 100644 index 0000000..43539ec --- /dev/null +++ b/IosClientKit/Sources/CoreUI/ErrorView.swift @@ -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) + } + } +} diff --git a/IosClientKit/Sources/CoreUI/LoadingView.swift b/IosClientKit/Sources/CoreUI/LoadingView.swift new file mode 100644 index 0000000..3005dec --- /dev/null +++ b/IosClientKit/Sources/CoreUI/LoadingView.swift @@ -0,0 +1,11 @@ +import SwiftUI + +/// 加载中占位视图。 +public struct LoadingView: View { + public init() {} + + public var body: some View { + ProgressView("加载中…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/IosClientKit/Sources/CoreUI/PlaceholderView.swift b/IosClientKit/Sources/CoreUI/PlaceholderView.swift new file mode 100644 index 0000000..945171e --- /dev/null +++ b/IosClientKit/Sources/CoreUI/PlaceholderView.swift @@ -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) + } +} diff --git a/IosClientKit/Sources/DesignSystem/DesignTokens.swift b/IosClientKit/Sources/DesignSystem/DesignTokens.swift new file mode 100644 index 0000000..2be3ef5 --- /dev/null +++ b/IosClientKit/Sources/DesignSystem/DesignTokens.swift @@ -0,0 +1,25 @@ +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.red + public static let warning = SwiftUI.Color.orange + } + + 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 + } +}