wangdl 1162eaac07 feat(ios): IOS-M1-01 学习首页改版
- 新增主行动卡片(今日复习/开始学习,基于优先级)
- 新增本周摘要行(本周分钟/完成任务/复习卡片/连续天数)
- 接入 ActivityService.streak()/summary() + ReviewService.dueCards()
- 保留今日任务列表 + 每日思考题
- 移除旧的进度环卡片

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:39:49 +08:00

209 lines
11 KiB
Swift

import SwiftUI
struct StudyHomeView: View {
@StateObject private var studyHomeVM = StudyHomeViewModel()
@StateObject private var reviewVM = ReviewViewModel()
@State private var streakDays = 0
@State private var weeklyMinutes = 0
@State private var reviewCount = 0
@State private var hasTodayReview = false
var body: some View {
ZStack {
Color.zxCanvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
// Header + streak
HStack {
Spacer()
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.system(size: 14)).foregroundColor(Color.zxOrange)
Text("\(streakDays) 天连续")
.font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxOrange)
}
.padding(.horizontal, 12).padding(.vertical, 6)
.background(Color.zxOrangeBG(0.1)).clipShape(Capsule())
.overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1))
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 4)
// MARK: - Main Action Card
if hasTodayReview {
mainActionCard(
icon: "arrow.triangle.2.circlepath",
title: "今日复习",
subtitle: "\(reviewCount) 张卡片待复习",
color: Color.zxPurple,
route: Route.reviewCard
)
} else {
mainActionCard(
icon: "sparkles",
title: "开始学习",
subtitle: "从知识库挑选内容开始今天的进步",
color: Color.zxPrimary,
route: Route.studyHome
)
}
// MARK: - Weekly Summary
weeklySummaryCard
// MARK: - Today Tasks
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("今日任务").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
Spacer()
HStack(spacing: 4) {
Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Color.zxF04)
Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}
ForEach($studyHomeVM.tasks) { $t in
routeForTask(t).map { route in
NavigationLink(value: route) {
ZXSTaskRowView(task: t) { t.d.toggle() }
}.foregroundColor(.primary)
}
}
}.padding(.horizontal, 20)
// MARK: - Daily Thinking
dailyThinkingCard.padding(.horizontal, 20)
Color.clear.frame(height: 100)
}
}
.scrollIndicators(.hidden)
.zxPullToRefresh { await refreshData() }
}
.task { await refreshData() }
.navigationDestination(for: Route.self) { $0.destination }
}
// MARK: - Main Action Card
private func mainActionCard(icon: String, title: String, subtitle: String, color: Color, route: Route) -> some View {
NavigationLink(value: route) {
VStack(spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("今日主行动").font(.system(size: 11, weight: .semibold)).foregroundColor(Color.zxInkTertiary).textCase(.uppercase).tracking(0.5)
Text(title).font(.system(size: 24, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
Text(subtitle).font(.system(size: 13)).foregroundColor(Color.zxF04)
}
Spacer()
ZStack {
Circle().fill(color.opacity(0.12)).frame(width: 64, height: 64)
Image(systemName: icon).font(.system(size: 26)).foregroundColor(color)
}
}
HStack(spacing: 8) {
Text("开始").font(.system(size: 14, weight: .semibold))
Image(systemName: "arrow.right").font(.system(size: 12, weight: .bold))
}
.foregroundColor(Color.zxOnPrimary).frame(maxWidth: .infinity).frame(height: 44)
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(20)
.background(Color.zxSurfaceElevated)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxHairline, lineWidth: 0.5))
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.foregroundColor(.primary)
.padding(.horizontal, 20)
}
// MARK: - Weekly Summary
private var weeklySummaryCard: some View {
HStack(spacing: 0) {
weeklyStat("\(weeklyMinutes)", "本周分钟", Color.zxOrange)
weeklyStat("\(studyHomeVM.doneCount)", "完成任务", Color.zxPurple)
weeklyStat("\(reviewCount)", "复习卡片", Color.zxTeal)
weeklyStat("\(streakDays)", "连续天数", Color.zxAmber)
}
.padding(.horizontal, 20)
}
private func weeklyStat(_ value: String, _ label: String, _ color: Color) -> some View {
VStack(spacing: 4) {
Text(value).font(.system(size: 20, weight: .heavy)).foregroundColor(color)
Text(label).font(.system(size: 10)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity)
}
// MARK: - Daily Thinking Card
private var dailyThinkingCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Image(systemName: "sparkles").font(.system(size: 13)).foregroundColor(Color.zxPrimary)
.frame(width: 30, height: 30).background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: ZXRadius.sm))
Text("每日思考题").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxInkPrimary)
Spacer()
Text("待回答").font(.system(size: 11, weight: .semibold)).foregroundColor(Color.zxAmberDeep)
.padding(.horizontal, 8).padding(.vertical, 3).background(Color.zxAmberSoft).clipShape(RoundedRectangle(cornerRadius: ZXRadius.xs))
}
Text("解释「注意力机制」在 Transformer 中的作用,不能使用搜索,用你自己的话说。")
.font(.system(size: 15, weight: .regular)).foregroundColor(Color.zxInkPrimary).lineSpacing(6)
NavigationLink(value: Route.dailyThinking) {
HStack(spacing: 8) {
Text("开始回答").font(.system(size: 14, weight: .semibold))
Image(systemName: "arrow.right").font(.system(size: 12, weight: .semibold))
}
.foregroundColor(Color.zxOnPrimary).frame(maxWidth: .infinity).frame(height: 48)
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
}.accessibilityLabel("开始回答每日思考题")
}
.padding(16)
.background(Color.zxSurfaceElevated)
.overlay(RoundedRectangle(cornerRadius: ZXRadius.lg).stroke(Color.zxHairline, lineWidth: 0.5))
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
}
// MARK: - Helpers
private func routeForTask(_ t: ZXSTask) -> Route? {
switch t.tp {
case "回忆测试": return .activeRecall
case "费曼练习": return .aiChat
case "薄弱点": return .weakPoints
case "间隔复习": return .reviewCard
case _ where !t.t.isEmpty: return .learningSession(taskTitle: t.t, taskType: t.tp, taskColorHex: t.ch)
default: return nil
}
}
private func refreshData() async {
do {
let streak = try? await ActivityService.shared.streak()
streakDays = streak?.currentStreak ?? 0
let summary = try? await ActivityService.shared.summary()
weeklyMinutes = summary?.totalMinutes ?? 0
reviewCount = summary?.totalCardsReviewed ?? 0
let dueCards = try? await ReviewService.shared.dueCards()
hasTodayReview = (dueCards?.count ?? 0) > 0
reviewCount = max(reviewCount, dueCards?.count ?? 0)
}
}
}
struct ZXSTask: Identifiable { let id = UUID(); let t: String; let tp: String; let ch: String; let m: Int; var d: Bool; var c: Color { Color(hex: ch) } }
struct ZXSTaskRow: View { @Binding var task: ZXSTask
var body: some View { Button { task.d.toggle() } label: { ZXSTaskRowView(task: task) {} }.foregroundColor(.primary) }
}
struct ZXSTaskRowView: View { let task: ZXSTask; var action: () -> Void
var body: some View { HStack(spacing: 12) { Image(systemName: task.d ? "checkmark.circle.fill" : "circle").font(.system(size: 20)).foregroundColor(task.d ? Color.zxGreen : Color.zxF02)
VStack(alignment: .leading, spacing: 4) { Text(task.t).font(.system(size: 13, weight: .semibold)).foregroundColor(task.d ? Color.zxF04 : Color.zxF0).strikethrough(task.d); HStack(spacing: 8) { Text(task.tp).font(.system(size: 10, weight: .semibold)).foregroundColor(task.c).padding(.horizontal, 6).padding(.vertical, 1).background(task.c.opacity(0.12)).clipShape(Capsule()); Text("\(task.m) 分钟").font(.system(size: 10)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.35)) } }
Spacer(); if !task.d { Image(systemName: "play.fill").font(.system(size: 14)).foregroundColor(.white).frame(width: 32, height: 32).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10)) } }
.padding(.horizontal, 16).padding(.vertical, 12).background(task.d ? Color.zxFill003 : Color.zxFill005).overlay(RoundedRectangle(cornerRadius: 14).stroke(task.d ? Color(hex: "#FFFFFF", opacity: 0.05) : Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).opacity(task.d ? 0.6 : 1).contentShape(Rectangle()).onTapGesture { action() }.zxPressable()
.accessibilityLabel("\(task.t), \(task.tp), 约\(task.m)分钟")
.accessibilityAddTraits(task.d ? .isSelected : [])
.accessibilityHint(task.d ? "已完成" : "双击开始学习") }
}