diff --git a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift index 7c7f577..bba1b3b 100644 --- a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift +++ b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift @@ -34,6 +34,7 @@ struct AIStudyAppApp: App { } } .preferredColorScheme(effectiveColorScheme) + .zxToast() .task { await authManager.restoreSession() } diff --git a/AIStudyApp/AIStudyApp/ContentView.swift b/AIStudyApp/AIStudyApp/ContentView.swift index c3b6c6d..202b77e 100644 --- a/AIStudyApp/AIStudyApp/ContentView.swift +++ b/AIStudyApp/AIStudyApp/ContentView.swift @@ -13,14 +13,16 @@ struct ContentView: View { default: NavigationStack { AIHomeView() } } VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom) - }.ignoresSafeArea(edges: .bottom) + } + .animation(.easeInOut(duration: 0.2), value: selectedTab) + .ignoresSafeArea(edges: .bottom) } } struct ZXTabBar: View { @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")] - 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 { @@ -56,5 +58,5 @@ struct ZXWeakRow: View { struct ZXAIInputBar: View { @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)} } diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift new file mode 100644 index 0000000..be40087 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift @@ -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.. Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift new file mode 100644 index 0000000..455a3a2 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift @@ -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 + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXRefreshableScrollView.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXRefreshableScrollView.swift new file mode 100644 index 0000000..a5da092 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXRefreshableScrollView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +// MARK: - Refreshable ScrollView with load-more + +struct ZXRefreshableScrollView: 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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift new file mode 100644 index 0000000..1e7ab65 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift @@ -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? + + 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) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index b45dcef..40fcb5d 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -124,7 +124,7 @@ struct UserProfileData: Codable { let currentGoal: String? } -struct UserPreferences: Codable { +struct UserPreferences: Codable, Equatable { let preferredMethods: [String]? let defaultFocusMinutes: Int? let aiSuggestionLevel: String? diff --git a/AIStudyApp/AIStudyApp/Core/Security/FileCache.swift b/AIStudyApp/AIStudyApp/Core/Security/FileCache.swift new file mode 100644 index 0000000..e44aaaa --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Security/FileCache.swift @@ -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(_ value: T, forKey key: String) throws { + let data = try encoder.encode(value) + try data.write(to: url(forKey: key), options: .atomic) + } + + func load(_ 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) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Security/LocalCache.swift b/AIStudyApp/AIStudyApp/Core/Security/LocalCache.swift new file mode 100644 index 0000000..674f05e --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Security/LocalCache.swift @@ -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(_ 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(_ 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(_ key: String) -> [T] { + (try? fileCache.load([T].self, forKey: key)) ?? [] + } + + func setList(_ items: [T], forKey key: String) { + try? fileCache.save(items, forKey: key) + } + + // MARK: - Expiry-based caching + + func getWithExpiry(_ 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(_ 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( + 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( + key: String, + ttl: TimeInterval, + fetch: @Sendable () async throws -> T + ) async throws { + let fresh = try await fetch() + setWithExpiry(fresh, forKey: key, ttl: ttl) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift new file mode 100644 index 0000000..d2f41dd --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift @@ -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 + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift index d1aa7ed..8d255fe 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift @@ -96,6 +96,8 @@ struct AIHomeView: View { .frame(maxWidth:.infinity).frame(height:42) .background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12)) } + .accessibilityLabel("开始回答今日思考题") + .accessibilityHint("用费曼方法解释注意力机制") } .padding(16).background(ZXGradient.thinkingCard) .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) .frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9)) } + .accessibilityLabel("发送消息,开始 AI 对话") } .padding(.horizontal,14).padding(.vertical,10) .background(.ultraThinMaterial).background(Color.zxFill004) @@ -187,6 +190,7 @@ struct ZXQuickAction: View { } .frame(width:72,height:72) .background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16)) + .accessibilityLabel(label.replacingOccurrences(of: "\n", with: "")) } } diff --git a/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift index 7fcebb9..42151c0 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift @@ -13,6 +13,8 @@ struct ActiveRecallView: View { @State private var currentAnswer = "" @State private var submitted: Set = [] @State private var showFinish = false + @State private var showThinking = false + @State private var showCelebration = false var current: RecallQuestion { questions[idx] } @@ -40,6 +42,18 @@ struct ActiveRecallView: View { .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) .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) } @@ -91,6 +105,9 @@ struct ActiveRecallView: View { .background(ZXGradient.thinkingCard) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: 16)) + .accessibilityElement(children: .combine) + .accessibilityLabel("问题 \(idx + 1):\(current.question)") + .accessibilityHint(current.isVoice ? "语音题,双击录音回答" : "文字题,在下方输入回答") } private var answerInput: some View { @@ -116,6 +133,13 @@ struct ActiveRecallView: View { answers[current.id] = current.isVoice ? "语音答案已录制" : currentAnswer submitted.insert(current.id) currentAnswer = "" + if submitted.count == questions.count { + showThinking = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + showThinking = false + showCelebration = true + } + } } label: { Text("提交回答") .font(.system(size: 14, weight: .bold)) @@ -124,8 +148,11 @@ struct ActiveRecallView: View { .background(ZXGradient.ctaPurple) .clipShape(RoundedRectangle(cornerRadius: 16)) } + .zxPressable() .disabled(currentAnswer.isEmpty && !current.isVoice) .opacity(currentAnswer.isEmpty && !current.isVoice ? 0.5 : 1) + .accessibilityLabel("提交回答") + .accessibilityHint("提交后可由 AI 分析你的回答质量") } } @@ -168,8 +195,11 @@ struct ActiveRecallView: View { .background(ZXGradient.brand) .clipShape(RoundedRectangle(cornerRadius: 16)) } + .zxPressable() } else { - NavigationLink(destination: AIFeedbackPageView()) { + Button { + showCelebration = true + } label: { Label("查看 AI 分析结果", systemImage: "sparkles") .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) @@ -177,6 +207,7 @@ struct ActiveRecallView: View { .background(ZXGradient.ctaPurple) .clipShape(RoundedRectangle(cornerRadius: 16)) } + .zxPressable() } } } diff --git a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift index 8976eb2..f369a1a 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift @@ -10,7 +10,7 @@ struct DailyThinkingPage: View { Text("AI会从三个方面评估你的回答:核心概念理解 · 理论深度 · 实际应用能力").font(.system(size:12)).foregroundColor(Color.zxF04) }.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))} - 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) }.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)} } struct AIFeedbackPageView: View { @State private var navigateToChat = false + @State private var isAnalyzing = true + 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)) - 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)) + ZStack { + Color.zxBg0.ignoresSafeArea() + if isAnalyzing { + ZXAIAnalysisProgress(steps: [ + "解析你的回答结构…", + "对比知识库标准答案…", + "评估概念理解深度…", + "生成个性化反馈…" + ]) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + 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)} } diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift index e0b48e6..97c1101 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift @@ -9,40 +9,32 @@ class ActivityViewModel: ObservableObject { @Published var isLoading = false @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 { isLoading = true errorMessage = nil - async let summaryTask: () = loadSummary() - async let focusTask: () = loadFocusItems() - async let heatmapTask: () = loadHeatmap() - _ = await (summaryTask, focusTask, heatmapTask) + 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 { + if summary == nil { errorMessage = "加载分析数据失败" } + } 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 {} + } } diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift index 24cab44..bc37fcf 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift @@ -15,6 +15,10 @@ struct AnalysisHomeView: View { .padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12) ScrollView { 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) { 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) @@ -26,7 +30,7 @@ struct AnalysisHomeView: View { ZXChartView() }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) 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 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) - }.scrollIndicators(.hidden) + } + .scrollIndicators(.hidden) + .zxPullToRefresh { await viewModel.refresh() } } } .task { await viewModel.loadAll() } diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift index edff226..da19a4e 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift @@ -17,16 +17,23 @@ struct LibraryHomeView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) } + .accessibilityLabel("搜索知识库") NavigationLink(destination: ImportPage()) { Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white) .frame(width: 36, height: 36).background(ZXGradient.brand) .clipShape(RoundedRectangle(cornerRadius: 10)) } + .accessibilityLabel("导入新知识库") } .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) + .accessibilityHint("输入关键词搜索知识库或知识点") 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 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)) @@ -35,13 +42,20 @@ struct LibraryHomeView: View { if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading { Text("还没有知识库,点击右上角 + 创建").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40) } + if viewModel.hasMore { + ZXLoadMoreFooter { await viewModel.loadMore() } + } NavigationLink(destination: CreateLibraryPage()) { 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) .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]), antialiased: true).foregroundColor(Color.zxBorder01)) .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() } diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index f2566cd..ffacb36 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -26,6 +26,10 @@ struct LibraryDetailPage: View { } }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8) 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 NavigationLink(destination: KnowledgeDetailPage(item: item)) { 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 { 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) .task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) } } diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift index b8dee1e..d884b0d 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift @@ -5,26 +5,59 @@ import Foundation class LibraryViewModel: ObservableObject { @Published var knowledgeBases: [KnowledgeBase] = [] @Published var isLoading = false + @Published var isRefreshing = false + @Published var isLoadingMore = false @Published var errorMessage: String? + @Published var hasMore = true + + private var currentPage = 1 + private let pageSize = 20 func loadKnowledgeBases() async { isLoading = true errorMessage = nil + currentPage = 1 do { - knowledgeBases = try await KnowledgeBaseService.shared.list() + knowledgeBases = try await KnowledgeBaseService.shared.list(page: 1, limit: pageSize) + hasMore = knowledgeBases.count >= pageSize } catch { - errorMessage = "加载知识库失败" + if knowledgeBases.isEmpty { errorMessage = "加载知识库失败" } } 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? { do { let kb = try await KnowledgeBaseService.shared.create(title: title, description: description) knowledgeBases.insert(kb, at: 0) + ZXToastManager.shared.success("知识库已创建") return kb } catch { - errorMessage = "创建知识库失败" + ZXToastManager.shared.error("创建失败") return nil } } @@ -33,8 +66,9 @@ class LibraryViewModel: ObservableObject { do { _ = try await KnowledgeBaseService.shared.delete(id: id) knowledgeBases.removeAll { $0.id == id } + ZXToastManager.shared.success("已删除") } catch { - errorMessage = "删除知识库失败" + ZXToastManager.shared.error("删除失败") } } } @@ -44,25 +78,55 @@ class LibraryDetailViewModel: ObservableObject { @Published var items: [KnowledgeItem] = [] @Published var knowledgeBase: KnowledgeBase? @Published var isLoading = false + @Published var isRefreshing = false + @Published var isLoadingMore = false @Published var errorMessage: String? + @Published var hasMore = true + + private var currentPage = 1 + private let pageSize = 20 func loadItems(knowledgeBaseId: String) async { isLoading = true errorMessage = nil + currentPage = 1 do { items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId) + hasMore = items.count >= pageSize } catch { - errorMessage = "加载知识点失败" + if items.isEmpty { errorMessage = "加载知识点失败" } } 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 { do { knowledgeBase = try await KnowledgeBaseService.shared.detail(id: id) - } catch { - errorMessage = "加载知识库详情失败" - } + } catch {} } func addItem(knowledgeBaseId: String, title: String, content: String?) async -> KnowledgeItem? { @@ -71,9 +135,10 @@ class LibraryDetailViewModel: ObservableObject { knowledgeBaseId: knowledgeBaseId, title: title, content: content ) items.append(item) + ZXToastManager.shared.success("知识点已添加") return item } catch { - errorMessage = "添加知识点失败" + ZXToastManager.shared.error("添加失败") return nil } } diff --git a/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift index 61bae88..97b4a97 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift @@ -8,6 +8,8 @@ struct EditProfilePage: View { @State private var bio: String = "" @State private var currentGoal: String = "" @State private var saved = false + @State private var isSaving = false + @State private var saveError: String? var body: some View { ZStack { @@ -49,28 +51,48 @@ struct EditProfilePage: View { .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)) .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 { Task { - _ = try? await UserService.shared.updateProfile(UpdateProfileRequest( - nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil - )) - _ = try? await UserService.shared.updateProfileDetail(UpdateProfileDataRequest( - learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity, - learningDirection: learningDirection.isEmpty ? nil : learningDirection, - bio: bio.isEmpty ? nil : bio, - currentGoal: currentGoal.isEmpty ? nil : currentGoal - )) - saved = true + isSaving = true + saveError = nil + do { + _ = try await UserService.shared.updateProfile(UpdateProfileRequest( + nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil + )) + _ = try await UserService.shared.updateProfileDetail(UpdateProfileDataRequest( + learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity, + learningDirection: learningDirection.isEmpty ? nil : learningDirection, + bio: bio.isEmpty ? nil : bio, + currentGoal: currentGoal.isEmpty ? nil : currentGoal + )) + saved = true + } catch { + saveError = "保存失败: \(error.localizedDescription)" + } + isSaving = false } } label: { - Text(saved ? "已保存" : "保存修改") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .frame(height: 52) - .background(ZXGradient.ctaPurple) - .clipShape(RoundedRectangle(cornerRadius: 16)) + HStack(spacing: 8) { + if isSaving { ProgressView().tint(.white) } + Text(saved ? "已保存" : "保存修改") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + } + .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) } diff --git a/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift b/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift index dd55ee8..5d3de80 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift @@ -1,28 +1,29 @@ import SwiftUI struct NotificationListView: View { - @State private var notifications: [ZXNotificationRowData] = [ - .init(type: "review", title: "复习提醒", content: "你有 8 个知识点需要复习", time: "刚刚", read: false), - .init(type: "ai", title: "AI 分析完成", content: "\"机器学习基础\"薄弱点分析已完成", time: "1小时前", read: 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), - ] + @State private var notifications: [NotificationItem] = [] + @State private var isLoading = false + @State private var isRefreshing = false var body: some View { ZStack { Color.zxBg0.ignoresSafeArea() ScrollView { 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) { Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03) Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03) }.padding(.top, 120) } else { ForEach(Array(notifications.enumerated()), id: \.offset) { i, n in - ZXNotificationRow(item: n) { - notifications[i].read = true + ZXNotificationItemRow(item: n) { + Task { _ = try? await NotificationService.shared.markRead(id: n.id) } } if i < notifications.count - 1 { ZXSettingDivider() @@ -33,28 +34,40 @@ struct NotificationListView: View { .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)) .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) .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 { - let id = UUID() - let type: String - let title: String - let content: String - let time: String - var read: Bool -} - -struct ZXNotificationRow: View { - let item: ZXNotificationRowData +struct ZXNotificationItemRow: View { + let item: NotificationItem let onTap: () -> Void private var iconName: String { switch item.type { case "review": return "arrow.triangle.2.circlepath" - case "ai": return "sparkles" + case "ai_analysis": return "sparkles" case "streak": return "flame.fill" default: return "bell.fill" } @@ -63,7 +76,7 @@ struct ZXNotificationRow: View { private var iconColor: Color { switch item.type { case "review": return Color.zxOrange - case "ai": return Color.zxPurple + case "ai_analysis": return Color.zxPurple case "streak": return Color.zxGreen default: return Color.zxAccent } @@ -77,12 +90,14 @@ struct ZXNotificationRow: View { VStack(alignment: .leading, spacing: 4) { HStack { 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) } } 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() Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift index 1c06369..2d5b334 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift @@ -4,7 +4,6 @@ struct ProfileView: View { @StateObject private var viewModel = ProfileViewModel() var body: some View { - let _ = Task { if viewModel.userProfile == nil { await viewModel.loadAll() } } ZStack { ZXGradient.page.ignoresSafeArea() ScrollView { @@ -18,12 +17,14 @@ struct ProfileView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) } + .accessibilityLabel("通知中心") NavigationLink(destination: SettingsView()) { Image(systemName: "gearshape").font(.system(size: 18)).foregroundColor(Color.zxF05) .frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) } + .accessibilityLabel("设置") }.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4) profileCard VStack(spacing: 0) { @@ -47,6 +48,7 @@ struct ProfileView: View { }.padding(.horizontal, 20) }.scrollIndicators(.hidden) } + .task { await viewModel.loadAll() } } private var profileCard: some View { 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) } }.padding(20).background(ZXGradient.profileCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.foregroundColor(.primary) + .accessibilityLabel("编辑个人资料,\(profile?.nickname ?? "学习者")") + .accessibilityHint("双击查看和编辑个人资料") } private var achievementsSection: some View { 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 } } 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 { var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 64) } diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileViewModel.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileViewModel.swift index ae54c34..9bbf478 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/ProfileViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileViewModel.swift @@ -13,23 +13,20 @@ class ProfileViewModel: ObservableObject { func loadAll() async { isLoading = true errorMessage = nil - async let _ = loadProfile() - async let _ = loadActivitySummary() + await loadProfile() isLoading = false + Task { await loadActivitySummary() } } func loadProfile() async { - isLoading = true - errorMessage = nil do { let profile = try await UserService.shared.myProfile() userProfile = profile preferences = profile.preferences profileData = profile.profile } catch { - errorMessage = "加载用户信息失败" + if userProfile == nil { errorMessage = "加载用户信息失败" } } - isLoading = false } func updatePreferences(_ dto: UpdatePreferencesRequest) async { diff --git a/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift b/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift index 9ea4c4c..15ed115 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift @@ -18,13 +18,6 @@ struct SettingsView: View { ZStack { Color.zxBg0.ignoresSafeArea() 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) { sectionHeader("外观与语言") VStack(spacing: 0) { @@ -113,6 +106,15 @@ struct SettingsView: View { } .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) } diff --git a/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift b/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift index 2ead523..8f83811 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift @@ -10,6 +10,8 @@ struct LearningSessionView: View { @State private var isRunning = true @State private var isPaused = 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() var body: some View { @@ -38,9 +40,16 @@ struct LearningSessionView: View { if isRunning { elapsed += 1 } } .confirmationDialog("结束学习?", isPresented: $showEndConfirm, titleVisibility: .visible) { - Button("结束并保存", role: .destructive) { isRunning = false } + Button("结束并保存", role: .destructive) { isRunning = false; sessionEnded = true; showCelebration = true } Button("继续学习", role: .cancel) {} } + .overlay { + if showCelebration { + ZXCelebrationView(title: "学习完成", subtitle: "你已专注学习了 \(formatTime(elapsed)),继续保持!") { + withAnimation(.easeOut(duration: 0.3)) { showCelebration = false } + } + } + } } private var timerCard: some View { @@ -73,6 +82,8 @@ struct LearningSessionView: View { .background(ZXGradient.brandPurple) .clipShape(RoundedRectangle(cornerRadius: 14)) } + .zxPressable() + .accessibilityLabel(isRunning ? "暂停学习" : "继续学习") Button { showEndConfirm = true } label: { Label("结束", systemImage: "stop.fill") .font(.system(size: 14, weight: .semibold)) @@ -82,6 +93,9 @@ struct LearningSessionView: View { .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: 14)) } + .zxPressable() + .accessibilityLabel("结束学习") + .accessibilityHint("停止计时并保存本次学习记录") } } .padding(24) diff --git a/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift b/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift index 04082a5..7d3ca89 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift @@ -18,6 +18,7 @@ struct ReviewCardView: View { @State private var flipped = false @State private var rating: Int? = nil @State private var finish = false + @State private var showCelebration = false var current: ReviewCardItem { cards[idx] } @@ -41,6 +42,13 @@ struct ReviewCardView: View { .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) .task { await viewModel.loadDueCards() } + .overlay { + if showCelebration { + ZXCelebrationView(title: "复习完成", subtitle: "你已完成本次间隔复习,继续保持!") { + withAnimation(.easeOut(duration: 0.3)) { showCelebration = false } + } + } + } } 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)) .clipShape(RoundedRectangle(cornerRadius: 20)) .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 { @@ -130,6 +143,7 @@ struct ReviewCardView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { idx += 1 } } else { finish = true + showCelebration = true } } } @@ -157,5 +171,8 @@ struct ZXRatingBtn: View { if !selected { RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1) } } } + .zxPressable() + .accessibilityLabel("\(label)") + .accessibilityHint("将此卡片标记为\(label)") } } diff --git a/AIStudyApp/AIStudyApp/Features/Study/ReviewPlanViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/ReviewPlanViewModel.swift new file mode 100644 index 0000000..ed2aa2d --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Study/ReviewPlanViewModel.swift @@ -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 + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift index d7a0022..2aa6fdd 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift @@ -2,26 +2,19 @@ import SwiftUI struct StudyHomeView: View { @StateObject private var studyVM = StudyViewModel() + @StateObject private var studyHomeVM = StudyHomeViewModel() @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 { ZStack { ZXGradient.page.ignoresSafeArea() 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)) } .padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4) 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) } } - ForEach($ts) { $t in + ForEach($studyHomeVM.tasks) { $t in if t.tp == "回忆测试" { NavigationLink(destination: ActiveRecallView()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary) } 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) - 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) } } .padding(.bottom, 120) } .padding(.horizontal, 20) } - .scrollIndicators(.hidden) } + .scrollIndicators(.hidden) + .zxPullToRefresh { 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() 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) } @@ -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) VStack(alignment: .leading, spacing: 4) { Text(task.t).font(.system(size: 13, weight: .semibold)).foregroundColor(task.d ? Color.zxF04 : Color.zxF0).strikethrough(task.d); HStack(spacing: 8) { Text(task.tp).font(.system(size: 10, weight: .semibold)).foregroundColor(task.c).padding(.horizontal, 6).padding(.vertical, 1).background(task.c.opacity(0.12)).clipShape(Capsule()); Text("约 \(task.m) 分钟").font(.system(size: 10)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.35)) } } Spacer(); if !task.d { Image(systemName: "play.fill").font(.system(size: 14)).foregroundColor(.white).frame(width: 32, height: 32).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10)) } } - .padding(.horizontal, 16).padding(.vertical, 12).background(task.d ? Color.zxFill003 : Color.zxFill005).overlay(RoundedRectangle(cornerRadius: 14).stroke(task.d ? Color(hex: "#FFFFFF", opacity: 0.05) : Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).opacity(task.d ? 0.6 : 1).contentShape(Rectangle()).onTapGesture { action() } } + .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 ? "已完成" : "双击开始学习") } } diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift new file mode 100644 index 0000000..f77be9f --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift @@ -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() + } +}