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

154 lines
4.3 KiB
Swift

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