145 lines
5.7 KiB
Swift
145 lines
5.7 KiB
Swift
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])
|
||
}
|
||
}
|
||
}
|