feat(ios): IOS-M1-01 学习首页改版
- 新增主行动卡片(今日复习/开始学习,基于优先级) - 新增本周摘要行(本周分钟/完成任务/复习卡片/连续天数) - 接入 ActivityService.streak()/summary() + ReviewService.dueCards() - 保留今日任务列表 + 每日思考题 - 移除旧的进度环卡片 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8e37223b7f
commit
1162eaac07
@ -1,9 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StudyHomeView: View {
|
||||
@StateObject private var studyVM = StudyViewModel()
|
||||
@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 {
|
||||
@ -11,232 +14,183 @@ struct StudyHomeView: View {
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// MARK: - Header
|
||||
// Header + streak
|
||||
HStack {
|
||||
Spacer()
|
||||
if studyVM.isLoading {
|
||||
ZXLoadingView(size: 22, lineWidth: 2)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color.zxOrange)
|
||||
Text("14 天连续")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Color.zxOrange)
|
||||
.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())
|
||||
.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)
|
||||
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 4)
|
||||
|
||||
// MARK: - Progress
|
||||
progressCard
|
||||
// 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: - Today's Tasks
|
||||
// MARK: - Weekly Summary
|
||||
weeklySummaryCard
|
||||
|
||||
// MARK: - Today Tasks
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("今日任务")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(Color.zxF0)
|
||||
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)
|
||||
Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||
Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||
}
|
||||
}
|
||||
ForEach($studyHomeVM.tasks) { $t in
|
||||
if t.tp == "回忆测试" {
|
||||
NavigationLink(value: Route.activeRecall) {
|
||||
routeForTask(t).map { route in
|
||||
NavigationLink(value: route) {
|
||||
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
} else if t.tp == "费曼练习" {
|
||||
NavigationLink(value: Route.aiChat) {
|
||||
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
} else if t.tp == "薄弱点" {
|
||||
NavigationLink(value: Route.weakPoints) {
|
||||
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
} else if t.tp == "间隔复习" {
|
||||
NavigationLink(value: Route.reviewCard) {
|
||||
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
} else {
|
||||
NavigationLink(value: Route.learningSession(taskTitle: t.t, taskType: t.tp, taskColorHex: t.ch)) {
|
||||
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
}.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}.padding(.horizontal, 20)
|
||||
|
||||
// MARK: - Daily Thinking
|
||||
dailyThinkingCard
|
||||
.padding(.horizontal, 20)
|
||||
dailyThinkingCard.padding(.horizontal, 20)
|
||||
|
||||
// Bottom spacer
|
||||
Color.clear.frame(height: 100)
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.zxPullToRefresh { await studyVM.loadSessions() }
|
||||
.zxPullToRefresh { await refreshData() }
|
||||
}
|
||||
.task { await studyVM.loadSessions() }
|
||||
.task { await refreshData() }
|
||||
.navigationDestination(for: Route.self) { $0.destination }
|
||||
}
|
||||
|
||||
// MARK: - Progress Card
|
||||
// MARK: - Main Action Card
|
||||
|
||||
private var progressCard: some View {
|
||||
let dn = studyHomeVM.doneCount
|
||||
let pct = CGFloat(dn) / 5
|
||||
return VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("今日进度")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.zxF05)
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(dn)")
|
||||
.font(.system(size: 26, weight: .black))
|
||||
.foregroundColor(Color.zxF0)
|
||||
.contentTransition(.numericText())
|
||||
Text("/ 5")
|
||||
Text("个任务")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.zxF04)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.trim(from: 0, to: pct)
|
||||
.stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 64, height: 64)
|
||||
.animation(.easeInOut(duration: 0.8), value: pct)
|
||||
Text("\(Int(pct * 100))%")
|
||||
.font(.system(size: 14, weight: .heavy))
|
||||
.foregroundColor(Color.zxPurple)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.zxFill008)
|
||||
.frame(height: 6)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6)
|
||||
.animation(.easeInOut(duration: 0.6), value: pct)
|
||||
}
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(dn * 12) 分钟")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Color.zxF0)
|
||||
Text("已学")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color.zxF04)
|
||||
}
|
||||
Spacer()
|
||||
VStack(spacing: 2) {
|
||||
Text("\((5 - dn) * 11) 分钟")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Color.zxF0)
|
||||
Text("剩余")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color.zxF04)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("+5 点")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Color.zxF0)
|
||||
Text("掌握")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color.zxF04)
|
||||
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))
|
||||
}
|
||||
.padding(16)
|
||||
.background(ZXGradient.progressCard)
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1))
|
||||
.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)
|
||||
|
||||
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("待回答").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)
|
||||
|
||||
.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))
|
||||
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("开始回答每日思考题")
|
||||
.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)
|
||||
)
|
||||
.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) } }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user