WangDL 89d89e542c 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>
2026-05-17 22:31:24 +08:00

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
}
}