feat(ios): P2 动效补充 + 无障碍适配

- 新增 ZXAnimations.swift — ZXButtonStyle/ZXPressModifier/ZXPageTransition/ZXThinkingOverlay/ZXCelebrationView/ZXAIAnalysisProgress
- 新增 ZXLoadingView.swift — 品牌化加载动画/ZXDotLoader/ZXShimmer
- 新增 ZXRefreshableScrollView.swift — 下拉刷新+上拉加载更多
- 新增 ZXToast.swift — 全局 Toast 通知系统
- 新增 FileCache.swift / LocalCache.swift — 本地缓存层
- 新增 AIChatViewModel.swift / StudyHomeViewModel.swift / ReviewPlanViewModel.swift
- 全部关键按钮接入 .zxPressable() 触觉反馈
- AI 分析流程接入 ZXThinkingOverlay + ZXAIAnalysisProgress
- 学习完成/复习完成接入 ZXCelebrationView 庆祝动画
- 全部关键交互元素添加 .accessibilityLabel
- 修复 ProfileViewModel async let 问题、EditProfilePage 保存失败、let _ = Task{} 反模式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-17 22:31:24 +08:00
parent b182203464
commit 89d89e542c
28 changed files with 1458 additions and 140 deletions

View File

@ -34,6 +34,7 @@ struct AIStudyAppApp: App {
} }
} }
.preferredColorScheme(effectiveColorScheme) .preferredColorScheme(effectiveColorScheme)
.zxToast()
.task { .task {
await authManager.restoreSession() await authManager.restoreSession()
} }

View File

@ -13,14 +13,16 @@ struct ContentView: View {
default: NavigationStack { AIHomeView() } default: NavigationStack { AIHomeView() }
} }
VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom) VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom)
}.ignoresSafeArea(edges: .bottom) }
.animation(.easeInOut(duration: 0.2), value: selectedTab)
.ignoresSafeArea(edges: .bottom)
} }
} }
struct ZXTabBar: View { struct ZXTabBar: View {
@Binding var active: String @Binding var active: String
private let items = [("ai","AI","brain.head.profile"),("library","知识库","books.vertical.fill"),("study","学习","bolt.fill"),("analysis","分析","chart.bar.fill"),("profile","我的","person.fill")] private let items = [("ai","AI","brain.head.profile"),("library","知识库","books.vertical.fill"),("study","学习","bolt.fill"),("analysis","分析","chart.bar.fill"),("profile","我的","person.fill")]
var body: some View{HStack(spacing:0){ForEach(items,id:\.0){item in let on=item.0==active;Button{active=item.0}label:{VStack(spacing:4){ZStack{if on{Circle().fill(Color.zxPurple.opacity(0.2)).frame(width:28,height:28).scaleEffect(1.4)};Image(systemName:item.2).font(.system(size:22,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)};Text(item.1).font(.system(size:10,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)}}.frame(maxWidth:.infinity)}}.padding(.top,6).padding(.bottom,34).frame(height:83).background(.ultraThinMaterial).background(Color.zxBg0.opacity(0.95)).overlay(alignment:.top){Rectangle().fill(Color.zxBorder008).frame(height:1)}} var body: some View{HStack(spacing:0){ForEach(items,id:\.0){item in let on=item.0==active;Button{active=item.0}label:{VStack(spacing:4){ZStack{if on{Circle().fill(Color.zxPurple.opacity(0.2)).frame(width:28,height:28).scaleEffect(1.4)};Image(systemName:item.2).font(.system(size:22,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)};Text(item.1).font(.system(size:10,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)}}.frame(maxWidth:.infinity)}.accessibilityLabel("\(item.1)标签")}.padding(.top,6).padding(.bottom,34).frame(height:83).background(.ultraThinMaterial).background(Color.zxBg0.opacity(0.95)).overlay(alignment:.top){Rectangle().fill(Color.zxBorder008).frame(height:1)}}
} }
struct ZXIconBtn: View { struct ZXIconBtn: View {
@ -56,5 +58,5 @@ struct ZXWeakRow: View {
struct ZXAIInputBar: View { struct ZXAIInputBar: View {
@Binding var text:String;let onSend:()->Void @Binding var text:String;let onSend:()->Void
var body: some View {HStack(spacing:10){Image(systemName:"sparkles").font(.system(size:16)).foregroundColor(Color.zxPurple);TextField("问 AI 任何学习问题…",text:$text).font(.system(size:14)).tint(Color.zxPurple);Spacer();Image(systemName:"mic.fill").font(.system(size:18)).foregroundColor(Color.zxF03);Button(action:onSend){Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9))}}.padding(.horizontal,14).padding(.vertical,10).background(.ultraThinMaterial).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius:20).stroke(Color.zxBorder008,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:20)).padding(.horizontal,20).padding(.bottom,34)} var body: some View {HStack(spacing:10){Image(systemName:"sparkles").font(.system(size:16)).foregroundColor(Color.zxPurple);TextField("问 AI 任何学习问题…",text:$text).font(.system(size:14)).tint(Color.zxPurple).accessibilityLabel("AI 学习问题输入框");Spacer();Image(systemName:"mic.fill").font(.system(size:18)).foregroundColor(Color.zxF03).accessibilityLabel("语音输入");Button(action:onSend){Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9))}.zxPressable().disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty).accessibilityLabel("发送消息")}.padding(.horizontal,14).padding(.vertical,10).background(.ultraThinMaterial).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius:20).stroke(Color.zxBorder008,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:20)).padding(.horizontal,20).padding(.bottom,34)}
} }

View File

