import WidgetKit import SwiftUI // MARK: - Models struct SymbolThumb: Codable, Identifiable, Sendable { var id: String { symbol } let symbol: String let open: Double let high: Double let low: Double let close: Double let chg: Double let change: Double let volume: Double let turnover: Double let quoteCurrencyName: String let baseCurrency: String let quoteCurrency: String var isPositive: Bool { chg >= 0 } var changePercent: String { String(format: "%+.2f%%", chg * 100) } var priceFormatted: String { let fmt = NumberFormatter() fmt.numberStyle = .decimal fmt.minimumFractionDigits = 2 if close >= 1000 { fmt.maximumFractionDigits = 2 } else if close >= 1 { fmt.maximumFractionDigits = 4 } else { fmt.maximumFractionDigits = 6 } return fmt.string(from: NSNumber(value: close)) ?? String(format: "%.2f", close) } } // MARK: - API struct RapiraAPI { static let url = URL(string: "https://api.rapira.net/market/symbol-thumb")! static func fetch() async throws -> [SymbolThumb] { let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode([SymbolThumb].self, from: data) } static func fetchIcon(for currency: String) async -> Data? { guard let url = URL(string: "https://cdn.rapira.net/media/crypto/\(currency).svg") else { return nil } return try? await URLSession.shared.data(from: url).0 } static func fetchIcons(for currencies: [String]) async -> [String: Data] { await withTaskGroup(of: (String, Data?).self) { group in for cur in Set(currencies) { group.addTask { (cur, await fetchIcon(for: cur)) } } var result: [String: Data] = [:] for await (cur, data) in group { if let data { result[cur] = data } } return result } } } // MARK: - Entry struct CryptoEntry: TimelineEntry { let date: Date let symbols: [SymbolThumb] let selectedPairs: [String] let icons: [String: Data] var filtered: [SymbolThumb] { let order = Dictionary(uniqueKeysWithValues: selectedPairs.enumerated().map { ($1, $0) }) return symbols .filter { selectedPairs.contains($0.symbol) } .sorted { (order[$0.symbol] ?? 99) < (order[$1.symbol] ?? 99) } } } // MARK: - Provider struct CryptoProvider: TimelineProvider { func placeholder(in context: Context) -> CryptoEntry { CryptoEntry(date: .now, symbols: Self.mock, selectedPairs: ["BTC/USDT", "ETH/USDT", "SOL/USDT"], icons: [:]) } func getSnapshot(in context: Context, completion: @escaping (CryptoEntry) -> Void) { Task { let symbols = (try? await RapiraAPI.fetch()) ?? Self.mock let pairs = loadPairs() let filtered = symbols.filter { pairs.contains($0.symbol) } let icons = await RapiraAPI.fetchIcons(for: filtered.map(\.quoteCurrency)) completion(CryptoEntry(date: .now, symbols: symbols, selectedPairs: pairs, icons: icons)) } } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { let symbols = (try? await RapiraAPI.fetch()) ?? Self.mock let pairs = loadPairs() let filtered = symbols.filter { pairs.contains($0.symbol) } let icons = await RapiraAPI.fetchIcons(for: filtered.map(\.quoteCurrency)) let entry = CryptoEntry(date: .now, symbols: symbols, selectedPairs: pairs, icons: icons) let next = Calendar.current.date(byAdding: .minute, value: 5, to: .now)! completion(Timeline(entries: [entry], policy: .after(next))) } } private func loadPairs() -> [String] { UserDefaults(suiteName: "group.robonen.rapira-market-widget.crypto")? .stringArray(forKey: "selectedPairs") ?? ["BTC/USDT", "ETH/USDT", "SOL/USDT"] } static let mock: [SymbolThumb] = [ SymbolThumb(symbol: "BTC/USDT", open: 71000, high: 71381.5, low: 68894.5, close: 70324, chg: 0.0234, change: 1676, volume: 223.9, turnover: 41930552, quoteCurrencyName: "Bitcoin", baseCurrency: "USDT", quoteCurrency: "BTC"), SymbolThumb(symbol: "ETH/USDT", open: 2152.9, high: 2177.4, low: 2105, close: 2143.5, chg: -0.00446, change: -9.4, volume: 27226, turnover: 119347435, quoteCurrencyName: "Ethereum", baseCurrency: "USDT", quoteCurrency: "ETH"), SymbolThumb(symbol: "SOL/USDT", open: 91.77, high: 92.29, low: 88.3, close: 90.08, chg: -0.01913, change: -1.69, volume: 349968, turnover: 71024390, quoteCurrencyName: "Solana", baseCurrency: "USDT", quoteCurrency: "SOL"), SymbolThumb(symbol: "TON/USDT", open: 1.31, high: 1.35, low: 1.29, close: 1.3382, chg: 0.0223, change: 0.0282, volume: 5200000, turnover: 6958640, quoteCurrencyName: "Toncoin", baseCurrency: "USDT", quoteCurrency: "TON"), ] } // MARK: - Theme private enum Theme { static let bgTop = Color(red: 0.08, green: 0.12, blue: 0.32) static let bgBottom = Color(red: 0.03, green: 0.05, blue: 0.18) static let label = Color(red: 0.45, green: 0.65, blue: 1.0) static let positive = Color(red: 0.05, green: 0.82, blue: 0.42) static let negative = Color(red: 1.0, green: 0.30, blue: 0.30) static let divider = Color.white.opacity(0.08) static var bgGradient: LinearGradient { LinearGradient( colors: [bgTop, bgBottom], startPoint: .top, endPoint: .bottom ) } } // MARK: - Coin Icon (Widget — uses prefetched Data) private struct WidgetCoinIcon: View { let symbol: String let data: Data? let size: CGFloat var body: some View { if let data, let img = NSImage(data: data) { Image(nsImage: img) .resizable() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .clipShape(Circle()) } else { ZStack { Circle() .fill( LinearGradient( colors: [coinColor(symbol), coinColor(symbol).opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) Text(String(symbol.prefix(1))) .font(.system(size: size * 0.42, weight: .bold, design: .rounded)) .foregroundStyle(.white) } .frame(width: size, height: size) } } } private func coinColor(_ s: String) -> Color { switch s { case "BTC": return .orange case "ETH": return Color(red: 0.39, green: 0.49, blue: 0.94) case "SOL": return Color(red: 0.56, green: 0.25, blue: 0.95) case "BNB": return Color(red: 0.96, green: 0.72, blue: 0.04) case "XRP": return Color(red: 0.14, green: 0.14, blue: 0.14) case "TON": return Color(red: 0.04, green: 0.65, blue: 0.93) default: return Color(red: 0.55, green: 0.55, blue: 0.60) } } // MARK: - Crypto Row private struct CryptoRow: View { let symbol: SymbolThumb let iconData: Data? let compact: Bool var body: some View { HStack(spacing: compact ? 8 : 10) { WidgetCoinIcon(symbol: symbol.quoteCurrency, data: iconData, size: compact ? 26 : 30) VStack(alignment: .leading, spacing: 1) { HStack(spacing: 2) { Text(symbol.quoteCurrency) .fontWeight(.bold) Text("/") .foregroundStyle(.white.opacity(0.35)) Text(symbol.baseCurrency) .fontWeight(.medium) .foregroundStyle(.white.opacity(0.55)) } .font(.system(size: compact ? 12 : 13)) .foregroundStyle(.white) Text(symbol.quoteCurrencyName) .font(.system(size: compact ? 9 : 10)) .foregroundStyle(.white.opacity(0.4)) .lineLimit(1) } Spacer(minLength: 4) Text(symbol.priceFormatted) .font(.system(size: compact ? 13 : 14, weight: .bold, design: .rounded)) .foregroundStyle(.white) .lineLimit(1) .minimumScaleFactor(0.6) Text(symbol.changePercent) .font(.system(size: compact ? 10 : 11, weight: .bold, design: .rounded)) .foregroundStyle(symbol.isPositive ? Theme.positive : Theme.negative) .frame(minWidth: compact ? 48 : 54, alignment: .trailing) } .padding(.horizontal, compact ? 12 : 14) .padding(.vertical, compact ? 6 : 8) } } // MARK: - Small Widget struct SmallWidgetView: View { let entry: CryptoEntry var body: some View { if let pair = entry.filtered.first { VStack(spacing: 4) { Spacer(minLength: 0) WidgetCoinIcon(symbol: pair.quoteCurrency, data: entry.icons[pair.quoteCurrency], size: 36) HStack(spacing: 2) { Text(pair.quoteCurrency) .fontWeight(.bold) Text("/") .foregroundStyle(.white.opacity(0.35)) Text(pair.baseCurrency) .fontWeight(.medium) .foregroundStyle(.white.opacity(0.55)) } .font(.system(size: 13)) .foregroundStyle(.white) Text(pair.priceFormatted) .font(.system(size: 22, weight: .bold, design: .rounded)) .foregroundStyle(.white) .minimumScaleFactor(0.5) .lineLimit(1) Text(pair.changePercent) .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(pair.isPositive ? Theme.positive : Theme.negative) Spacer(minLength: 0) } .frame(maxWidth: .infinity) .containerBackground(for: .widget) { Theme.bgGradient } } } } // MARK: - Medium Widget struct MediumWidgetView: View { let entry: CryptoEntry var body: some View { VStack(spacing: 0) { let items = Array(entry.filtered.prefix(3)) ForEach(Array(items.enumerated()), id: \.element.id) { idx, sym in CryptoRow(symbol: sym, iconData: entry.icons[sym.quoteCurrency], compact: true) if idx < items.count - 1 { Theme.divider.frame(height: 1).padding(.leading, 46) } } } .frame(maxHeight: .infinity) .containerBackground(for: .widget) { Theme.bgGradient } } } // MARK: - Large Widget struct LargeWidgetView: View { let entry: CryptoEntry var body: some View { VStack(spacing: 0) { let items = Array(entry.filtered.prefix(6)) ForEach(Array(items.enumerated()), id: \.element.id) { idx, sym in CryptoRow(symbol: sym, iconData: entry.icons[sym.quoteCurrency], compact: false) if idx < items.count - 1 { Theme.divider.frame(height: 1).padding(.leading, 54) } } Spacer(minLength: 0) } .containerBackground(for: .widget) { Theme.bgGradient } } } // MARK: - Entry View struct CryptoWidgetEntryView: View { var entry: CryptoEntry @Environment(\.widgetFamily) private var family var body: some View { switch family { case .systemSmall: SmallWidgetView(entry: entry) case .systemMedium: MediumWidgetView(entry: entry) case .systemLarge: LargeWidgetView(entry: entry) default: MediumWidgetView(entry: entry) } } } // MARK: - Widget @main struct CryptoWidget: Widget { let kind = "CryptoWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: CryptoProvider()) { entry in CryptoWidgetEntryView(entry: entry) } .configurationDisplayName("Rapira Crypto") .description("Курсы криптовалют в реальном времени") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } // MARK: - Preview #Preview(as: .systemLarge) { CryptoWidget() } timeline: { CryptoEntry(date: .now, symbols: CryptoProvider.mock, selectedPairs: ["BTC/USDT", "ETH/USDT", "SOL/USDT", "TON/USDT"], icons: [:]) }