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:
parent
b182203464
commit
89d89e542c
@ -34,6 +34,7 @@ struct AIStudyAppApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(effectiveColorScheme)
|
.preferredColorScheme(effectiveColorScheme)
|
||||||
|
.zxToast()
|
||||||
.task {
|
.task {
|
||||||
await authManager.restoreSession()
|
await authManager.restoreSession()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)}
|
||||||
}
|
}
|
||||||
|
|||||||
307
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift
Normal file
307
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
135
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift
Normal file
135
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
153
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift
Normal file
153
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?
|
||||||
|
|||||||
43
AIStudyApp/AIStudyApp/Core/Security/FileCache.swift
Normal file
43
AIStudyApp/AIStudyApp/Core/Security/FileCache.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
AIStudyApp/AIStudyApp/Core/Security/LocalCache.swift
Normal file
92
AIStudyApp/AIStudyApp/Core/Security/LocalCache.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift
Normal file
42
AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
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()) {
|
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)
|
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) {
|
HStack(spacing: 12) {
|
||||||
NavigationLink(destination: AIChatPage()) {
|
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))
|
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()) {
|
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))
|
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)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)} }
|
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() }
|
||||||
|
|||||||
@ -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() }
|
||||||
|
|||||||
@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
saveError = nil
|
||||||
|
do {
|
||||||
|
_ = try await UserService.shared.updateProfile(UpdateProfileRequest(
|
||||||
nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil
|
nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil
|
||||||
))
|
))
|
||||||
_ = try? await UserService.shared.updateProfileDetail(UpdateProfileDataRequest(
|
_ = try await UserService.shared.updateProfileDetail(UpdateProfileDataRequest(
|
||||||
learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity,
|
learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity,
|
||||||
learningDirection: learningDirection.isEmpty ? nil : learningDirection,
|
learningDirection: learningDirection.isEmpty ? nil : learningDirection,
|
||||||
bio: bio.isEmpty ? nil : bio,
|
bio: bio.isEmpty ? nil : bio,
|
||||||
currentGoal: currentGoal.isEmpty ? nil : currentGoal
|
currentGoal: currentGoal.isEmpty ? nil : currentGoal
|
||||||
))
|
))
|
||||||
saved = true
|
saved = true
|
||||||
|
} catch {
|
||||||
|
saveError = "保存失败: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
isSaving = false
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isSaving { ProgressView().tint(.white) }
|
||||||
Text(saved ? "已保存" : "保存修改")
|
Text(saved ? "已保存" : "保存修改")
|
||||||
.font(.system(size: 14, weight: .bold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 52)
|
.frame(height: 52)
|
||||||
.background(ZXGradient.ctaPurple)
|
.background {
|
||||||
|
if isSaving { Color.gray } else { ZXGradient.ctaPurple }
|
||||||
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) }
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 ? "已完成" : "双击开始学习") }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user