@ -0,0 +1,307 @@
import SwiftUI
// MARK: - Animated Button Style
struct ZXButtonStyle: ButtonStyle {
let branded: Bool
init(branded: Bool = false) {
self.branded = branded
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.opacity(configuration.isPressed ? 0.85 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
.sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed)
}
}
// MARK: - Scale press modifier (quick apply)
struct ZXPressModifier: ViewModifier {
@State private var pressed = false
func body(content: Content) -> some View {
content
.scaleEffect(pressed ? 0.96 : 1.0)
.opacity(pressed ? 0.8 : 1.0)
.animation(.easeOut(duration: 0.12), value: pressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in pressed = true }
.onEnded { _ in pressed = false }
)
.sensoryFeedback(.impact(weight: .light), trigger: pressed)
}
}
extension View {
func zxPressable() -> some View {
modifier(ZXPressModifier())
}
}
// MARK: - Page transition modifier
struct ZXPageTransition: ViewModifier {
let edge: Edge
func body(content: Content) -> some View {
content
.transition(
.move(edge: edge)
.combined(with: .opacity)
)
}
}
extension View {
func zxPageTransition(from edge: Edge = .trailing) -> some View {
modifier(ZXPageTransition(edge: edge))
}
}
// MARK: - AI Thinking overlay
struct ZXThinkingOverlay: View {
let message: String
init(_ message: String = "AI 正在分析你的回答…") {
self.message = message
}
@State private var show = false
var body: some View {
ZStack {
Color.black.opacity(0.4).ignoresSafeArea()
VStack(spacing: 20) {
// Animated brain
ZStack {
Circle()
.fill(RadialGradient(
colors: [Color(hex: "#7C6EFA", opacity: 0.3), .clear],
center: .center, startRadius: 8, endRadius: 32
))
.frame(width: 64, height: 64)
.scaleEffect(show ? 1.3 : 0.8)
.animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: show)
Image(systemName: "brain.head.profile")
.font(.system(size: 28))
.foregroundColor(.white.opacity(0.9))
}
VStack(spacing: 12) {
Text(message)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
ZXDotLoader(color: .white)
}
}
.padding(32)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(.ultraThinMaterial)
)
}
.onAppear { show = true }
}
}
// MARK: - Celebration / Confetti effect
struct ZXCelebrationView: View {
let title: String
let subtitle: String
let onDismiss: () -> Void
@State private var particles: [ConfettiParticle] = []
@State private var showContent = false
var body: some View {
ZStack {
Color.black.opacity(0.5).ignoresSafeArea()
.onTapGesture { dismiss() }
// Particles
ForEach(particles) { p in
Circle()
.fill(p.color)
.frame(width: p.size, height: p.size)
.position(x: p.x, y: p.y)
.opacity(p.opacity)
.scaleEffect(p.scale)
}
// Content card
VStack(spacing: 20) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "sparkles")
.font(.system(size: 36))
.foregroundColor(.white)
}
.scaleEffect(showContent ? 1 : 0.5)
.animation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.2), value: showContent)
VStack(spacing: 6) {
Text(title)
.font(.system(size: 22, weight: .heavy))
.foregroundColor(.white)
Text(subtitle)
.font(.system(size: 14))
.foregroundColor(Color(hex: "#F0F0FF", opacity: 0.6))
}
.opacity(showContent ? 1 : 0)
.offset(y: showContent ? 0 : 20)
Button(action: dismiss) {
Text("继续学习")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(
LinearGradient(
colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.opacity(showContent ? 1 : 0)
}
.padding(28)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(.ultraThinMaterial)
)
.padding(.horizontal, 20)
}
.onAppear {
showContent = true
launchConfetti()
}
}
private func dismiss() {
withAnimation(.easeOut(duration: 0.25)) { showContent = false }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
onDismiss()
}
}
private func launchConfetti() {
let colors: [Color] = [Color(hex: "#7C6EFA"), Color(hex: "#F97316"),
Color(hex: "#A78BFA"), Color(hex: "#34D399"),
Color(hex: "#F59E0B"), Color(hex: "#4ECDC4")]
var ps: [ConfettiParticle] = []
for i in 0..<60 {
let delay = Double(i) * 0.015
let x = CGFloat.random(in: 0...UIScreen.main.bounds.width)
let endY = CGFloat.random(in: 80...UIScreen.main.bounds.height * 0.7)
let size = CGFloat.random(in: 4...10)
let color = colors.randomElement()!
ps.append(ConfettiParticle(
id: UUID(), color: color, size: size,
x: x, y: -30, targetY: endY,
scale: 1, opacity: 1, delay: delay
))
}
particles = ps
for p in particles {
withAnimation(.spring(response: 0.8, dampingFraction: 0.6).delay(p.delay)) {
if let idx = particles.firstIndex(where: { $0.id == p.id }) {
particles[idx].y = p.targetY
particles[idx].opacity = 0.4
particles[idx].scale = 0.3
}
}
}
}
}
private struct ConfettiParticle: Identifiable {
let id: UUID
let color: Color
let size: CGFloat
var x: CGFloat
var y: CGFloat
let targetY: CGFloat
var scale: CGFloat
var opacity: Double
let delay: Double
}
// MARK: - AI Analysis progress view
struct ZXAIAnalysisProgress: View {
let steps: [String]
@State private var currentStep = 0
@State private var progress: CGFloat = 0
var body: some View {
VStack(spacing: 24) {
ZStack {
ZXLoadingView(size: 48, lineWidth: 3)
}
VStack(spacing: 4) {
Text("AI 分析中…")
.font(.system(size: 17, weight: .bold))
.foregroundColor(Color.zxF0)
Text(steps[safe: currentStep] ?? steps.last ?? "")
.font(.system(size: 13))
.foregroundColor(Color.zxF04)
}
GeometryReader { g in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.zxFill008)
.frame(height: 6)
RoundedRectangle(cornerRadius: 3)
.fill(ZXGradient.progressBar)
.frame(width: g.size.width * progress, height: 6)
.animation(.easeInOut(duration: 0.6), value: progress)
}
}
.frame(height: 6)
.padding(.horizontal, 40)
}
.padding(28)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(.ultraThinMaterial)
)
.padding(.horizontal, 40)
.onAppear {
var delay: TimeInterval = 0.8
for i in 0..<steps.count {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
currentStep = i
progress = CGFloat(i + 1) / CGFloat(steps.count)
}
delay += 1.2
}
}
}
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@ -0,0 +1,135 @@
import SwiftUI
// MARK: - Branded loading spinner
struct ZXLoadingView: View {
let size: CGFloat
let lineWidth: CGFloat
init(size: CGFloat = 36, lineWidth: CGFloat = 3) {
self.size = size
self.lineWidth = lineWidth
}
@State private var rotation: Double = 0
var body: some View {
ZStack {
// rotating gradient arc
Circle()
.trim(from: 0.05, to: 0.8)
.stroke(
AngularGradient(
colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316"), Color(hex: "#7C6EFA")],
center: .center,
startAngle: .degrees(rotation),
endAngle: .degrees(rotation + 300)
),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.frame(width: size, height: size)
.rotationEffect(.degrees(rotation))
// center dot
Circle()
.fill(Color(hex: "#7C6EFA", opacity: 0.3))
.frame(width: size * 0.3, height: size * 0.3)
}
.onAppear {
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
rotation = 360
}
}
}
}
// MARK: - Full-screen loading overlay
struct ZXLoadingOverlay: View {
let message: String?
init(_ message: String? = nil) {
self.message = message
}
var body: some View {
ZStack {
Color.black.opacity(0.35).ignoresSafeArea()
VStack(spacing: 16) {
ZXLoadingView(size: 44, lineWidth: 3.5)
if let message {
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxF04)
}
}
.padding(28)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(.ultraThinMaterial)
)
}
}
}
// MARK: - Skeleton shimmer (for placeholder loading)
struct ZXShimmer: ViewModifier {
@State private var phase: CGFloat = -0.5
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
colors: [
Color.white.opacity(0),
Color.white.opacity(0.06),
Color.white.opacity(0),
],
startPoint: .leading,
endPoint: .trailing
)
.rotationEffect(.degrees(15))
.scaleEffect(2)
.offset(x: phase * 400)
.animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: phase)
)
.clipped()
.onAppear { phase = 1.5 }
}
}
extension View {
func zxShimmer() -> some View {
modifier(ZXShimmer())
}
}
// MARK: - Staggered dot loader (for inline use, e.g. AI thinking)
struct ZXDotLoader: View {
@State private var step = 0
let color: Color
init(color: Color = Color.zxPurple) {
self.color = color
}
var body: some View {
HStack(spacing: 6) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(color)
.frame(width: 8, height: 8)
.scaleEffect(step == i ? 1.2 : 0.7)
.opacity(step == i ? 1 : 0.4)
.animation(.easeInOut(duration: 0.4), value: step)
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
step = (step + 1) % 3
}
}
}
}

View File

