feat: v2
@@ -1,51 +1,61 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
||||
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 630 B |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,144 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,373 +1,38 @@
|
||||
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"
|
||||
/// Идентификатор вида — менять нельзя, иначе слетят установленные виджеты.
|
||||
static let kind = "CryptoWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CryptoProvider()) { entry in
|
||||
StaticConfiguration(kind: Self.kind, provider: CryptoProvider()) { entry in
|
||||
CryptoWidgetEntryView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Rapira Crypto")
|
||||
.description("Курсы криптовалют в реальном времени")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
// MARK: - Превью
|
||||
|
||||
#Preview(as: .systemSmall) {
|
||||
CryptoWidget()
|
||||
} timeline: {
|
||||
PreviewData.entry
|
||||
}
|
||||
|
||||
#Preview(as: .systemMedium) {
|
||||
CryptoWidget()
|
||||
} timeline: {
|
||||
PreviewData.entry
|
||||
}
|
||||
|
||||
#Preview(as: .systemLarge) {
|
||||
CryptoWidget()
|
||||
} timeline: {
|
||||
CryptoEntry(date: .now, symbols: CryptoProvider.mock, selectedPairs: ["BTC/USDT", "ETH/USDT", "SOL/USDT", "TON/USDT"], icons: [:])
|
||||
}
|
||||
PreviewData.entry
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Последний удачный ответ API.
|
||||
struct MarketSnapshot: Codable, Sendable {
|
||||
let date: Date
|
||||
let thumbs: [SymbolThumb]
|
||||
let histories: [String: [Double]]
|
||||
}
|
||||
|
||||
/// Дисковый кэш снимка рынка в контейнере App Group.
|
||||
///
|
||||
/// Офлайн-фолбэк: без сети виджет показывает последние реальные котировки
|
||||
/// с честной меткой времени, а не фиктивные данные.
|
||||
struct MarketSnapshotCache: Sendable {
|
||||
private let fileURL: URL?
|
||||
|
||||
private static let logger = Logger(subsystem: AppGroup.id, category: "snapshot-cache")
|
||||
|
||||
init() {
|
||||
fileURL = AppGroup.cachesURL?.appendingPathComponent("market-snapshot.json")
|
||||
}
|
||||
|
||||
func load() -> MarketSnapshot? {
|
||||
guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
do {
|
||||
return try JSONDecoder().decode(MarketSnapshot.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("Кэш повреждён: \(String(describing: error), privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ snapshot: MarketSnapshot) {
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try JSONEncoder().encode(snapshot).write(to: fileURL, options: .atomic)
|
||||
} catch {
|
||||
Self.logger.error("Кэш не сохранён: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// Демо-данные для плейсхолдера, галереи виджетов и превью.
|
||||
/// Плейсхолдер нужен и в релизе, поэтому файл не под `#if DEBUG`.
|
||||
enum PreviewData {
|
||||
static var entry: CryptoEntry {
|
||||
CryptoEntry(
|
||||
date: .now,
|
||||
quotes: thumbs.map { thumb in
|
||||
Quote(thumb: thumb, series: series(for: thumb), icon: nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
static let thumbs: [SymbolThumb] = [
|
||||
SymbolThumb(symbol: "BTC/USDT", open: 71000, high: 71381.5, low: 68894.5,
|
||||
close: 70324, chg: 0.0234, change: 1676, volume: 223.9,
|
||||
turnover: 41_930_552, 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: 119_347_435, 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: 349_968,
|
||||
turnover: 71_024_390, 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: 5_200_000,
|
||||
turnover: 6_958_640, quoteCurrencyName: "Toncoin",
|
||||
baseCurrency: "USDT", quoteCurrency: "TON"),
|
||||
]
|
||||
|
||||
/// Синтетический ряд, похожий на дневной график.
|
||||
private static func series(for thumb: SymbolThumb, points: Int = 48) -> [Double] {
|
||||
var values = (0..<points).map { index -> Double in
|
||||
let t = Double(index) / Double(points - 1)
|
||||
let wave = sin(t * 7) * 0.4 + sin(t * 17 + 1.2) * 0.18 + sin(t * 3.3) * 0.5
|
||||
return thumb.open + (thumb.close - thumb.open) * t + wave * (thumb.high - thumb.low) * 0.35
|
||||
}
|
||||
values.append(thumb.close)
|
||||
return values
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Форма линии
|
||||
|
||||
/// Сглаженная ломаная по точкам; `closed` замыкает контур вниз для заливки.
|
||||
private struct SparkShape: Shape {
|
||||
let points: [CGPoint]
|
||||
let closed: Bool
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
guard points.count > 1 else { return path }
|
||||
|
||||
path.move(to: points[0])
|
||||
for index in 0..<(points.count - 1) {
|
||||
let current = points[index]
|
||||
let next = points[index + 1]
|
||||
let mid = CGPoint(x: (current.x + next.x) / 2, y: (current.y + next.y) / 2)
|
||||
if index == 0 {
|
||||
path.addLine(to: mid)
|
||||
} else {
|
||||
path.addQuadCurve(to: mid, control: current)
|
||||
}
|
||||
}
|
||||
path.addLine(to: points[points.count - 1])
|
||||
|
||||
if closed {
|
||||
path.addLine(to: CGPoint(x: points[points.count - 1].x, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: points[0].x, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Спарклайн
|
||||
|
||||
/// График в стиле макета: линия с горизонтальным градиентом (яркая точка
|
||||
/// на 77%), свечение-заливка под линией и «живая» точка на конце.
|
||||
struct Sparkline: View {
|
||||
let values: [Double]
|
||||
let positive: Bool
|
||||
var lineWidth: CGFloat = 2
|
||||
var showPulse: Bool = true
|
||||
var pulseSize: CGFloat = 16
|
||||
var areaOpacity: Double = 0.25
|
||||
/// Отступ справа в диаметрах точки: линия обрывается, не доходя до края.
|
||||
var trailingRoom: CGFloat = 1.4
|
||||
|
||||
/// Больше точек глазом уже не различить, а форма тяжелеет.
|
||||
private static let maxPoints = 96
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let points = points(in: geo.size)
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Заливка: вертикальный уход в прозрачность + горизонтальная
|
||||
// маска — свечение сосредоточено слева, как в макете.
|
||||
SparkShape(points: points, closed: true)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
RapiraPalette.line(positive).opacity(areaOpacity),
|
||||
RapiraPalette.line(positive).opacity(0),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
colors: [.white, .white.opacity(0.25)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
|
||||
// Гало точки лежит под линией, ядро — над ней.
|
||||
if showPulse, let last = points.last {
|
||||
Circle()
|
||||
.fill(RapiraPalette.halo(positive))
|
||||
.frame(width: pulseSize, height: pulseSize)
|
||||
.position(last)
|
||||
}
|
||||
|
||||
SparkShape(points: points, closed: false)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: RapiraPalette.lineEdge(positive), location: 0),
|
||||
.init(color: RapiraPalette.line(positive), location: 0.77),
|
||||
.init(color: RapiraPalette.lineEdge(positive), location: 1),
|
||||
]),
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
),
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
|
||||
if showPulse, let last = points.last {
|
||||
Circle()
|
||||
.fill(RapiraPalette.line(positive))
|
||||
.frame(width: pulseSize * 0.487, height: pulseSize * 0.487)
|
||||
.position(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Геометрия
|
||||
|
||||
private func points(in size: CGSize) -> [CGPoint] {
|
||||
let series = downsampled(values, limit: Self.maxPoints)
|
||||
guard series.count > 1, size.width > 0, size.height > 0 else { return [] }
|
||||
|
||||
let trailingInset = showPulse ? pulseSize * trailingRoom : lineWidth / 2
|
||||
let verticalPad = max(lineWidth, showPulse ? pulseSize / 2 : lineWidth)
|
||||
let width = max(size.width - trailingInset, 1)
|
||||
let height = max(size.height - verticalPad * 2, 1)
|
||||
|
||||
let low = series.min() ?? 0
|
||||
let high = series.max() ?? 1
|
||||
let span = high - low
|
||||
|
||||
return series.enumerated().map { index, value in
|
||||
let x = width * CGFloat(index) / CGFloat(series.count - 1)
|
||||
let normalized = span > 0 ? (value - low) / span : 0.5
|
||||
let y = verticalPad + height * (1 - CGFloat(normalized))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
}
|
||||
|
||||
private func downsampled(_ values: [Double], limit: Int) -> [Double] {
|
||||
guard values.count > limit else { return values }
|
||||
let step = Double(values.count - 1) / Double(limit - 1)
|
||||
return (0..<limit).map { values[Int((Double($0) * step).rounded())] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Карточка-герой
|
||||
|
||||
/// Одна пара крупно: заголовок, сквозной график, цена и изменение за 24ч.
|
||||
struct HeroCard: View {
|
||||
let quote: Quote
|
||||
let updatedAt: Date
|
||||
/// Горизонтальный отступ контейнера — график компенсирует его
|
||||
/// и уходит под края виджета, как в макете.
|
||||
let horizontalPadding: CGFloat
|
||||
let iconSize: CGFloat
|
||||
let titleSize: CGFloat
|
||||
let priceSize: CGFloat
|
||||
let chartHeight: CGFloat
|
||||
var showFullDate = true
|
||||
var showAbsolute = true
|
||||
|
||||
private var thumb: SymbolThumb { quote.thumb }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
PairHeader(thumb: thumb, icon: quote.icon, iconSize: iconSize, titleSize: titleSize)
|
||||
|
||||
UpdatedAt(date: updatedAt, fontSize: titleSize * 0.62, withDate: showFullDate)
|
||||
.padding(.top, titleSize * 0.45)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
chart
|
||||
.frame(height: chartHeight)
|
||||
.padding(.horizontal, -horizontalPadding)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
PriceLabel(thumb: thumb, size: priceSize)
|
||||
|
||||
StatsLine(thumb: thumb, fontSize: titleSize * 0.78, showAbsolute: showAbsolute)
|
||||
.padding(.top, titleSize * 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chart: some View {
|
||||
if quote.series.count > 1 {
|
||||
Sparkline(
|
||||
values: quote.series,
|
||||
positive: thumb.isPositive,
|
||||
lineWidth: 2,
|
||||
pulseSize: iconSize * 0.52
|
||||
)
|
||||
} else {
|
||||
// Нет истории — держим ритм макета ровной линией.
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
RapiraPalette.lineEdge(thumb.isPositive),
|
||||
RapiraPalette.line(thumb.isPositive).opacity(0.5),
|
||||
RapiraPalette.lineEdge(thumb.isPositive),
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(height: 1.5)
|
||||
.frame(maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Строка списка
|
||||
|
||||
/// Компактная строка: иконка, пара, мини-график, цена и процент.
|
||||
struct CryptoRow: View {
|
||||
let quote: Quote
|
||||
let compact: Bool
|
||||
|
||||
private var thumb: SymbolThumb { quote.thumb }
|
||||
private var iconSize: CGFloat { compact ? 24 : 26 }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
CoinIconView(currency: thumb.quoteCurrency, data: quote.icon, size: iconSize)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 1) {
|
||||
Text(thumb.quoteCurrency)
|
||||
.foregroundStyle(.white)
|
||||
Text("/\(thumb.baseCurrency)")
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
.font(.system(size: compact ? 12 : 13, weight: .bold))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(thumb.quoteCurrencyName)
|
||||
.font(.system(size: compact ? 9 : 10, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if quote.series.count > 1 {
|
||||
Sparkline(
|
||||
values: quote.series,
|
||||
positive: thumb.isPositive,
|
||||
lineWidth: 1.5,
|
||||
pulseSize: 7,
|
||||
areaOpacity: 0.16,
|
||||
trailingRoom: 0.6
|
||||
)
|
||||
.frame(width: compact ? 46 : 56, height: 22)
|
||||
}
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(thumb.priceFormatted)
|
||||
.font(.system(size: compact ? 12 : 13, weight: .bold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
|
||||
Text(thumb.changePercent)
|
||||
.font(.system(size: compact ? 10 : 10.5, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(RapiraPalette.stat(thumb.isPositive))
|
||||
}
|
||||
.frame(minWidth: compact ? 62 : 70, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Заголовок пары
|
||||
|
||||
/// Иконка монеты + «BTC/USDT» + знак Rapira справа.
|
||||
struct PairHeader: View {
|
||||
let thumb: SymbolThumb
|
||||
let icon: Data?
|
||||
let iconSize: CGFloat
|
||||
let titleSize: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: iconSize * 0.3) {
|
||||
CoinIconView(currency: thumb.quoteCurrency, data: icon, size: iconSize)
|
||||
|
||||
HStack(spacing: 1) {
|
||||
Text(thumb.quoteCurrency)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.white, .white.opacity(0.85)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
Text("/\(thumb.baseCurrency)")
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
.font(.system(size: titleSize, weight: .bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
RapiraMark(size: iconSize * 0.52)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Цена
|
||||
|
||||
/// Крупная цена с приглушённым символом валюты: «70 324,00 USDT».
|
||||
struct PriceLabel: View {
|
||||
let thumb: SymbolThumb
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: size * 0.16) {
|
||||
Text(thumb.priceFormatted)
|
||||
.font(.system(size: size, weight: .bold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.white, .white.opacity(0.7)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
|
||||
Text(thumb.baseSymbol)
|
||||
.font(.system(size: size * 0.42, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Изменение за 24ч
|
||||
|
||||
/// «+1 676,00 USDT · 2.34%» одной зелёной/красной строкой, без подложки.
|
||||
struct StatsLine: View {
|
||||
let thumb: SymbolThumb
|
||||
let fontSize: CGFloat
|
||||
var showAbsolute = true
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: fontSize * 0.4) {
|
||||
if showAbsolute {
|
||||
Text(thumb.changeFormatted)
|
||||
Text("·")
|
||||
.foregroundStyle(RapiraPalette.stat(thumb.isPositive).opacity(0.45))
|
||||
Text(thumb.changePercentPlain)
|
||||
} else {
|
||||
Text(thumb.changePercent)
|
||||
}
|
||||
}
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(RapiraPalette.stat(thumb.isPositive))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Время обновления
|
||||
|
||||
/// Подпись «27 октября 17:10» под парой — как в макете.
|
||||
struct UpdatedAt: View {
|
||||
let date: Date
|
||||
let fontSize: CGFloat
|
||||
var withDate = true
|
||||
|
||||
var body: some View {
|
||||
Text(MarketFormatting.updated(date, withDate: withDate))
|
||||
.font(.system(size: fontSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Пустое состояние
|
||||
|
||||
struct EmptyState: View {
|
||||
enum Reason {
|
||||
/// Ни одна выбранная пара не найдена в данных.
|
||||
case noSelection
|
||||
/// Ни сети, ни кэша.
|
||||
case noData
|
||||
}
|
||||
|
||||
let reason: Reason
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
RapiraMark(size: 26)
|
||||
Text(message)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch reason {
|
||||
case .noSelection: "Выберите пары\nв приложении"
|
||||
case .noData: "Нет данных.\nПроверьте соединение"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Маленький
|
||||
|
||||
/// Герой-карточка первой пары в сжатой компоновке.
|
||||
struct SmallWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
private let padding: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let hero = entry.quotes.first {
|
||||
HeroCard(
|
||||
quote: hero,
|
||||
updatedAt: entry.date,
|
||||
horizontalPadding: padding,
|
||||
iconSize: 24,
|
||||
titleSize: 13,
|
||||
priceSize: 21,
|
||||
chartHeight: 38,
|
||||
showFullDate: false,
|
||||
showAbsolute: false
|
||||
)
|
||||
.padding(padding)
|
||||
} else {
|
||||
EmptyState(reason: entry.emptyReason)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.containerBackground(for: .widget) { RapiraBackground() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Средний
|
||||
|
||||
/// Список до трёх пар со временем обновления в шапке.
|
||||
struct MediumWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.quotes.isEmpty {
|
||||
EmptyState(reason: entry.emptyReason)
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { RapiraBackground() }
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
UpdatedAt(date: entry.date, fontSize: 9.5)
|
||||
Spacer()
|
||||
RapiraMark(size: 13)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
|
||||
let quotes = Array(entry.quotes.prefix(3))
|
||||
ForEach(Array(quotes.enumerated()), id: \.element.id) { index, quote in
|
||||
CryptoRow(quote: quote, compact: true)
|
||||
.frame(maxHeight: .infinity)
|
||||
if index < quotes.count - 1 {
|
||||
RapiraPalette.divider.frame(height: 1).padding(.leading, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Большой
|
||||
|
||||
/// Герой первой пары + до четырёх остальных списком.
|
||||
struct LargeWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
private let padding: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let hero = entry.quotes.first {
|
||||
content(hero: hero, rest: Array(entry.quotes.dropFirst().prefix(4)))
|
||||
} else {
|
||||
EmptyState(reason: entry.emptyReason)
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { RapiraBackground() }
|
||||
}
|
||||
|
||||
private func content(hero: Quote, rest: [Quote]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HeroCard(
|
||||
quote: hero,
|
||||
updatedAt: entry.date,
|
||||
horizontalPadding: padding,
|
||||
iconSize: 36,
|
||||
titleSize: 19,
|
||||
priceSize: 32,
|
||||
chartHeight: 72
|
||||
)
|
||||
|
||||
if !rest.isEmpty {
|
||||
RapiraPalette.divider
|
||||
.frame(height: 1)
|
||||
.padding(.top, 14)
|
||||
.padding(.horizontal, -padding)
|
||||
|
||||
ForEach(Array(rest.enumerated()), id: \.element.id) { index, quote in
|
||||
CryptoRow(quote: quote, compact: false)
|
||||
.frame(maxHeight: .infinity)
|
||||
if index < rest.count - 1 {
|
||||
RapiraPalette.divider.frame(height: 1).padding(.leading, 34)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(padding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Диспетчер размеров
|
||||
|
||||
struct CryptoWidgetEntryView: View {
|
||||
let 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CryptoEntry {
|
||||
var emptyReason: EmptyState.Reason {
|
||||
dataUnavailable ? .noData : .noSelection
|
||||
}
|
||||
}
|
||||