Files
rapira-market-widget/crypto/CryptoWidget.swift
T
2026-04-29 20:59:39 +07:00

373 lines
13 KiB
Swift

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