@ -0,0 +1,119 @@
import SwiftUI
// MARK: - Refreshable ScrollView with load-more
struct ZXRefreshableScrollView<Content: View>: View {
let onRefresh: () async -> Void
let onLoadMore: (() async -> Void)?
let hasMore: Bool
let content: () -> Content
init(
onRefresh: @escaping () async -> Void,
onLoadMore: (() async -> Void)? = nil,
hasMore: Bool = false,
@ViewBuilder content: @escaping () -> Content
) {
self.onRefresh = onRefresh
self.onLoadMore = onLoadMore
self.hasMore = hasMore
self.content = content
}
@State private var isRefreshing = false
var body: some View {
ScrollView {
// Pull-to-refresh anchor
if isRefreshing {
VStack(spacing: 8) {
ZXLoadingView(size: 28, lineWidth: 2.5)
Text("刷新中…")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.padding(.bottom, 4)
}
content()
// Load-more footer
if let onLoadMore, hasMore {
ZXLoadMoreFooter(action: onLoadMore)
}
}
.scrollIndicators(.hidden)
.refreshable {
await onRefresh()
}
}
}
// MARK: - Load-more footer
struct ZXLoadMoreFooter: View {
let action: () async -> Void
@State private var isLoading = false
var body: some View {
HStack(spacing: 10) {
if isLoading {
ZXLoadingView(size: 20, lineWidth: 2)
Text("加载中…")
.font(.system(size: 13))
.foregroundColor(Color.zxF04)
} else {
Text("上拉加载更多")
.font(.system(size: 13))
.foregroundColor(Color.zxF04)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.padding(.bottom, 80)
.task {
isLoading = true
await action()
isLoading = false
}
}
}
// MARK: - Pull-to-refresh modifier (for plain ScrollView)
struct ZXPullToRefreshModifier: ViewModifier {
let onRefresh: () async -> Void
@State private var isRefreshing = false
func body(content: Content) -> some View {
VStack(spacing: 0) {
if isRefreshing {
HStack(spacing: 10) {
ZXLoadingView(size: 22, lineWidth: 2)
Text("正在刷新…")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
content
}
.animation(.easeInOut(duration: 0.25), value: isRefreshing)
.refreshable {
isRefreshing = true
await onRefresh()
isRefreshing = false
}
}
}
extension View {
func zxPullToRefresh(_ action: @escaping () async -> Void) -> some View {
modifier(ZXPullToRefreshModifier(onRefresh: action))
}
}

View File

@ -0,0 +1,153 @@
import SwiftUI
import Combine
// MARK: - Toast type
enum ZXToastType {
case success, error, warning, info
var icon: String {
switch self {
case .success: return "checkmark.circle.fill"
case .error: return "xmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .info: return "info.circle.fill"
}
}
var color: Color {
switch self {
case .success: return Color.zxGreen
case .error: return Color.zxRed
case .warning: return Color.zxOrange
case .info: return Color.zxPurple
}
}
}
// MARK: - Global toast manager
@MainActor
final class ZXToastManager: ObservableObject {
static let shared = ZXToastManager()
@Published var current: ZXToastItem?
private var queue: [ZXToastItem] = []
private var hideTask: Task<Void, Never>?
private init() {}
func show(_ message: String, type: ZXToastType = .info, duration: TimeInterval = 2.5) {
let item = ZXToastItem(message: message, type: type)
if current != nil {
queue.append(item)
current = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.showNext()
}
} else {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
current = item
}
scheduleHide(duration)
}
}
func success(_ message: String) { show(message, type: .success) }
func error(_ message: String) { show(message, type: .error) }
func warning(_ message: String) { show(message, type: .warning) }
func info(_ message: String) { show(message, type: .info) }
private func showNext() {
guard !queue.isEmpty else { return }
let next = queue.removeFirst()
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
current = next
}
scheduleHide(next.duration)
}
private func scheduleHide(_ duration: TimeInterval) {
hideTask?.cancel()
hideTask = Task {
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
guard !Task.isCancelled else { return }
await MainActor.run {
withAnimation(.easeOut(duration: 0.25)) {
current = nil
}
}
try? await Task.sleep(nanoseconds: 350_000_000)
showNext()
}
}
}
struct ZXToastItem: Equatable {
let id = UUID()
let message: String
let type: ZXToastType
let duration: TimeInterval
init(message: String, type: ZXToastType, duration: TimeInterval = 2.5) {
self.message = message
self.type = type
self.duration = duration
}
static func == (lhs: ZXToastItem, rhs: ZXToastItem) -> Bool { lhs.id == rhs.id }
}
// MARK: - Toast overlay modifier
struct ZXToastOverlay: ViewModifier {
@ObservedObject private var manager = ZXToastManager.shared
func body(content: Content) -> some View {
content.overlay(alignment: .top) {
if let item = manager.current {
ZXToastBar(item: item)
.padding(.horizontal, 20)
.padding(.top, ZXSpacing.statusBarH + 8)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
}
extension View {
func zxToast() -> some View {
modifier(ZXToastOverlay())
}
}
// MARK: - Toast bar view
struct ZXToastBar: View {
let item: ZXToastItem
var body: some View {
HStack(spacing: 10) {
Image(systemName: item.type.icon)
.font(.system(size: 16))
.foregroundColor(item.type.color)
Text(item.message)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.zxF0)
.lineLimit(2)
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(item.type.color.opacity(0.2), lineWidth: 1)
)
)
.shadow(color: Color.black.opacity(0.25), radius: 12, y: 4)
}
}

View File

@ -124,7 +124,7 @@ struct UserProfileData: Codable {
let currentGoal: String? let currentGoal: String?
} }
struct UserPreferences: Codable { struct UserPreferences: Codable, Equatable {
let preferredMethods: [String]? let preferredMethods: [String]?
let defaultFocusMinutes: Int? let defaultFocusMinutes: Int?
let aiSuggestionLevel: String? let aiSuggestionLevel: String?

View File

@ -0,0 +1,43 @@
import Foundation
final class FileCache {
private let directory: URL
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(suite: String) {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
directory = base.appendingPathComponent("FileCache/\(suite)", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
private func url(forKey key: String) -> URL {
directory.appendingPathComponent("\(key).json")
}
func save<T: Encodable>(_ value: T, forKey key: String) throws {
let data = try encoder.encode(value)
try data.write(to: url(forKey: key), options: .atomic)
}
func load<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T? {
let fileURL = url(forKey: key)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
let data = try Data(contentsOf: fileURL)
return try decoder.decode(T.self, from: data)
}
func remove(forKey key: String) throws {
let fileURL = url(forKey: key)
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
}
func clear() throws {
if FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.removeItem(at: directory)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
}

View File

@ -0,0 +1,92 @@
import Foundation
/// Lightweight offline cache wrapper: memory disk network fallback.
/// Uses UserDefaults for small values, FileCache for larger blobs.
@MainActor
final class LocalCache {
static let shared = LocalCache()
private let defaults = UserDefaults.standard
private let fileCache = FileCache(suite: "local_cache")
private init() {}
// MARK: - Simple values (UserDefaults)
func get<T>(_ key: String) -> T? where T: Decodable {
// Try memory/disk via FileCache first, then UserDefaults
if let cached: T = try? fileCache.load(T.self, forKey: key) {
return cached
}
return nil
}
func set<T>(_ value: T, forKey key: String) where T: Encodable {
try? fileCache.save(value, forKey: key)
}
func remove(_ key: String) {
try? fileCache.remove(forKey: key)
}
// MARK: - Array caching (common pattern)
func getList<T: Decodable>(_ key: String) -> [T] {
(try? fileCache.load([T].self, forKey: key)) ?? []
}
func setList<T: Encodable>(_ items: [T], forKey key: String) {
try? fileCache.save(items, forKey: key)
}
// MARK: - Expiry-based caching
func getWithExpiry<T: Decodable>(_ key: String, ttl: TimeInterval = 300) -> T? {
let expiryKey = "\(key)_expiry"
let expiry = defaults.double(forKey: expiryKey)
guard expiry == 0 || Date().timeIntervalSince1970 < expiry else {
remove(key)
defaults.removeObject(forKey: expiryKey)
return nil
}
return get(key)
}
func setWithExpiry<T: Encodable>(_ value: T, forKey key: String, ttl: TimeInterval = 300) {
set(value, forKey: key)
defaults.set(Date().timeIntervalSince1970 + ttl, forKey: "\(key)_expiry")
}
func clearAll() {
try? fileCache.clear()
}
}
// MARK: - ViewModel caching helper
extension LocalCache {
/// Wrap an API fetch with cache-first strategy.
/// Returns cached data instantly, then refreshes in background.
func cacheFirst<T: Codable>(
key: String,
ttl: TimeInterval = 300,
fetch: @Sendable () async throws -> T
) async throws -> T {
if let cached: T = getWithExpiry(key, ttl: ttl) {
Task { try? await refreshCache(key: key, ttl: ttl, fetch: fetch) }
return cached
}
let fresh = try await fetch()
setWithExpiry(fresh, forKey: key, ttl: ttl)
return fresh
}
private func refreshCache<T: Codable>(
key: String,
ttl: TimeInterval,
fetch: @Sendable () async throws -> T
) async throws {
let fresh = try await fetch()
setWithExpiry(fresh, forKey: key, ttl: ttl)
}
}

View File

@ -0,0 +1,42 @@
import Combine
import Foundation
struct AIMessage: Identifiable {
let id = UUID()
let role: AIMessageRole
let content: String
}
enum AIMessageRole {
case user, ai
}
@MainActor
final class AIChatViewModel: ObservableObject {
@Published var messages: [AIMessage] = [
AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手。")
]
@Published var inputText = ""
@Published var isSending = false
var canSend: Bool {
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending
}
func send() {
guard canSend else { return }
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
messages.append(AIMessage(role: .user, content: text))
inputText = ""
isSending = true
Task {
try? await Task.sleep(nanoseconds: 1_200_000_000)
messages.append(AIMessage(
role: .ai,
content: "好的,我理解你的问题。需要我帮你制定学习计划吗?"
))
isSending = false
}
}
}

View File

@ -96,6 +96,8 @@ struct AIHomeView: View {
.frame(maxWidth:.infinity).frame(height:42) .frame(maxWidth:.infinity).frame(height:42)
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12)) .background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12))
} }
.accessibilityLabel("开始回答今日思考题")
.accessibilityHint("用费曼方法解释注意力机制")
} }
.padding(16).background(ZXGradient.thinkingCard) .padding(16).background(ZXGradient.thinkingCard)
.overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.1),lineWidth:1)) .overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.1),lineWidth:1))
@ -165,6 +167,7 @@ struct AIHomeView: View {
Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white) Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white)
.frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9)) .frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9))
} }
.accessibilityLabel("发送消息,开始 AI 对话")
} }
.padding(.horizontal,14).padding(.vertical,10) .padding(.horizontal,14).padding(.vertical,10)
.background(.ultraThinMaterial).background(Color.zxFill004) .background(.ultraThinMaterial).background(Color.zxFill004)
@ -187,6 +190,7 @@ struct ZXQuickAction: View {
} }
.frame(width:72,height:72) .frame(width:72,height:72)
.background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16)) .background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16))
.accessibilityLabel(label.replacingOccurrences(of: "\n", with: ""))
} }
} }

View File

@ -13,6 +13,8 @@ struct ActiveRecallView: View {
@State private var currentAnswer = "" @State private var currentAnswer = ""
@State private var submitted: Set<String> = [] @State private var submitted: Set<String> = []
@State private var showFinish = false @State private var showFinish = false
@State private var showThinking = false
@State private var showCelebration = false
var current: RecallQuestion { questions[idx] } var current: RecallQuestion { questions[idx] }
@ -40,6 +42,18 @@ struct ActiveRecallView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
.task { await viewModel.loadQuestions() } .task { await viewModel.loadQuestions() }
.overlay {
if showThinking {
ZXThinkingOverlay("AI 正在分析你的回答…")
}
}
.overlay {
if showCelebration {
ZXCelebrationView(title: "回忆完成", subtitle: "你已完成所有主动回忆题目AI 分析结果已生成") {
withAnimation(.easeOut(duration: 0.3)) { showCelebration = false }
}
}
}
} }
private var isSubmitted: Bool { submitted.contains(current.id) } private var isSubmitted: Bool { submitted.contains(current.id) }
@ -91,6 +105,9 @@ struct ActiveRecallView: View {
.background(ZXGradient.thinkingCard) .background(ZXGradient.thinkingCard)
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .combine)
.accessibilityLabel("问题 \(idx + 1)\(current.question)")
.accessibilityHint(current.isVoice ? "语音题,双击录音回答" : "文字题,在下方输入回答")
} }
private var answerInput: some View { private var answerInput: some View {
@ -116,6 +133,13 @@ struct ActiveRecallView: View {
answers[current.id] = current.isVoice ? "语音答案已录制" : currentAnswer answers[current.id] = current.isVoice ? "语音答案已录制" : currentAnswer
submitted.insert(current.id) submitted.insert(current.id)
currentAnswer = "" currentAnswer = ""
if submitted.count == questions.count {
showThinking = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
showThinking = false
showCelebration = true
}
}
} label: { } label: {
Text("提交回答") Text("提交回答")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 14, weight: .bold))
@ -124,8 +148,11 @@ struct ActiveRecallView: View {
.background(ZXGradient.ctaPurple) .background(ZXGradient.ctaPurple)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
} }
.zxPressable()
.disabled(currentAnswer.isEmpty && !current.isVoice) .disabled(currentAnswer.isEmpty && !current.isVoice)
.opacity(currentAnswer.isEmpty && !current.isVoice ? 0.5 : 1) .opacity(currentAnswer.isEmpty && !current.isVoice ? 0.5 : 1)
.accessibilityLabel("提交回答")
.accessibilityHint("提交后可由 AI 分析你的回答质量")
} }
} }
@ -168,8 +195,11 @@ struct ActiveRecallView: View {
.background(ZXGradient.brand) .background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
} }
.zxPressable()
} else { } else {
NavigationLink(destination: AIFeedbackPageView()) { Button {
showCelebration = true
} label: {
Label("查看 AI 分析结果", systemImage: "sparkles") Label("查看 AI 分析结果", systemImage: "sparkles")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 14, weight: .bold))
.foregroundColor(.white) .foregroundColor(.white)
@ -177,6 +207,7 @@ struct ActiveRecallView: View {
.background(ZXGradient.ctaPurple) .background(ZXGradient.ctaPurple)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
} }
.zxPressable()
} }
} }
} }

View File

