373 lines
13 KiB
Swift
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: [:])
|
|
} |