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