@ -10,7 +10,7 @@ struct DailyThinkingPage: View {
Text("AI会从三个方面评估你的回答核心概念理解 · 理论深度 · 实际应用能力").font(.system(size:12)).foregroundColor(Color.zxF04) Text("AI会从三个方面评估你的回答核心概念理解 · 理论深度 · 实际应用能力").font(.system(size:12)).foregroundColor(Color.zxF04)
}.padding(16).background(ZXGradient.thinkingCard).clipShape(RoundedRectangle(cornerRadius:16)) }.padding(16).background(ZXGradient.thinkingCard).clipShape(RoundedRectangle(cornerRadius:16))
VStack(alignment:.leading,spacing:8){Text("你的回答").font(.system(size:13,weight:.semibold)).foregroundColor(Color.zxF04);TextEditor(text:$answer).font(.system(size:13)).foregroundColor(Color.zxF0).tint(Color.zxPurple).frame(minHeight:160).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1))} VStack(alignment:.leading,spacing:8){Text("你的回答").font(.system(size:13,weight:.semibold)).foregroundColor(Color.zxF04);TextEditor(text:$answer).font(.system(size:13)).foregroundColor(Color.zxF0).tint(Color.zxPurple).frame(minHeight:160).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1))}
if !submitted{ NavigationLink(destination:AIFeedbackPageView()){ Text("提交回答,获取 AI 反馈").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16)).shadow(color:Color(hex:"#7C6EFA",opacity:0.3),radius:24) } } if !submitted{ NavigationLink(destination:AIFeedbackPageView()){ Text("提交回答,获取 AI 反馈").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16)).shadow(color:Color(hex:"#7C6EFA",opacity:0.3),radius:24) }.zxPressable() }
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,120) }.scrollIndicators(.hidden) }.padding(.horizontal,20).padding(.top, 8).padding(.bottom,120) }.scrollIndicators(.hidden)
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar) }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)
} }
@ -25,22 +25,171 @@ struct WeakPointsPage: View { var body: some View { ZStack{Color.zxBg0.ignoresSa
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} } }.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} }
struct AIFeedbackPageView: View { struct AIFeedbackPageView: View {
@State private var navigateToChat = false @State private var navigateToChat = false
@State private var isAnalyzing = true
var body: some View { var body: some View {
ZStack{Color.zxBg0.ignoresSafeArea();ScrollView{VStack(spacing:16){ HStack(spacing:20){ ZStack{Circle().trim(from:0,to:0.78).stroke(ZXGradient.brand,style:StrokeStyle(lineWidth:10,lineCap:.round)).rotationEffect(.degrees(-90)).frame(width:80,height:80);VStack(spacing:0){Text("78").font(.system(size:22,weight:.heavy)).foregroundColor(Color.zxPurple);Text("/ 100").font(.system(size:9)).foregroundColor(Color.zxF04)}};VStack(alignment:.leading,spacing:2){Text("良好掌握").font(.system(size:18,weight:.heavy)).foregroundColor(Color.zxF0);Text("理解核心概念,但缺少理论深度和解决方案").font(.system(size:12)).foregroundColor(Color.zxF0045).lineSpacing(4)};Spacer() }.padding(20).background(ZXGradient.feedbackScore).clipShape(RoundedRectangle(cornerRadius:20)).overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.2),lineWidth:1)) ZStack {
VStack(alignment:.leading,spacing:8){Text("你的回答").font(.system(size:13,weight:.semibold)).foregroundColor(Color.zxF04);Text("过拟合就像一个学生只会「死记硬背」考题,而不是真正理解知识…").font(.system(size:13)).foregroundColor(Color.zxF007).lineSpacing(6).padding(14).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder006,lineWidth:1))} Color.zxBg0.ignoresSafeArea()
VStack(alignment:.leading,spacing:8){HStack(spacing:8){Image(systemName:"checkmark.circle.fill").foregroundColor(Color.zxGreen);Text("答对的部分").font(.system(size:14,weight:.bold)).foregroundColor(Color.zxF0)};ForEach(["正确识别出过拟合是\"记住训练数据\"而非\"学习规律\"","使用了死记硬背类比,方向正确且贴切"],id:\.self){s in HStack(alignment:.top,spacing:12){Circle().fill(Color.zxGreen).frame(width:6,height:6).padding(.top,6);Text(s).font(.system(size:13)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.75)).lineSpacing(4)}.padding(12).background(Color(hex:"#34D399",opacity:0.07)).clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(Color(hex:"#34D399",opacity:0.18),lineWidth:1))}} if isAnalyzing {
NavigationLink(destination: StudyHomeView()) { ZXAIAnalysisProgress(steps: [
Label("加入待巩固,安排间隔复习",systemImage:"bolt.fill").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16)).shadow(color:Color(hex:"#7C6EFA",opacity:0.3),radius:24) "解析你的回答结构…",
} "对比知识库标准答案…",
HStack(spacing:12){ "评估概念理解深度…",
NavigationLink(destination: AIChatPage()) { "生成个性化反馈…"
HStack(spacing:4){Text("深入提问").font(.system(size:13));Image(systemName:"chevron.right").font(.system(size:14))}.foregroundColor(Color.zxF05).frame(maxWidth:.infinity).frame(height:44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1)) ])
} .onAppear {
NavigationLink(destination: DailyThinkingPage()) { DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
HStack(spacing:4){Text("再来一题").font(.system(size:13));Image(systemName:"chevron.right").font(.system(size:14))}.foregroundColor(Color.zxF05).frame(maxWidth:.infinity).frame(height:44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1)) withAnimation(.easeOut(duration: 0.4)) { isAnalyzing = false }
}
}
} else {
ScrollView {
VStack(spacing: 16) {
HStack(spacing: 20) {
ZStack {
Circle().trim(from: 0, to: 0.78).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 10, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 80, height: 80)
VStack(spacing: 0) {
Text("78").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxPurple)
Text("/ 100").font(.system(size: 9)).foregroundColor(Color.zxF04)
}
}
VStack(alignment: .leading, spacing: 2) {
Text("良好掌握").font(.system(size: 18, weight: .heavy)).foregroundColor(Color.zxF0)
Text("理解核心概念,但缺少理论深度和解决方案").font(.system(size: 12)).foregroundColor(Color.zxF0045).lineSpacing(4)
}
Spacer()
}
.padding(20)
.background(ZXGradient.feedbackScore)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1))
VStack(alignment: .leading, spacing: 8) {
Text("你的回答").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF04)
Text("过拟合就像一个学生只会「死记硬背」考题,而不是真正理解知识…").font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(6).padding(14).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1))
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill").foregroundColor(Color.zxGreen)
Text("答对的部分").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
}
ForEach(["正确识别出过拟合是\"记住训练数据\"而非\"学习规律\"", "使用了死记硬背类比,方向正确且贴切"], id: \.self) { s in
HStack(alignment: .top, spacing: 12) {
Circle().fill(Color.zxGreen).frame(width: 6, height: 6).padding(.top, 6)
Text(s).font(.system(size: 13)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.75)).lineSpacing(4)
}
.padding(12)
.background(Color(hex: "#34D399", opacity: 0.07))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color(hex: "#34D399", opacity: 0.18), lineWidth: 1))
}
}
NavigationLink(destination: StudyHomeView()) {
Label("加入待巩固,安排间隔复习", systemImage: "bolt.fill")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 52)
.background(ZXGradient.ctaPurple)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 24)
}
HStack(spacing: 12) {
NavigationLink(destination: AIChatPage()) {
HStack(spacing: 4) {
Text("深入提问").font(.system(size: 13))
Image(systemName: "chevron.right").font(.system(size: 14))
}
.foregroundColor(Color.zxF05)
.frame(maxWidth: .infinity).frame(height: 44)
.background(Color.zxFill005)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
NavigationLink(destination: DailyThinkingPage()) {
HStack(spacing: 4) {
Text("再来一题").font(.system(size: 13))
Image(systemName: "chevron.right").font(.system(size: 14))
}
.foregroundColor(Color.zxF05)
.frame(maxWidth: .infinity).frame(height: 44)
.background(Color.zxFill005)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
}
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}
.scrollIndicators(.hidden)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
} }
} }
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
}
}
// MARK: - AI Chat
struct AIChatPage: View {
@StateObject private var vm = AIChatViewModel()
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 16) {
ForEach(vm.messages) { m in
chatBubble(m)
.id(m.id)
}
if vm.isSending {
HStack(spacing: 8) {
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple)
.frame(width: 28, height: 28)
.background(Color(hex: "#7C6EFA", opacity: 0.15))
.clipShape(Circle())
ZXDotLoader(color: Color.zxPurple)
.padding(.leading, 4)
Spacer()
}
.padding(.horizontal, 20)
}
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}
.scrollIndicators(.hidden)
.onChange(of: vm.messages.count) { _ in
withAnimation { proxy.scrollTo(vm.messages.last?.id) }
}
}
ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() })
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
}
private func chatBubble(_ m: AIMessage) -> some View {
HStack(alignment: .top, spacing: 8) {
if m.role == .ai {
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple)
.frame(width: 28, height: 28)
.background(Color(hex: "#7C6EFA", opacity: 0.15))
.clipShape(Circle())
}
Text(m.content).font(.system(size: 14))
.foregroundColor(m.role == .user ? .white : Color.zxF007)
.padding(12)
.background(m.role == .user ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004))
.clipShape(RoundedRectangle(cornerRadius: 16))
if m.role == .user {
Circle().frame(width: 28, height: 28)
.foregroundColor(Color.zxPurpleBG(0.2))
.overlay(Text("").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple))
}
}
.frame(maxWidth: .infinity, alignment: m.role == .user ? .trailing : .leading)
} }
} }
struct AIChatPage: View { @State private var msg="";@State private var msgs:[(String,String)]=[("ai","你好!我是你的 AI 学习助手。")]; var body: some View { ZStack{Color.zxBg0.ignoresSafeArea();VStack(spacing:0){ScrollViewReader{proxy in ScrollView{VStack(spacing:16){ForEach(Array(msgs.enumerated()),id:\.offset){i,m in HStack(alignment:.top,spacing:8){if m.0=="ai"{Image(systemName:"brain.head.profile").foregroundColor(Color.zxPurple).frame(width:28,height:28).background(Color(hex:"#7C6EFA",opacity:0.15)).clipShape(Circle())};Text(m.1).font(.system(size:14)).foregroundColor(m.0=="user" ? .white:Color.zxF007).padding(12).background(m.0=="user" ? AnyView(ZXGradient.brandPurple):AnyView(Color.zxFill004)).clipShape(RoundedRectangle(cornerRadius:16));if m.0=="user"{Circle().frame(width:28,height:28).foregroundColor(Color.zxPurpleBG(0.2)).overlay(Text("").font(.system(size:10,weight:.bold)).foregroundColor(Color.zxPurple))}}.frame(maxWidth:.infinity,alignment:m.0=="user" ? .trailing:.leading)}}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,100).id("bottom")}.scrollIndicators(.hidden).onChange(of:msgs.count){withAnimation{proxy.scrollTo("bottom")}}};ZXAIInputBar(text:$msg,onSend:{guard !msg.isEmpty else{return};msgs.append(("user",msg));msg="";DispatchQueue.main.asyncAfter(deadline:.now()+1){msgs.append(("ai","好的,我理解你的问题。需要我帮你制定学习计划吗?"))}})}}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} }

