Files
rapira-market-widget/crypto/CryptoProvider.swift
T
2026-08-12 07:06:01 +07:00

145 lines
5.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}
}
}