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

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