View File

@ -9,40 +9,32 @@ class ActivityViewModel: ObservableObject {
@Published var isLoading = false @Published var isLoading = false
@Published var errorMessage: String? @Published var errorMessage: String?
func loadSummary() async {
isLoading = true
errorMessage = nil
do {
summary = try await ActivityService.shared.summary()
} catch {
errorMessage = "加载学习统计失败"
}
isLoading = false
}
func loadFocusItems() async {
do {
focusItems = try await FocusItemService.shared.list()
} catch {
errorMessage = "加载弱项列表失败"
}
}
func loadHeatmap() async {
do {
heatmap = try await ActivityService.shared.heatmap()
} catch {
// heatmap is non-critical, silently fail
}
}
func loadAll() async { func loadAll() async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
async let summaryTask: () = loadSummary() do {
async let focusTask: () = loadFocusItems() async let s = ActivityService.shared.summary()
async let heatmapTask: () = loadHeatmap() async let f = FocusItemService.shared.list()
_ = await (summaryTask, focusTask, heatmapTask) async let h = ActivityService.shared.heatmap()
let (summaryResult, focusResult, heatmapResult) = try await (s, f, h)
summary = summaryResult
focusItems = focusResult
heatmap = heatmapResult
} catch {
if summary == nil { errorMessage = "加载分析数据失败" }
}
isLoading = false isLoading = false
} }
func refresh() async {
do {
async let s = ActivityService.shared.summary()
async let f = FocusItemService.shared.list()
async let h = ActivityService.shared.heatmap()
let (summaryResult, focusResult, heatmapResult) = try await (s, f, h)
summary = summaryResult
focusItems = focusResult
heatmap = heatmapResult
} catch {}
}
} }

View File

@ -15,6 +15,10 @@ struct AnalysisHomeView: View {
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12) .padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12)
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
if viewModel.isLoading && viewModel.summary == nil {
VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
.frame(maxWidth: .infinity).padding(.top, 80)
}
HStack(spacing: 12) { HStack(spacing: 12) {
ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "\(viewModel.summary?.dailyAverage ?? 0)%", trend: "", color: Color.zxPurple) ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "\(viewModel.summary?.dailyAverage ?? 0)%", trend: "", color: Color.zxPurple)
ZXStatBadge(icon: "bolt.fill", label: "总分钟", value: "\(viewModel.summary?.totalMinutes ?? 0)", trend: "", color: Color.zxOrange) ZXStatBadge(icon: "bolt.fill", label: "总分钟", value: "\(viewModel.summary?.totalMinutes ?? 0)", trend: "", color: Color.zxOrange)
@ -26,7 +30,7 @@ struct AnalysisHomeView: View {
ZXChartView() ZXChartView()
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(destination: WeakPointsPage()) { Text("全部 \(viewModel.focusItems.count)").font(.system(size: 12)).foregroundColor(Color.zxPurple) } } HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(destination: WeakPointsPage()) { Text("全部 \(viewModel.focusItems.count)").font(.system(size: 12)).foregroundColor(Color.zxPurple) }.accessibilityLabel("查看全部薄弱知识点") }
ForEach(viewModel.focusItems.prefix(5)) { item in ForEach(viewModel.focusItems.prefix(5)) { item in
ZXWeakRow(score: item.masteryScore ?? 0, topic: item.title, lib: item.knowledgeBaseId ?? "", priority: item.priority ?? "normal") ZXWeakRow(score: item.masteryScore ?? 0, topic: item.title, lib: item.knowledgeBaseId ?? "", priority: item.priority ?? "normal")
} }
@ -35,7 +39,9 @@ struct AnalysisHomeView: View {
} }
} }
}.padding(.horizontal, 20).padding(.bottom, 120) }.padding(.horizontal, 20).padding(.bottom, 120)
}.scrollIndicators(.hidden) }
.scrollIndicators(.hidden)
.zxPullToRefresh { await viewModel.refresh() }
} }
} }
.task { await viewModel.loadAll() } .task { await viewModel.loadAll() }

View File

@ -17,16 +17,23 @@ struct LibraryHomeView: View {
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
} }
.accessibilityLabel("搜索知识库")
NavigationLink(destination: ImportPage()) { NavigationLink(destination: ImportPage()) {
Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white) Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white)
.frame(width: 36, height: 36).background(ZXGradient.brand) .frame(width: 36, height: 36).background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
} }
.accessibilityLabel("导入新知识库")
} }
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12) .padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12)
HStack(spacing: 8) { Image(systemName: "magnifyingglass").font(.system(size: 16)).foregroundColor(Color.zxF03); TextField("搜索知识库或知识点…", text: $s).font(.system(size: 14)).tint(Color.zxPurple) } HStack(spacing: 8) { Image(systemName: "magnifyingglass").font(.system(size: 16)).foregroundColor(Color.zxF03); TextField("搜索知识库或知识点…", text: $s).font(.system(size: 14)).tint(Color.zxPurple).accessibilityLabel("搜索知识库") }
.padding(.horizontal, 14).frame(height: 44).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).padding(.horizontal, 20).padding(.bottom, 16) .padding(.horizontal, 14).frame(height: 44).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).padding(.horizontal, 20).padding(.bottom, 16)
.accessibilityHint("输入关键词搜索知识库或知识点")
ScrollView { VStack(spacing: 12) { ScrollView { VStack(spacing: 12) {
if viewModel.isLoading && viewModel.knowledgeBases.isEmpty {
VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
.frame(maxWidth: .infinity).padding(.top, 80)
}
ForEach(viewModel.knowledgeBases) { kb in ForEach(viewModel.knowledgeBases) { kb in
NavigationLink(destination: LibraryDetailPage(knowledgeBaseId: kb.id)) { NavigationLink(destination: LibraryDetailPage(knowledgeBaseId: kb.id)) {
ZLibraryCard(emoji: "📚", name: kb.title, desc: kb.description ?? "", color: Color.zxPurple, items: kb.itemCount ?? 0, mastery: 50, tags: [], last: lastStudiedText(kb.lastStudiedAt)) ZLibraryCard(emoji: "📚", name: kb.title, desc: kb.description ?? "", color: Color.zxPurple, items: kb.itemCount ?? 0, mastery: 50, tags: [], last: lastStudiedText(kb.lastStudiedAt))
@ -35,13 +42,20 @@ struct LibraryHomeView: View {
if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading { if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading {
Text("还没有知识库,点击右上角 + 创建").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40) Text("还没有知识库,点击右上角 + 创建").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
} }
if viewModel.hasMore {
ZXLoadMoreFooter { await viewModel.loadMore() }
}
NavigationLink(destination: CreateLibraryPage()) { NavigationLink(destination: CreateLibraryPage()) {
HStack(spacing: 8) { Image(systemName: "plus").font(.system(size: 16)); Text("创建新知识库").font(.system(size: 14, weight: .semibold)) } HStack(spacing: 8) { Image(systemName: "plus").font(.system(size: 16)); Text("创建新知识库").font(.system(size: 14, weight: .semibold)) }
.foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill003) .foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill003)
.overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]), antialiased: true).foregroundColor(Color.zxBorder01)) .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]), antialiased: true).foregroundColor(Color.zxBorder01))
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
} }
}.padding(.horizontal, 20).padding(.bottom, 120) }.scrollIndicators(.hidden) .accessibilityLabel("创建新知识库")
.accessibilityHint("导入文档或文本生成结构化知识库")
}.padding(.horizontal, 20).padding(.bottom, 120) }
.scrollIndicators(.hidden)
.zxPullToRefresh { await viewModel.refresh() }
} }
} }
.task { await viewModel.loadKnowledgeBases() } .task { await viewModel.loadKnowledgeBases() }

View File

@ -26,6 +26,10 @@ struct LibraryDetailPage: View {
} }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8) }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
ScrollView { VStack(spacing: 12) { ScrollView { VStack(spacing: 12) {
if viewModel.isLoading && viewModel.items.isEmpty {
VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
.frame(maxWidth: .infinity).padding(.top, 80)
}
ForEach(viewModel.items) { item in ForEach(viewModel.items) { item in
NavigationLink(destination: KnowledgeDetailPage(item: item)) { NavigationLink(destination: KnowledgeDetailPage(item: item)) {
ZXCardRow(emoji: "📝", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen) ZXCardRow(emoji: "📝", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
@ -34,7 +38,12 @@ struct LibraryDetailPage: View {
if viewModel.items.isEmpty && !viewModel.isLoading { if viewModel.items.isEmpty && !viewModel.isLoading {
Text("暂无知识点").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40) Text("暂无知识点").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
} }
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) } if viewModel.hasMore {
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
}
}.padding(.horizontal, 20).padding(.bottom, 80) }
.scrollIndicators(.hidden)
.zxPullToRefresh { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) } }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar) }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
.task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) } .task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) }
} }

