feat: 首页改为落地页并接入登录态
This commit is contained in:
@@ -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)
|
||||
}
|
||||
IntroSection(intros: data.intros)
|
||||
}
|
||||
.padding(DS.Spacing.md)
|
||||
}
|
||||
.background(DS.Color.brandPaper)
|
||||
.alert("登录提示", isPresented: $showLoginDialog) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("去登录") { onNavigateToAuth() }
|
||||
} message: {
|
||||
Text("登录后使用此功能,是否前往登录?")
|
||||
}
|
||||
.padding(.vertical, DS.Spacing.sm)
|
||||
}
|
||||
|
||||
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(DS.Spacing.lg)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(DS.Gradient.hero)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 模块宫格
|
||||
|
||||
private struct ModuleGrid: View {
|
||||
let modules: [HomeModule]
|
||||
let onTap: (HomeModule) -> Void
|
||||
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: DS.Spacing.sm), count: 3)
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: DS.Spacing.sm) {
|
||||
ForEach(modules) { module in
|
||||
ModuleItem(module: module, onTap: onTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModuleItem: View {
|
||||
let module: HomeModule
|
||||
let onTap: (HomeModule) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: DS.Spacing.sm) {
|
||||
Text(module.emoji)
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(DS.Color.brandChip)
|
||||
.clipShape(Circle())
|
||||
Text(module.title)
|
||||
.font(.footnote.bold())
|
||||
.foregroundStyle(DS.Color.brandDark)
|
||||
Text(module.description)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(DS.Color.brandMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, DS.Spacing.md)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap(module) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 产品介绍
|
||||
|
||||
private struct IntroSection: View {
|
||||
let intros: [IntroCard]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
|
||||
Text("产品介绍")
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(DS.Color.brandDark)
|
||||
ForEach(intros, id: \.title) { intro in
|
||||
IntroItem(intro: intro)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IntroItem: View {
|
||||
let intro: IntroCard
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Spacing.xs) {
|
||||
Text(intro.title)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(DS.Color.brandGold)
|
||||
Text(intro.body)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(DS.Color.brandBrown)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(DS.Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import CoreModel
|
||||
|
||||
/// Kotlin: sealed interface HomeUiState → Swift: enum 关联值
|
||||
/// 首页状态(对应 Kotlin sealed interface UiState)。
|
||||
public enum HomeUiState {
|
||||
case loading
|
||||
case success(HomeData)
|
||||
case empty
|
||||
case success(HomeData, isGuest: Bool)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
@@ -8,24 +8,47 @@ public final class HomeViewModel {
|
||||
public private(set) var uiState: HomeUiState = .loading
|
||||
|
||||
private let homeUseCase: HomeUseCase
|
||||
private let sessionUseCase: SessionUseCase
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var dataTask: Task<Void, Never>?
|
||||
|
||||
public init(homeUseCase: HomeUseCase) {
|
||||
public init(homeUseCase: HomeUseCase, sessionUseCase: SessionUseCase) {
|
||||
self.homeUseCase = homeUseCase
|
||||
self.sessionUseCase = sessionUseCase
|
||||
}
|
||||
|
||||
public func load() {
|
||||
// flatMapLatest 的等价:新 load 前先取消旧的观察。
|
||||
// flatMapLatest 的等价:登录态变化时重新订阅对应数据源。
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
self.uiState = .loading
|
||||
do {
|
||||
for try await data in self.homeUseCase.observeHome() {
|
||||
self.uiState = data.habits.isEmpty ? .empty : .success(data)
|
||||
for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() {
|
||||
self.reload(isLoggedIn: isLoggedIn)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.uiState = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(isLoggedIn: Bool) {
|
||||
dataTask?.cancel()
|
||||
uiState = .loading
|
||||
dataTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
if isLoggedIn {
|
||||
for try await data in self.homeUseCase.observeHome() {
|
||||
self.uiState = .success(data, isGuest: false)
|
||||
}
|
||||
} else {
|
||||
for try await data in self.homeUseCase.observeGuestHome() {
|
||||
self.uiState = .success(data, isGuest: true)
|
||||
}
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// 被更新的 load() 取代,保持当前状态即可。
|
||||
} catch {
|
||||
self.uiState = .error(error.localizedDescription)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user