This commit is contained in:
2026-08-12 07:06:01 +07:00
parent 1025658518
commit 9bc96dbc2d
39 changed files with 1682 additions and 658 deletions
+144
View File
@@ -0,0 +1,144 @@
import WidgetKit
import Foundation
import os
// MARK: - Модель отображения
/// Готовая к отрисовке пара: рыночные данные + график + иконка.
struct Quote: Identifiable, Sendable {
let thumb: SymbolThumb
/// Цены за 24ч; последняя точка текущая цена, чтобы график был «живым».
/// Пусто, если история недоступна.
let series: [Double]
let icon: Data?
var id: String { thumb.id }
}
// MARK: - Entry
struct CryptoEntry: TimelineEntry, Sendable {
let date: Date
/// Пары в порядке, выбранном пользователем.
let quotes: [Quote]
/// true не удалось получить данные ни из сети, ни из кэша.
var dataUnavailable = false
}
// MARK: - Провайдер
struct CryptoProvider: TimelineProvider {
private let api = RapiraAPI.shared
private let store = PairSelectionStore.shared
private let cache = MarketSnapshotCache()
private static let logger = Logger(subsystem: AppGroup.id, category: "provider")
/// Плановое обновление и ускоренный повтор после ошибки.
private static let refreshInterval: TimeInterval = 5 * 60
private static let retryInterval: TimeInterval = 2 * 60
// MARK: TimelineProvider
func placeholder(in context: Context) -> CryptoEntry {
PreviewData.entry
}
func getSnapshot(in context: Context, completion: @escaping (CryptoEntry) -> Void) {
// Галерея виджетов ждать сеть не должна.
guard !context.isPreview else {
completion(PreviewData.entry)
return
}
Task { completion(await makeEntry().entry) }
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CryptoEntry>) -> Void) {
Task {
let (entry, isLive) = await makeEntry()
let interval = isLive ? Self.refreshInterval : Self.retryInterval
let timeline = Timeline(
entries: [entry],
policy: .after(Date.now.addingTimeInterval(interval))
)
completion(timeline)
}
}
// MARK: - Сборка entry
/// Живые данные при ошибке последний удачный снимок пустое состояние.
private func makeEntry() async -> (entry: CryptoEntry, isLive: Bool) {
let pairs = store.load()
do {
let thumbs = try await api.symbolThumbs()
let selected = Self.select(pairs, from: thumbs)
async let histories = loadHistories(for: selected.map(\.symbol))
async let icons = IconStore.shared.icons(for: selected.map(\.quoteCurrency))
let quotes = Self.makeQuotes(selected, histories: await histories, icons: await icons)
cache.save(MarketSnapshot(date: .now, thumbs: thumbs, histories: await histories))
return (CryptoEntry(date: .now, quotes: quotes), true)
} catch {
Self.logger.error("Рынок недоступен: \(String(describing: error), privacy: .public)")
return (await fallbackEntry(pairs: pairs), false)
}
}
/// Entry из дискового кэша; date честное время тех данных.
private func fallbackEntry(pairs: [String]) async -> CryptoEntry {
guard let snapshot = cache.load() else {
return CryptoEntry(date: .now, quotes: [], dataUnavailable: true)
}
let selected = Self.select(pairs, from: snapshot.thumbs)
let icons = await IconStore.shared.icons(for: selected.map(\.quoteCurrency))
let quotes = Self.makeQuotes(selected, histories: snapshot.histories, icons: icons)
return CryptoEntry(date: snapshot.date, quotes: quotes)
}
/// История по каждой паре; сбой одной пары не валит остальные
/// пара просто остаётся без графика.
private func loadHistories(for symbols: [String]) async -> [String: [Double]] {
await withTaskGroup(of: (String, [Double]).self) { group in
for symbol in symbols {
group.addTask {
do {
return (symbol, try await api.closes(for: symbol))
} catch {
Self.logger.notice(
"Нет истории \(symbol, privacy: .public): \(String(describing: error), privacy: .public)"
)
return (symbol, [])
}
}
}
var result: [String: [Double]] = [:]
for await (symbol, closes) in group where closes.count > 1 {
result[symbol] = closes
}
return result
}
}
/// Отбирает выбранные пары, сохраняя порядок выбора.
private static func select(_ pairs: [String], from thumbs: [SymbolThumb]) -> [SymbolThumb] {
let bySymbol = Dictionary(thumbs.map { ($0.symbol, $0) }) { first, _ in first }
return pairs.compactMap { bySymbol[$0] }
}
private static func makeQuotes(
_ thumbs: [SymbolThumb],
histories: [String: [Double]],
icons: [String: Data]
) -> [Quote] {
thumbs.map { thumb in
var series = histories[thumb.symbol] ?? []
if !series.isEmpty {
series.append(thumb.close)
}
return Quote(thumb: thumb, series: series, icon: icons[thumb.quoteCurrency])
}
}
}