View File

@ -5,26 +5,59 @@ import Foundation
class LibraryViewModel: ObservableObject { class LibraryViewModel: ObservableObject {
@Published var knowledgeBases: [KnowledgeBase] = [] @Published var knowledgeBases: [KnowledgeBase] = []
@Published var isLoading = false @Published var isLoading = false
@Published var isRefreshing = false
@Published var isLoadingMore = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var hasMore = true
private var currentPage = 1
private let pageSize = 20
func loadKnowledgeBases() async { func loadKnowledgeBases() async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
currentPage = 1
do { do {
knowledgeBases = try await KnowledgeBaseService.shared.list() knowledgeBases = try await KnowledgeBaseService.shared.list(page: 1, limit: pageSize)
hasMore = knowledgeBases.count >= pageSize
} catch { } catch {
errorMessage = "加载知识库失败" if knowledgeBases.isEmpty { errorMessage = "加载知识库失败" }
} }
isLoading = false isLoading = false
} }
func refresh() async {
isRefreshing = true
currentPage = 1
do {
knowledgeBases = try await KnowledgeBaseService.shared.list(page: 1, limit: pageSize)
hasMore = knowledgeBases.count >= pageSize
} catch {}
isRefreshing = false
}
func loadMore() async {
guard !isLoadingMore, hasMore else { return }
isLoadingMore = true
currentPage += 1
do {
let more = try await KnowledgeBaseService.shared.list(page: currentPage, limit: pageSize)
knowledgeBases.append(contentsOf: more)
hasMore = more.count >= pageSize
} catch {
currentPage -= 1
}
isLoadingMore = false
}
func createKnowledgeBase(title: String, description: String?) async -> KnowledgeBase? { func createKnowledgeBase(title: String, description: String?) async -> KnowledgeBase? {
do { do {
let kb = try await KnowledgeBaseService.shared.create(title: title, description: description) let kb = try await KnowledgeBaseService.shared.create(title: title, description: description)
knowledgeBases.insert(kb, at: 0) knowledgeBases.insert(kb, at: 0)
ZXToastManager.shared.success("知识库已创建")
return kb return kb
} catch { } catch {
errorMessage = "创建知识库失败" ZXToastManager.shared.error("创建失败")
return nil return nil
} }
} }
@ -33,8 +66,9 @@ class LibraryViewModel: ObservableObject {
do { do {
_ = try await KnowledgeBaseService.shared.delete(id: id) _ = try await KnowledgeBaseService.shared.delete(id: id)
knowledgeBases.removeAll { $0.id == id } knowledgeBases.removeAll { $0.id == id }
ZXToastManager.shared.success("已删除")
} catch { } catch {
errorMessage = "删除知识库失败" ZXToastManager.shared.error("删除失败")
} }
} }
} }
@ -44,25 +78,55 @@ class LibraryDetailViewModel: ObservableObject {
@Published var items: [KnowledgeItem] = [] @Published var items: [KnowledgeItem] = []
@Published var knowledgeBase: KnowledgeBase? @Published var knowledgeBase: KnowledgeBase?
@Published var isLoading = false @Published var isLoading = false
@Published var isRefreshing = false
@Published var isLoadingMore = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var hasMore = true
private var currentPage = 1
private let pageSize = 20
func loadItems(knowledgeBaseId: String) async { func loadItems(knowledgeBaseId: String) async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
currentPage = 1
do { do {
items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId) items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
hasMore = items.count >= pageSize
} catch { } catch {
errorMessage = "加载知识点失败" if items.isEmpty { errorMessage = "加载知识点失败" }
} }
isLoading = false isLoading = false
} }
func refresh(knowledgeBaseId: String) async {
isRefreshing = true
currentPage = 1
do {
items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
hasMore = items.count >= pageSize
} catch {}
isRefreshing = false
}
func loadMore(knowledgeBaseId: String) async {
guard !isLoadingMore, hasMore else { return }
isLoadingMore = true
currentPage += 1
do {
let more = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
items.append(contentsOf: more)
hasMore = more.count >= pageSize
} catch {
currentPage -= 1
}
isLoadingMore = false
}
func loadKnowledgeBase(id: String) async { func loadKnowledgeBase(id: String) async {
do { do {
knowledgeBase = try await KnowledgeBaseService.shared.detail(id: id) knowledgeBase = try await KnowledgeBaseService.shared.detail(id: id)
} catch { } catch {}
errorMessage = "加载知识库详情失败"
}
} }
func addItem(knowledgeBaseId: String, title: String, content: String?) async -> KnowledgeItem? { func addItem(knowledgeBaseId: String, title: String, content: String?) async -> KnowledgeItem? {
@ -71,9 +135,10 @@ class LibraryDetailViewModel: ObservableObject {
knowledgeBaseId: knowledgeBaseId, title: title, content: content knowledgeBaseId: knowledgeBaseId, title: title, content: content
) )
items.append(item) items.append(item)
ZXToastManager.shared.success("知识点已添加")
return item return item
} catch { } catch {
errorMessage = "添加知识点失败" ZXToastManager.shared.error("添加失败")
return nil return nil
} }
} }

View File

