- 新增 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>
154 lines
4.3 KiB
Swift
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)
|
|
}
|
|
}
|