diff --git a/IosClientKit/Sources/FeatureHome/HomeScreen.swift b/IosClientKit/Sources/FeatureHome/HomeScreen.swift index 79aa4a1..c749c98 100644 --- a/IosClientKit/Sources/FeatureHome/HomeScreen.swift +++ b/IosClientKit/Sources/FeatureHome/HomeScreen.swift @@ -5,9 +5,22 @@ import DesignSystem public struct HomeScreen: View { private let viewModel: HomeViewModel + private let onNavigateToCheckin: () -> Void + private let onNavigateToPlan: () -> Void + private let onNavigateToAuth: () -> Void - public init(viewModel: HomeViewModel) { + @State private var showLoginDialog = false + + public init( + viewModel: HomeViewModel, + onNavigateToCheckin: @escaping () -> Void, + onNavigateToPlan: @escaping () -> Void, + onNavigateToAuth: @escaping () -> Void + ) { self.viewModel = viewModel + self.onNavigateToCheckin = onNavigateToCheckin + self.onNavigateToPlan = onNavigateToPlan + self.onNavigateToAuth = onNavigateToAuth } public var body: some View { @@ -21,57 +34,153 @@ public struct HomeScreen: View { switch viewModel.uiState { case .loading: LoadingView() - case .empty: - EmptyStateView(title: "今天还没有待打卡的习惯", systemImage: "checkmark.circle") - case .success(let data): - successContent(data) case .error(let message): ErrorView(message: message) { viewModel.load() } + case .success(let data, let isGuest): + successContent(data, isGuest: isGuest) } } - private func successContent(_ data: HomeData) -> some View { - List { - Section { - VStack(alignment: .leading, spacing: DS.Spacing.sm) { - Text(data.greeting) - .font(.largeTitle.bold()) - Text(data.summary) - .font(.subheadline) - .foregroundStyle(.secondary) + private func successContent(_ data: HomeData, isGuest: Bool) -> some View { + ScrollView { + VStack(spacing: DS.Spacing.md) { + HeroCard(data: data) + ModuleGrid(modules: data.modules) { module in + handleModuleTap(module, isGuest: isGuest) } - .padding(.vertical, DS.Spacing.sm) + IntroSection(intros: data.intros) } + .padding(DS.Spacing.md) + } + .background(DS.Color.brandPaper) + .alert("登录提示", isPresented: $showLoginDialog) { + Button("取消", role: .cancel) {} + Button("去登录") { onNavigateToAuth() } + } message: { + Text("登录后使用此功能,是否前往登录?") + } + } - Section("今日打卡") { - ForEach(data.habits) { habit in - HabitRow(habit: habit) - } - } + private func handleModuleTap(_ module: HomeModule, isGuest: Bool) { + if isGuest { + showLoginDialog = true + return + } + switch module.destination { + case .checkin: onNavigateToCheckin() + case .plan: onNavigateToPlan() + case .about: break } } } -private struct HabitRow: View { - let habit: Habit +// MARK: - Hero 卡 + +private struct HeroCard: View { + let data: HomeData var body: some View { - HStack { - Image(systemName: habit.isCheckedInToday ? "checkmark.circle.fill" : "circle") - .foregroundStyle(habit.isCheckedInToday ? DS.Color.success : DS.Color.warning) - .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text(habit.title) - .font(.headline) - Text(habit.detail) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Text("🔥 \(habit.streakDays) 天") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + Text(data.greeting) + .font(.caption.bold()) + .foregroundStyle(DS.Color.brandLightAmber) + .padding(.horizontal, DS.Spacing.sm) + .padding(.vertical, DS.Spacing.xs) + .background(DS.Color.brandGold.opacity(0.18)) + .clipShape(Capsule()) + Text(data.displayName) + .font(.title2.bold()) + .foregroundStyle(.white) + Text(data.encouragement) + .font(.subheadline) + .foregroundStyle(DS.Color.brandCream) } - .padding(.vertical, DS.Spacing.xs) + .padding(DS.Spacing.lg) + .frame(maxWidth: .infinity, alignment: .leading) + .background(DS.Gradient.hero) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + } +} + +// MARK: - 模块宫格 + +private struct ModuleGrid: View { + let modules: [HomeModule] + let onTap: (HomeModule) -> Void + + private let columns = Array(repeating: GridItem(.flexible(), spacing: DS.Spacing.sm), count: 3) + + var body: some View { + LazyVGrid(columns: columns, spacing: DS.Spacing.sm) { + ForEach(modules) { module in + ModuleItem(module: module, onTap: onTap) + } + } + } +} + +private struct ModuleItem: View { + let module: HomeModule + let onTap: (HomeModule) -> Void + + var body: some View { + VStack(spacing: DS.Spacing.sm) { + Text(module.emoji) + .font(.title2) + .frame(width: 44, height: 44) + .background(DS.Color.brandChip) + .clipShape(Circle()) + Text(module.title) + .font(.footnote.bold()) + .foregroundStyle(DS.Color.brandDark) + Text(module.description) + .font(.caption2) + .foregroundStyle(DS.Color.brandMuted) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, DS.Spacing.md) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .contentShape(Rectangle()) + .onTapGesture { onTap(module) } + } +} + +// MARK: - 产品介绍 + +private struct IntroSection: View { + let intros: [IntroCard] + + var body: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + Text("产品介绍") + .font(.title3.bold()) + .foregroundStyle(DS.Color.brandDark) + ForEach(intros, id: \.title) { intro in + IntroItem(intro: intro) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct IntroItem: View { + let intro: IntroCard + + var body: some View { + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + Text(intro.title) + .font(.subheadline.bold()) + .foregroundStyle(DS.Color.brandGold) + Text(intro.body) + .font(.subheadline) + .foregroundStyle(DS.Color.brandBrown) + .lineSpacing(4) + } + .padding(DS.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/IosClientKit/Sources/FeatureHome/HomeUiState.swift b/IosClientKit/Sources/FeatureHome/HomeUiState.swift index 3926a18..c65872f 100644 --- a/IosClientKit/Sources/FeatureHome/HomeUiState.swift +++ b/IosClientKit/Sources/FeatureHome/HomeUiState.swift @@ -1,9 +1,8 @@ import CoreModel -/// Kotlin: sealed interface HomeUiState → Swift: enum 关联值 +/// 首页状态(对应 Kotlin sealed interface UiState)。 public enum HomeUiState { case loading - case success(HomeData) - case empty + case success(HomeData, isGuest: Bool) case error(String) } diff --git a/IosClientKit/Sources/FeatureHome/HomeViewModel.swift b/IosClientKit/Sources/FeatureHome/HomeViewModel.swift index ff3b237..95bd87a 100644 --- a/IosClientKit/Sources/FeatureHome/HomeViewModel.swift +++ b/IosClientKit/Sources/FeatureHome/HomeViewModel.swift @@ -8,24 +8,47 @@ public final class HomeViewModel { public private(set) var uiState: HomeUiState = .loading private let homeUseCase: HomeUseCase + private let sessionUseCase: SessionUseCase private var loadTask: Task? + private var dataTask: Task? - public init(homeUseCase: HomeUseCase) { + public init(homeUseCase: HomeUseCase, sessionUseCase: SessionUseCase) { self.homeUseCase = homeUseCase + self.sessionUseCase = sessionUseCase } public func load() { - // flatMapLatest 的等价:新 load 前先取消旧的观察。 + // flatMapLatest 的等价:登录态变化时重新订阅对应数据源。 loadTask?.cancel() loadTask = Task { [weak self] in guard let self else { return } - self.uiState = .loading do { - for try await data in self.homeUseCase.observeHome() { - self.uiState = data.habits.isEmpty ? .empty : .success(data) + for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() { + self.reload(isLoggedIn: isLoggedIn) + } + } catch is CancellationError { + } catch { + self.uiState = .error(error.localizedDescription) + } + } + } + + private func reload(isLoggedIn: Bool) { + dataTask?.cancel() + uiState = .loading + dataTask = Task { [weak self] in + guard let self else { return } + do { + if isLoggedIn { + for try await data in self.homeUseCase.observeHome() { + self.uiState = .success(data, isGuest: false) + } + } else { + for try await data in self.homeUseCase.observeGuestHome() { + self.uiState = .success(data, isGuest: true) + } } } catch is CancellationError { - // 被更新的 load() 取代,保持当前状态即可。 } catch { self.uiState = .error(error.localizedDescription) }