@ -8,6 +8,8 @@ struct EditProfilePage: View {
@State private var bio: String = "" @State private var bio: String = ""
@State private var currentGoal: String = "" @State private var currentGoal: String = ""
@State private var saved = false @State private var saved = false
@State private var isSaving = false
@State private var saveError: String?
var body: some View { var body: some View {
ZStack { ZStack {
@ -49,28 +51,48 @@ struct EditProfilePage: View {
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)) .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
if let error = saveError {
Text(error).font(.system(size: 13)).foregroundColor(.red)
.padding(.horizontal, 16).padding(.vertical, 10)
.background(Color.red.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
Button { Button {
Task { Task {
_ = try? await UserService.shared.updateProfile(UpdateProfileRequest( isSaving = true
nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil saveError = nil
)) do {
_ = try? await UserService.shared.updateProfileDetail(UpdateProfileDataRequest( _ = try await UserService.shared.updateProfile(UpdateProfileRequest(
learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity, nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil
learningDirection: learningDirection.isEmpty ? nil : learningDirection, ))
bio: bio.isEmpty ? nil : bio, _ = try await UserService.shared.updateProfileDetail(UpdateProfileDataRequest(
currentGoal: currentGoal.isEmpty ? nil : currentGoal learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity,
)) learningDirection: learningDirection.isEmpty ? nil : learningDirection,
saved = true bio: bio.isEmpty ? nil : bio,
currentGoal: currentGoal.isEmpty ? nil : currentGoal
))
saved = true
} catch {
saveError = "保存失败: \(error.localizedDescription)"
}
isSaving = false
} }
} label: { } label: {
Text(saved ? "已保存" : "保存修改") HStack(spacing: 8) {
.font(.system(size: 14, weight: .bold)) if isSaving { ProgressView().tint(.white) }
.foregroundColor(.white) Text(saved ? "已保存" : "保存修改")
.frame(maxWidth: .infinity) .font(.system(size: 14, weight: .bold))
.frame(height: 52) .foregroundColor(.white)
.background(ZXGradient.ctaPurple) }
.clipShape(RoundedRectangle(cornerRadius: 16)) .frame(maxWidth: .infinity)
.frame(height: 52)
.background {
if isSaving { Color.gray } else { ZXGradient.ctaPurple }
}
.clipShape(RoundedRectangle(cornerRadius: 16))
} }
.disabled(isSaving)
} }
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
} }

View File

@ -1,28 +1,29 @@
import SwiftUI import SwiftUI
struct NotificationListView: View { struct NotificationListView: View {
@State private var notifications: [ZXNotificationRowData] = [ @State private var notifications: [NotificationItem] = []
.init(type: "review", title: "复习提醒", content: "你有 8 个知识点需要复习", time: "刚刚", read: false), @State private var isLoading = false
.init(type: "ai", title: "AI 分析完成", content: "\"机器学习基础\"薄弱点分析已完成", time: "1小时前", read: false), @State private var isRefreshing = false
.init(type: "streak", title: "学习成就", content: "恭喜!你已连续学习 14 天 🔥", time: "昨天", read: true),
.init(type: "review", title: "复习提醒", content: "今天有 3 个知识点需要费曼解释练习", time: "2天前", read: true),
.init(type: "system", title: "系统通知", content: "v1.0 版本已更新,新增间隔复习功能", time: "3天前", read: true),
]
var body: some View { var body: some View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
if notifications.isEmpty { if isLoading && notifications.isEmpty {
VStack(spacing: 12) {
ZXLoadingView(size: 36, lineWidth: 3)
Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04)
}.padding(.top, 120)
} else if notifications.isEmpty {
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03) Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03)
Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03) Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03)
}.padding(.top, 120) }.padding(.top, 120)
} else { } else {
ForEach(Array(notifications.enumerated()), id: \.offset) { i, n in ForEach(Array(notifications.enumerated()), id: \.offset) { i, n in
ZXNotificationRow(item: n) { ZXNotificationItemRow(item: n) {
notifications[i].read = true Task { _ = try? await NotificationService.shared.markRead(id: n.id) }
} }
if i < notifications.count - 1 { if i < notifications.count - 1 {
ZXSettingDivider() ZXSettingDivider()
@ -33,28 +34,40 @@ struct NotificationListView: View {
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)) .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar) .scrollIndicators(.hidden)
.zxPullToRefresh { await refresh() }
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.task { await loadNotifications() }
}
private func loadNotifications() async {
isLoading = true
do {
notifications = try await NotificationService.shared.list()
} catch { /* keep empty state */ }
isLoading = false
}
private func refresh() async {
isRefreshing = true
do {
notifications = try await NotificationService.shared.list()
} catch {}
isRefreshing = false
} }
} }
struct ZXNotificationRowData: Identifiable { struct ZXNotificationItemRow: View {
let id = UUID() let item: NotificationItem
let type: String
let title: String
let content: String
let time: String
var read: Bool
}
struct ZXNotificationRow: View {
let item: ZXNotificationRowData
let onTap: () -> Void let onTap: () -> Void
private var iconName: String { private var iconName: String {
switch item.type { switch item.type {
case "review": return "arrow.triangle.2.circlepath" case "review": return "arrow.triangle.2.circlepath"
case "ai": return "sparkles" case "ai_analysis": return "sparkles"
case "streak": return "flame.fill" case "streak": return "flame.fill"
default: return "bell.fill" default: return "bell.fill"
} }
@ -63,7 +76,7 @@ struct ZXNotificationRow: View {
private var iconColor: Color { private var iconColor: Color {
switch item.type { switch item.type {
case "review": return Color.zxOrange case "review": return Color.zxOrange
case "ai": return Color.zxPurple case "ai_analysis": return Color.zxPurple
case "streak": return Color.zxGreen case "streak": return Color.zxGreen
default: return Color.zxAccent default: return Color.zxAccent
} }
@ -77,12 +90,14 @@ struct ZXNotificationRow: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0) Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
if !item.read { if item.readAt == nil {
Circle().fill(Color.zxPurple).frame(width: 6, height: 6) Circle().fill(Color.zxPurple).frame(width: 6, height: 6)
} }
} }
Text(item.content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2) Text(item.content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2)
Text(item.time).font(.system(size: 10)).foregroundColor(Color.zxF03) if let createdAt = item.createdAt {
Text(createdAt.prefix(10).description).font(.system(size: 10)).foregroundColor(Color.zxF03)
}
} }
Spacer() Spacer()
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)

View File

@ -4,7 +4,6 @@ struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel() @StateObject private var viewModel = ProfileViewModel()
var body: some View { var body: some View {
let _ = Task { if viewModel.userProfile == nil { await viewModel.loadAll() } }
ZStack { ZStack {
ZXGradient.page.ignoresSafeArea() ZXGradient.page.ignoresSafeArea()
ScrollView { ScrollView {
@ -18,12 +17,14 @@ struct ProfileView: View {
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
} }
.accessibilityLabel("通知中心")
NavigationLink(destination: SettingsView()) { NavigationLink(destination: SettingsView()) {
Image(systemName: "gearshape").font(.system(size: 18)).foregroundColor(Color.zxF05) Image(systemName: "gearshape").font(.system(size: 18)).foregroundColor(Color.zxF05)
.frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05)) .frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05))
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
} }
.accessibilityLabel("设置")
}.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4) }.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
profileCard profileCard
VStack(spacing: 0) { VStack(spacing: 0) {
@ -47,6 +48,7 @@ struct ProfileView: View {
}.padding(.horizontal, 20) }.padding(.horizontal, 20)
}.scrollIndicators(.hidden) }.scrollIndicators(.hidden)
} }
.task { await viewModel.loadAll() }
} }
private var profileCard: some View { private var profileCard: some View {
let profile = viewModel.userProfile let profile = viewModel.userProfile
@ -63,6 +65,8 @@ struct ProfileView: View {
HStack(spacing: 0) { ZXProfileStat(value: "\(viewModel.summary?.activeDays ?? 0)", label: "活跃天", color: Color.zxOrange); ZXProfileStat(value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", label: "复习卡片", color: Color.zxPurple); ZXProfileStat(value: "\(viewModel.summary?.totalMinutes ?? 0)", label: "分钟", color: Color.zxTeal) } HStack(spacing: 0) { ZXProfileStat(value: "\(viewModel.summary?.activeDays ?? 0)", label: "活跃天", color: Color.zxOrange); ZXProfileStat(value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", label: "复习卡片", color: Color.zxPurple); ZXProfileStat(value: "\(viewModel.summary?.totalMinutes ?? 0)", label: "分钟", color: Color.zxTeal) }
}.padding(20).background(ZXGradient.profileCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.padding(20).background(ZXGradient.profileCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
}.foregroundColor(.primary) }.foregroundColor(.primary)
.accessibilityLabel("编辑个人资料,\(profile?.nickname ?? "学习者")")
.accessibilityHint("双击查看和编辑个人资料")
} }
private var achievementsSection: some View { private var achievementsSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@ -75,7 +79,7 @@ struct ZXProfileStat: View { let v: String; let l: String; let c: Color; var bod
init(value: String, label: String, color: Color) { self.v = value; self.l = label; self.c = color } init(value: String, label: String, color: Color) { self.v = value; self.l = label; self.c = color }
} }
struct ZXProfileMenuRow: View { let emoji: String; let title: String; let desc: String struct ZXProfileMenuRow: View { let emoji: String; let title: String; let desc: String
var body: some View { HStack(spacing: 12) { Text(emoji).font(.system(size: 20)).frame(width: 36, height: 36).background(Color.zxFill006).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(.horizontal, 16).padding(.vertical, 14) } var body: some View { HStack(spacing: 12) { Text(emoji).font(.system(size: 20)).frame(width: 36, height: 36).background(Color.zxFill006).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(.horizontal, 16).padding(.vertical, 14).accessibilityLabel("\(title)\(desc)") }
} }
struct ZXProfileDivider: View { struct ZXProfileDivider: View {
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 64) } var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 64) }

View File

@ -13,23 +13,20 @@ class ProfileViewModel: ObservableObject {
func loadAll() async { func loadAll() async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
async let _ = loadProfile() await loadProfile()
async let _ = loadActivitySummary()
isLoading = false isLoading = false
Task { await loadActivitySummary() }
} }
func loadProfile() async { func loadProfile() async {
isLoading = true
errorMessage = nil
do { do {
let profile = try await UserService.shared.myProfile() let profile = try await UserService.shared.myProfile()
userProfile = profile userProfile = profile
preferences = profile.preferences preferences = profile.preferences
profileData = profile.profile profileData = profile.profile
} catch { } catch {
errorMessage = "加载用户信息失败" if userProfile == nil { errorMessage = "加载用户信息失败" }
} }
isLoading = false
} }
func updatePreferences(_ dto: UpdatePreferencesRequest) async { func updatePreferences(_ dto: UpdatePreferencesRequest) async {

View File

@ -18,13 +18,6 @@ struct SettingsView: View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
ScrollView { ScrollView {
let _ = Task { await profileVM.loadProfile(); if let p = profileVM.preferences {
appearance = p.appearance ?? "system"
language = p.language ?? "zh-CN"
defaultFocusMinutes = p.defaultFocusMinutes ?? 25
notificationEnabled = p.notificationEnabled ?? true
reviewReminder = notificationEnabled
} }
VStack(spacing: 16) { VStack(spacing: 16) {
sectionHeader("外观与语言") sectionHeader("外观与语言")
VStack(spacing: 0) { VStack(spacing: 0) {
@ -113,6 +106,15 @@ struct SettingsView: View {
} }
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
} }
.task { await profileVM.loadProfile() }
.onChange(of: profileVM.preferences) { _, p in
guard let p else { return }
appearance = p.appearance ?? "system"
language = p.language ?? "zh-CN"
defaultFocusMinutes = p.defaultFocusMinutes ?? 25
notificationEnabled = p.notificationEnabled ?? true
reviewReminder = notificationEnabled
}
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar) .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
} }

View File

@ -10,6 +10,8 @@ struct LearningSessionView: View {
@State private var isRunning = true @State private var isRunning = true
@State private var isPaused = false @State private var isPaused = false
@State private var showEndConfirm = false @State private var showEndConfirm = false
@State private var showCelebration = false
@State private var sessionEnded = false
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
@ -38,9 +40,16 @@ struct LearningSessionView: View {
if isRunning { elapsed += 1 } if isRunning { elapsed += 1 }
} }
.confirmationDialog("结束学习?", isPresented: $showEndConfirm, titleVisibility: .visible) { .confirmationDialog("结束学习?", isPresented: $showEndConfirm, titleVisibility: .visible) {
Button("结束并保存", role: .destructive) { isRunning = false } Button("结束并保存", role: .destructive) { isRunning = false; sessionEnded = true; showCelebration = true }
Button("继续学习", role: .cancel) {} Button("继续学习", role: .cancel) {}
} }
.overlay {
if showCelebration {
ZXCelebrationView(title: "学习完成", subtitle: "你已专注学习了 \(formatTime(elapsed)),继续保持!") {
withAnimation(.easeOut(duration: 0.3)) { showCelebration = false }
}
}
}
} }
private var timerCard: some View { private var timerCard: some View {
@ -73,6 +82,8 @@ struct LearningSessionView: View {
.background(ZXGradient.brandPurple) .background(ZXGradient.brandPurple)
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
} }
.zxPressable()
.accessibilityLabel(isRunning ? "暂停学习" : "继续学习")
Button { showEndConfirm = true } label: { Button { showEndConfirm = true } label: {
Label("结束", systemImage: "stop.fill") Label("结束", systemImage: "stop.fill")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
@ -82,6 +93,9 @@ struct LearningSessionView: View {
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
} }
.zxPressable()
.accessibilityLabel("结束学习")
.accessibilityHint("停止计时并保存本次学习记录")
} }
} }
.padding(24) .padding(24)

View File

