- 新增 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>
308 lines
9.8 KiB
Swift
308 lines
9.8 KiB
Swift
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
|
|
}
|
|
}
|