- 新增 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>
93 lines
2.7 KiB
Swift
93 lines
2.7 KiB
Swift
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)
|
|
}
|
|
}
|