@ -18,6 +18,7 @@ struct ReviewCardView: View {
@State private var flipped = false @State private var flipped = false
@State private var rating: Int? = nil @State private var rating: Int? = nil
@State private var finish = false @State private var finish = false
@State private var showCelebration = false
var current: ReviewCardItem { cards[idx] } var current: ReviewCardItem { cards[idx] }
@ -41,6 +42,13 @@ struct ReviewCardView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
.task { await viewModel.loadDueCards() } .task { await viewModel.loadDueCards() }
.overlay {
if showCelebration {
ZXCelebrationView(title: "复习完成", subtitle: "你已完成本次间隔复习,继续保持!") {
withAnimation(.easeOut(duration: 0.3)) { showCelebration = false }
}
}
}
} }
private var progressBar: some View { private var progressBar: some View {
@ -105,6 +113,11 @@ struct ReviewCardView: View {
.overlay(RoundedRectangle(cornerRadius: 20).stroke((flipped ? Color.zxPurple : Color.zxAccent).opacity(0.15), lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 20).stroke((flipped ? Color.zxPurple : Color.zxAccent).opacity(0.15), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 20)) .clipShape(RoundedRectangle(cornerRadius: 20))
.onTapGesture { withAnimation(.easeInOut(duration: 0.4)) { flipped.toggle() } } .onTapGesture { withAnimation(.easeInOut(duration: 0.4)) { flipped.toggle() } }
.zxPressable()
.accessibilityElement(children: .combine)
.accessibilityLabel(flipped ? "答案:\(current.answer)" : "问题:\(current.question)")
.accessibilityHint(flipped ? "来源:\(current.source)" : "双击翻转查看答案")
.accessibilityAddTraits(.isButton)
} }
private var ratingBar: some View { private var ratingBar: some View {
@ -130,6 +143,7 @@ struct ReviewCardView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { idx += 1 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { idx += 1 }
} else { } else {
finish = true finish = true
showCelebration = true
} }
} }
} }
@ -157,5 +171,8 @@ struct ZXRatingBtn: View {
if !selected { RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1) } if !selected { RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1) }
} }
} }
.zxPressable()
.accessibilityLabel("\(label)")
.accessibilityHint("将此卡片标记为\(label)")
} }
} }

View File

@ -0,0 +1,59 @@
import Combine
import Foundation
struct ReviewTask: Identifiable, Equatable {
let id: String
let userId: String
let lessonId: String
let sourceSessionId: String
let reviewType: ReviewType
let scheduledAt: String
let completedAt: String?
var status: ReviewTaskStatus
}
enum ReviewType: String {
case recall, feynman, review
}
enum ReviewTaskStatus: String {
case pending, completed
}
@MainActor
final class ReviewPlanViewModel: ObservableObject {
@Published var todayTasks: [ReviewTask] = [
ReviewTask(id: "t1", userId: "u1", lessonId: "l1", sourceSessionId: "s1",
reviewType: .recall, scheduledAt: "", completedAt: nil, status: .pending),
ReviewTask(id: "t2", userId: "u1", lessonId: "l2", sourceSessionId: "s2",
reviewType: .feynman, scheduledAt: "", completedAt: nil, status: .completed),
ReviewTask(id: "t3", userId: "u1", lessonId: "l3", sourceSessionId: "s3",
reviewType: .review, scheduledAt: "", completedAt: nil, status: .pending),
]
@Published var tomorrowTasks: [ReviewTask] = [
ReviewTask(id: "t4", userId: "u1", lessonId: "l4", sourceSessionId: "s4",
reviewType: .recall, scheduledAt: "", completedAt: nil, status: .pending),
ReviewTask(id: "t5", userId: "u1", lessonId: "l5", sourceSessionId: "s5",
reviewType: .feynman, scheduledAt: "", completedAt: nil, status: .pending),
]
@Published var weekTasks: [ReviewTask] = [
ReviewTask(id: "t6", userId: "u1", lessonId: "l6", sourceSessionId: "s6",
reviewType: .review, scheduledAt: "", completedAt: nil, status: .pending),
ReviewTask(id: "t7", userId: "u1", lessonId: "l7", sourceSessionId: "s7",
reviewType: .recall, scheduledAt: "", completedAt: nil, status: .completed),
]
var totalCount: Int { todayTasks.count + tomorrowTasks.count + weekTasks.count }
func toggleTask(_ task: ReviewTask) {
for list in [\ReviewPlanViewModel.todayTasks, \.tomorrowTasks, \.weekTasks] {
if let idx = self[keyPath: list].firstIndex(where: { $0.id == task.id }) {
let current = self[keyPath: list][idx].status
self[keyPath: list][idx].status = current == .completed ? .pending : .completed
return
}
}
}
}

View File

@ -2,26 +2,19 @@ import SwiftUI
struct StudyHomeView: View { struct StudyHomeView: View {
@StateObject private var studyVM = StudyViewModel() @StateObject private var studyVM = StudyViewModel()
@StateObject private var studyHomeVM = StudyHomeViewModel()
@StateObject private var reviewVM = ReviewViewModel() @StateObject private var reviewVM = ReviewViewModel()
@State private var ts: [ZXSTask] = [
.init(t: "机器学习 - 回忆测试", tp: "回忆测试", c: Color.zxPurple, m: 10, d: true),
.init(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: Color.zxOrange, m: 15, d: true),
.init(t: "英语词汇 - 25 个待复习", tp: "词汇复习", c: Color.zxTeal, m: 8, d: false),
.init(t: "注意力机制 - 费曼解释", tp: "费曼练习", c: Color.zxAccent, m: 12, d: false),
.init(t: "产品设计 - 薄弱点复习", tp: "薄弱点", c: Color.zxYellow, m: 10, d: false),
]
private let wb: [CGFloat] = [0.3, 0.7, 1.0, 0.4, 0.9, 0.6, 0.2]
private let dl = ["","","","","","",""]
var body: some View { var body: some View {
ZStack { ZXGradient.page.ignoresSafeArea() ZStack { ZXGradient.page.ignoresSafeArea()
ScrollView { VStack(spacing: 16) { ScrollView { VStack(spacing: 16) {
HStack { VStack(alignment: .leading, spacing: 2) { Text("周四1月16日").font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxF04); Text("学习工作台").font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.4) }; Spacer() HStack { VStack(alignment: .leading, spacing: 2) { Text("学习工作台").font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.4) }; 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) }.padding(.horizontal, 12).padding(.vertical, 6).background(Color.zxOrangeBG(0.1)).clipShape(Capsule()).overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1)) } 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) }.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, ZXSpacing.statusBarH + 16).padding(.bottom, 4) .padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
pc pc
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) } } 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($ts) { $t in ForEach($studyHomeVM.tasks) { $t in
if t.tp == "回忆测试" { if t.tp == "回忆测试" {
NavigationLink(destination: ActiveRecallView()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary) NavigationLink(destination: ActiveRecallView()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
} else if t.tp == "费曼练习" { } else if t.tp == "费曼练习" {
@ -36,14 +29,16 @@ struct StudyHomeView: View {
} }
} }
VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
HStack(alignment: .bottom, spacing: 8) { ForEach(0..<7, id: \.self) { i in VStack(spacing: 8) { RoundedRectangle(cornerRadius: 6).fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: wb[i] * 0.9 + 0.1)).frame(height: wb[i] * 60); Text(dl[i]).font(.system(size: 10, weight: i == 2 ? .bold : .regular)).foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03) }.frame(maxWidth: .infinity) } } HStack(alignment: .bottom, spacing: 8) { ForEach(0..<7, id: \.self) { i in VStack(spacing: 8) { RoundedRectangle(cornerRadius: 6).fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: studyHomeVM.weekActivity[i] * 0.9 + 0.1)).frame(height: studyHomeVM.weekActivity[i] * 60); Text(studyHomeVM.dayLabels[i]).font(.system(size: 10, weight: i == 2 ? .bold : .regular)).foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03) }.frame(maxWidth: .infinity) } }
HStack { Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03) } } HStack { Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03) } }
.padding(.bottom, 120) } .padding(.bottom, 120) }
.padding(.horizontal, 20) } .padding(.horizontal, 20) }
.scrollIndicators(.hidden) } .scrollIndicators(.hidden)
.zxPullToRefresh { await studyVM.loadSessions() }
}
.task { await studyVM.loadSessions() } .task { await studyVM.loadSessions() }
} }
private var pc: some View { let dn = ts.filter(\.d).count; let pct = CGFloat(dn) / 5 private var pc: 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); Text("/ 5"); Text("个任务").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) } }; Spacer() 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); Text("/ 5"); Text("个任务").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) } }; 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); Text("\(Int(pct * 100))%").font(.system(size: 14, weight: .heavy)).foregroundColor(Color.zxPurple) } } ZStack { Circle().trim(from: 0, to: pct).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 64, height: 64); Text("\(Int(pct * 100))%").font(.system(size: 14, weight: .heavy)).foregroundColor(Color.zxPurple) } }
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) } 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) }
@ -59,5 +54,8 @@ 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) 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)) } } 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)) } } 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() } } .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 ? "已完成" : "双击开始学习") }
} }

View File

@ -0,0 +1,26 @@
import Combine
import Foundation
@MainActor
final class StudyHomeViewModel: ObservableObject {
@Published var tasks: [ZXSTask] = [
ZXSTask(t: "机器学习 - 回忆测试", tp: "回忆测试", c: .zxPurple, m: 10, d: true),
ZXSTask(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: .zxOrange, m: 15, d: true),
ZXSTask(t: "英语词汇 - 25 个待复习", tp: "词汇复习", c: .zxTeal, m: 8, d: false),
ZXSTask(t: "注意力机制 - 费曼解释", tp: "费曼练习", c: .zxAccent, m: 12, d: false),
ZXSTask(t: "产品设计 - 薄弱点复习", tp: "薄弱点", c: .zxYellow, m: 10, d: false),
]
@Published var weekActivity: [CGFloat] = [0.3, 0.7, 1.0, 0.4, 0.9, 0.6, 0.2]
let dayLabels = ["", "", "", "", "", "", ""]
var doneCount: Int { tasks.filter(\.d).count }
var progress: Double { tasks.isEmpty ? 0 : Double(doneCount) / Double(tasks.count) }
var doneMinutes: Int { tasks.filter(\.d).map(\.m).reduce(0, +) }
var remainingMinutes: Int { tasks.filter { !$0.d }.map(\.m).reduce(0, +) }
func toggleTask(_ task: ZXSTask) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].d.toggle()
}
}