This commit is contained in:
2026-08-12 07:06:01 +07:00
parent 1025658518
commit 9bc96dbc2d
39 changed files with 1682 additions and 658 deletions
+49
View File
@@ -0,0 +1,49 @@
import Foundation
// MARK: - App Group
/// Единая точка правды об App Group: идентификатор и пути к общему хранилищу.
/// Идентификатор обязан совпадать со значением в entitlements обоих таргетов.
///
/// `nonisolated` инфраструктура не привязана к UI: в app-таргете включена
/// изоляция MainActor по умолчанию, а сюда ходят и акторы, и провайдер виджета.
nonisolated enum AppGroup {
static let id = "group.robonen.rapira-market-widget.crypto"
/// UserDefaults, видимые и приложению, и виджету.
static var defaults: UserDefaults? {
UserDefaults(suiteName: id)
}
/// Каталог дисковых кэшей в контейнере группы.
/// nil если entitlements не настроены; кэширование тогда просто отключается.
static var cachesURL: URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: id)?
.appendingPathComponent("Library/Caches", isDirectory: true)
}
}
// MARK: - Выбор пар
/// Пары, выбранные пользователем для виджета. Порядок сохраняется:
/// первая пара «герой» маленького и большого виджетов.
nonisolated struct PairSelectionStore: Sendable {
static let shared = PairSelectionStore()
/// Стартовый набор до первой настройки.
static let defaultPairs = ["BTC/USDT", "ETH/USDT", "SOL/USDT"]
/// Максимум пар: столько вмещает большой виджет (1 крупная + 4 строки).
static let maxSelection = 5
private let key = "selectedPairs"
func load() -> [String] {
AppGroup.defaults?.stringArray(forKey: key) ?? Self.defaultPairs
}
func save(_ pairs: [String]) {
AppGroup.defaults?.set(pairs, forKey: key)
}
}
+59
View File
@@ -0,0 +1,59 @@
import SwiftUI
import AppKit
/// Иконка монеты из готовых данных (виджет получает их от провайдера).
/// Без данных кружок с первой буквой в фирменном цвете монеты.
struct CoinIconView: View {
let currency: String
let data: Data?
let size: CGFloat
var body: some View {
Group {
if let data, let image = NSImage(data: data) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(Circle())
} else {
fallback
}
}
.frame(width: size, height: size)
.overlay(Circle().strokeBorder(.white.opacity(0.08), lineWidth: 1))
}
private var fallback: some View {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [
CoinPalette.color(for: currency),
CoinPalette.color(for: currency).opacity(0.65),
],
startPoint: .top,
endPoint: .bottom
)
)
Text(String(currency.prefix(1)))
.font(.system(size: size * 0.44, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
}
}
/// Иконка с самостоятельной загрузкой через общий кэш для окна настроек.
struct RemoteCoinIcon: View {
let currency: String
var size: CGFloat = 30
@State private var data: Data?
var body: some View {
CoinIconView(currency: currency, data: data, size: size)
.task(id: currency) {
data = await IconStore.shared.data(for: currency)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import Foundation
import os
/// Кэш иконок монет: память диск CDN.
///
/// Диск контейнер App Group, поэтому кэш общий для приложения и виджета:
/// иконка скачивается один раз, а не при каждом обновлении таймлайна.
actor IconStore {
static let shared = IconStore()
/// Иконки монет практически не меняются неделя жизни файла достаточна.
private let maxAge: TimeInterval
private let directory: URL?
private var memory: [String: Data] = [:]
private static let logger = Logger(subsystem: AppGroup.id, category: "icon-store")
init(maxAge: TimeInterval = 7 * 24 * 60 * 60) {
self.maxAge = maxAge
self.directory = Self.makeDirectory()
}
// MARK: - Публичное API
/// Иконка валюты; nil если её нет ни в кэше, ни на CDN.
func data(for currency: String) async -> Data? {
if let data = memory[currency] {
return data
}
let file = fileURL(for: currency)
if let file, let data = freshData(at: file) {
memory[currency] = data
return data
}
do {
let data = try await RapiraAPI.shared.iconData(for: currency)
memory[currency] = data
if let file {
try? data.write(to: file, options: .atomic)
}
return data
} catch {
Self.logger.notice(
"Иконка \(currency, privacy: .public) недоступна: \(String(describing: error), privacy: .public)"
)
// Просроченный файл лучше пустого кружка.
if let file, let stale = try? Data(contentsOf: file) {
memory[currency] = stale
return stale
}
return nil
}
}
/// Пакетная загрузка для провайдера виджета.
func icons(for currencies: some Sequence<String>) async -> [String: Data] {
await withTaskGroup(of: (String, Data?).self) { group in
for currency in Set(currencies) {
group.addTask { (currency, await self.data(for: currency)) }
}
var result: [String: Data] = [:]
for await (currency, data) in group {
if let data {
result[currency] = data
}
}
return result
}
}
// MARK: - Диск
private func fileURL(for currency: String) -> URL? {
directory?.appendingPathComponent("\(currency).svg")
}
/// Данные файла, если он ещё не устарел.
private func freshData(at url: URL) -> Data? {
guard
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let modified = attributes[.modificationDate] as? Date,
Date.now.timeIntervalSince(modified) < maxAge
else { return nil }
return try? Data(contentsOf: url)
}
private static func makeDirectory() -> URL? {
let base = AppGroup.cachesURL
?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
guard let base else { return nil }
let directory = base.appendingPathComponent("coin-icons", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
} catch {
logger.error("Не удалось создать кэш иконок: \(String(describing: error), privacy: .public)")
return nil
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import Foundation
/// Форматирование рыночных значений.
///
/// `NumberFormatter`/`DateFormatter` дороги в создании, поэтому все экземпляры
/// кэшируются (чтение форматтеров потокобезопасно начиная с macOS 10.9).
enum MarketFormatting {
// MARK: - Публичное API
/// Цена с точностью, зависящей от порядка величины:
/// «70 324,00», «90,0800», «0,338200».
static func price(_ value: Double) -> String {
priceFormatter(for: value).string(from: NSNumber(value: value))
?? String(format: "%.2f", value)
}
/// Абсолютное изменение со знаком: «+1 676,00».
static func signedAmount(_ value: Double) -> String {
let formatter = abs(value) >= 100 ? signedTwoDigits : signedFourDigits
return formatter.string(from: NSNumber(value: value))
?? String(format: "%+.2f", value)
}
/// Доля проценты: 0.0234 «2.34%» или «+2.34%».
static func percent(_ share: Double, signed: Bool) -> String {
String(format: signed ? "%+.2f%%" : "%.2f%%", share * 100)
}
/// Метка обновления: «27 октября 17:10» или «17:10».
static func updated(_ date: Date, withDate: Bool) -> String {
(withDate ? dateTime : timeOnly).string(from: date)
}
// MARK: - Кэш форматтеров
private static let priceTwoDigits = decimal(maxFraction: 2)
private static let priceFourDigits = decimal(maxFraction: 4)
private static let priceSixDigits = decimal(maxFraction: 6)
private static let signedTwoDigits = decimal(maxFraction: 2, signed: true)
private static let signedFourDigits = decimal(maxFraction: 4, signed: true)
private static func priceFormatter(for value: Double) -> NumberFormatter {
switch abs(value) {
case 1000...: priceTwoDigits
case 1...: priceFourDigits
default: priceSixDigits
}
}
private static func decimal(maxFraction: Int, signed: Bool = false) -> NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = maxFraction
if signed {
formatter.positivePrefix = formatter.plusSign
}
return formatter
}
private static let dateTime = dateFormatter("d MMMM HH:mm")
private static let timeOnly = dateFormatter("HH:mm")
private static func dateFormatter(_ format: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = .current
formatter.dateFormat = format
return formatter
}
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
import os
/// Клиент публичного API Rapira.
///
/// Транспортный слой без бизнес-логики: обработка статусов, декодирование
/// и таймауты. Что делать с ошибками решают вызывающие стороны.
///
/// `nonisolated` чтобы в app-таргете (MainActor по умолчанию) сетевые
/// вызовы из акторов не прыгали через главный поток.
nonisolated struct RapiraAPI: Sendable {
enum APIError: Error {
case invalidResponse
case badStatus(Int)
}
static let shared = RapiraAPI()
private let session: URLSession
private static let logger = Logger(subsystem: AppGroup.id, category: "api")
/// - Parameter session: подменяется в тестах; nil сессия по умолчанию.
init(session: URLSession? = nil) {
self.session = session ?? Self.makeSession()
}
// MARK: - Методы
/// Сводки по всем торговым парам.
func symbolThumbs() async throws -> [SymbolThumb] {
try await get([SymbolThumb].self, from: Endpoint.symbolThumbs.url)
}
/// Цены закрытия за окно `hours` часов с шагом `resolutionMinutes` минут.
/// Ответ API массив свечей `[ts, open, high, low, close, volume]`.
func closes(
for symbol: String,
resolutionMinutes: Int = 15,
hours: Int = 24,
now: Date = .now
) async throws -> [Double] {
let toMs = Int(now.timeIntervalSince1970 * 1000)
let fromMs = toMs - hours * 3_600_000
let endpoint = Endpoint.history(
symbol: symbol, resolutionMinutes: resolutionMinutes, fromMs: fromMs, toMs: toMs
)
let candles = try await get([[Double]].self, from: endpoint.url)
return candles.compactMap { $0.count > 4 ? $0[4] : nil }
}
/// SVG-иконка монеты с CDN.
func iconData(for currency: String) async throws -> Data {
try await data(from: Endpoint.icon(currency: currency).url)
}
// MARK: - Транспорт
private func get<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
try JSONDecoder().decode(type, from: await data(from: url))
}
private func data(from url: URL) async throws -> Data {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
Self.logger.error("HTTP \(http.statusCode) \(url.absoluteString, privacy: .public)")
throw APIError.badStatus(http.statusCode)
}
return data
}
private static func makeSession() -> URLSession {
// Виджет живёт секунды: URL-кэш не нужен (иконки кэшируются на диске),
// а короткие таймауты не дают выйти за бюджет времени расширения.
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 20
return URLSession(configuration: configuration)
}
}
// MARK: - Эндпоинты
private enum Endpoint {
case symbolThumbs
case history(symbol: String, resolutionMinutes: Int, fromMs: Int, toMs: Int)
case icon(currency: String)
/// Адреса собираются из констант force unwrap безопасен.
var url: URL {
switch self {
case .symbolThumbs:
URL(string: "https://api.rapira.net/market/symbol-thumb")!
case let .history(symbol, resolution, fromMs, toMs):
{
var components = URLComponents(string: "https://api.rapira.net/market/history")!
components.queryItems = [
URLQueryItem(name: "symbol", value: symbol),
URLQueryItem(name: "resolution", value: String(resolution)),
URLQueryItem(name: "from", value: String(fromMs)),
URLQueryItem(name: "to", value: String(toMs)),
]
return components.url!
}()
case let .icon(currency):
URL(string: "https://cdn.rapira.net/media/crypto/\(currency).svg")!
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import SwiftUI
// MARK: - Палитра
/// Фирменные цвета Rapira из дизайн-макета.
enum RapiraPalette {
// Рост
static let accent = Color(red: 0.251, green: 0.835, blue: 0.216) // #40D537
static let accentEdge = Color(red: 0.051, green: 0.157, blue: 0.082) // #0D2815
static let accentHalo = Color(red: 0.075, green: 0.214, blue: 0.106) // #13371B
static let accentText = Color(red: 0.277, green: 0.895, blue: 0.239) // #47E43D
// Падение зеркальная красная гамма.
static let negative = Color(red: 0.949, green: 0.267, blue: 0.267) // #F24444
static let negativeEdge = Color(red: 0.161, green: 0.051, blue: 0.051)
static let negativeHalo = Color(red: 0.220, green: 0.075, blue: 0.075)
static let negativeText = Color(red: 0.965, green: 0.310, blue: 0.290)
// Фон и разделители
static let backgroundTint = Color(red: 0.157, green: 0.161, blue: 0.169) // #28292B
static let divider = Color.white.opacity(0.06)
// MARK: Выбор цвета по направлению движения
static func line(_ positive: Bool) -> Color { positive ? accent : negative }
static func lineEdge(_ positive: Bool) -> Color { positive ? accentEdge : negativeEdge }
static func halo(_ positive: Bool) -> Color { positive ? accentHalo : negativeHalo }
static func stat(_ positive: Bool) -> Color { positive ? accentText : negativeText }
}
// MARK: - Фон
/// Радиальный фон из макета: широкая подсветка от верхней кромки, уходящая
/// в чёрный на 61%. В исходнике это эллипс с радиусами 995×512 отсюда
/// растяжение по горизонтали.
struct RapiraBackground: View {
var body: some View {
GeometryReader { geo in
RadialGradient(
gradient: Gradient(stops: [
.init(color: RapiraPalette.backgroundTint, location: 0),
.init(color: .black, location: 0.612),
]),
center: UnitPoint(x: 0.5, y: 0),
startRadius: 0,
endRadius: geo.size.height
)
.frame(width: geo.size.width, height: geo.size.height)
.scaleEffect(x: 1.94, y: 1, anchor: .top)
.clipped()
}
}
}
// MARK: - Логотип
/// Знак Rapira две «ленты», кривые сняты с фирменного SVG
/// (34.7 × 32 в исходных координатах).
struct RapiraMark: View {
var size: CGFloat = 22
var body: some View {
ZStack {
MarkShape(part: .upper).fill(.white)
MarkShape(part: .lower).fill(.white.opacity(0.6))
}
.frame(width: size * (34.736 / 32.0), height: size)
}
private struct MarkShape: Shape {
enum Part { case upper, lower }
let part: Part
func path(in rect: CGRect) -> Path {
let sx = rect.width / 34.736
let sy = rect.height / 32.0
func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: rect.minX + x * sx, y: rect.minY + y * sy)
}
var path = Path()
switch part {
case .upper:
path.move(to: p(15.545, 0))
path.addCurve(to: p(7.406, 6.217), control1: p(12.037, 0), control2: p(8.393, 2.783))
path.addLine(to: p(0, 31.996))
path.addLine(to: p(6.942, 31.996))
path.addLine(to: p(12.094, 14.062))
path.addCurve(to: p(20.233, 7.845), control1: p(13.081, 10.629), control2: p(16.725, 7.845))
path.addLine(to: p(32.482, 7.845))
path.addLine(to: p(34.736, 0))
case .lower:
path.move(to: p(31.083, 12.803))
path.addLine(to: p(27.464, 25.713))
path.addCurve(to: p(19.432, 32.0), control1: p(26.490, 29.186), control2: p(22.895, 32.0))
path.addLine(to: p(11.886, 32.0))
path.addLine(to: p(15.506, 19.090))
path.addCurve(to: p(23.537, 12.803), control1: p(16.479, 15.618), control2: p(20.075, 12.803))
path.addLine(to: p(31.083, 12.803))
}
path.closeSubpath()
return path
}
}
}
// MARK: - Цвета монет
/// Фолбэк-цвета для иконок монет, когда SVG с CDN недоступен.
enum CoinPalette {
static func color(for currency: String) -> Color {
switch currency {
case "BTC": .orange
case "ETH": Color(red: 0.39, green: 0.49, blue: 0.94)
case "SOL": Color(red: 0.56, green: 0.25, blue: 0.95)
case "BNB": Color(red: 0.96, green: 0.72, blue: 0.04)
case "XRP": Color(red: 0.14, green: 0.14, blue: 0.14)
case "TON": Color(red: 0.04, green: 0.65, blue: 0.93)
case "USDT": Color(red: 0.149, green: 0.631, blue: 0.482)
default: Color(red: 0.55, green: 0.55, blue: 0.60)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
/// Сводка по торговой паре из `GET /market/symbol-thumb`.
///
/// Нюанс нейминга: API Rapira меняет местами base и quote относительно
/// привычной терминологии для пары "BTC/USDT" `quoteCurrency == "BTC"`
/// (монета), а `baseCurrency == "USDT"` (валюта, в которой выражена цена).
struct SymbolThumb: Codable, Identifiable, Sendable {
var id: String { symbol }
let symbol: String
let open: Double
let high: Double
let low: Double
let close: Double
/// Относительное изменение за 24ч (0.0234 == +2.34%).
let chg: Double
/// Абсолютное изменение за 24ч в валюте цены.
let change: Double
let volume: Double
let turnover: Double
let quoteCurrencyName: String
let baseCurrency: String
let quoteCurrency: String
}
// MARK: - Представление
extension SymbolThumb {
var isPositive: Bool { chg >= 0 }
/// «70 324,00»
var priceFormatted: String { MarketFormatting.price(close) }
/// «+2.34%»
var changePercent: String { MarketFormatting.percent(chg, signed: true) }
/// «2.34%» без знака, когда знак уже несёт соседнее значение.
var changePercentPlain: String { MarketFormatting.percent(abs(chg), signed: false) }
/// «+1 676,00 USDT»
var changeFormatted: String { "\(MarketFormatting.signedAmount(change)) \(baseSymbol)" }
/// Символ валюты цены: вместо RUB и т.п.
var baseSymbol: String {
switch baseCurrency {
case "RUB": ""
case "USD": "$"
case "EUR": ""
default: baseCurrency
}
}
}
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+144
View File
@@ -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])
}
}
}
+19 -354
View File
@@ -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
}
+46
View File
@@ -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)")
}
}
}
+44
View File
@@ -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
}
}
+135
View File
@@ -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())] }
}
}
+131
View File
@@ -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)
}
}
}
+138
View File
@@ -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Проверьте соединение"
}
}
}
+147
View File
@@ -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
}
}
@@ -68,6 +68,11 @@
path = crypto;
sourceTree = "<group>";
};
48E90CE02F73510000D6ABD4 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -94,6 +99,7 @@
isa = PBXGroup;
children = (
48E90CDE2F734FB600D6ABD4 /* cryptoExtension.entitlements */,
48E90CE02F73510000D6ABD4 /* Shared */,
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
48E90CCC2F734F8800D6ABD4 /* crypto */,
48E90CC72F734F8800D6ABD4 /* Frameworks */,
@@ -138,6 +144,7 @@
);
fileSystemSynchronizedGroups = (
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
48E90CE02F73510000D6ABD4 /* Shared */,
);
name = "rapira-market-widget";
packageProductDependencies = (
@@ -160,6 +167,7 @@
);
fileSystemSynchronizedGroups = (
48E90CCC2F734F8800D6ABD4 /* crypto */,
48E90CE02F73510000D6ABD4 /* Shared */,
);
name = cryptoExtension;
packageProductDependencies = (
@@ -7,12 +7,12 @@
<key>cryptoExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>1</integer>
</dict>
<key>rapira-market-widget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>0</integer>
</dict>
</dict>
</dict>
@@ -1,6 +1,15 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x37",
"green" : "0xD5",
"red" : "0x40"
}
},
"idiom" : "universal"
}
],
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,206 +0,0 @@
import SwiftUI
import WidgetKit
private let appGreen = Color(red: 0.20, green: 0.78, blue: 0.35)
private let appRed = Color(red: 1.0, green: 0.23, blue: 0.19)
// MARK: - Settings View
struct CryptoWidgetSettingsView: View {
@State private var allSymbols: [SymbolThumb] = []
@State private var selectedPairs: Set<String> = []
@State private var searchText = ""
@State private var isLoading = false
@State private var saved = false
private let maxSelection = 6
private var filtered: [SymbolThumb] {
if searchText.isEmpty { return allSymbols }
let q = searchText.lowercased()
return allSymbols.filter {
$0.symbol.lowercased().contains(q) ||
$0.quoteCurrencyName.lowercased().contains(q)
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Rapira Crypto")
.font(.system(size: 16, weight: .bold))
Text("\(selectedPairs.count)/\(maxSelection)")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.quaternary, in: Capsule())
Spacer()
Button {
savePairs()
} label: {
HStack(spacing: 4) {
if saved {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
Text("Сохранено")
} else {
Text("Сохранить")
}
}
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(.borderedProminent)
.tint(saved ? .green : .accentColor)
.controlSize(.small)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
// Search
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.tertiary)
.font(.system(size: 12))
TextField("Поиск пары...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 13))
}
.padding(8)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.padding(.horizontal, 16)
.padding(.bottom, 8)
Divider()
// List
if isLoading {
Spacer()
ProgressView()
.scaleEffect(0.8)
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(filtered) { sym in
AppPairRow(
symbol: sym,
isSelected: selectedPairs.contains(sym.symbol),
canSelect: selectedPairs.count < maxSelection || selectedPairs.contains(sym.symbol)
) {
togglePair(sym.symbol)
}
Divider().padding(.leading, 56).opacity(0.4)
}
}
.padding(.vertical, 4)
}
}
}
.frame(width: 420, height: 560)
.background(Color(.windowBackgroundColor))
.onChange(of: selectedPairs) { _, _ in
saved = false
}
.task {
loadSavedPairs()
await loadData()
}
}
// MARK: - Actions
private func togglePair(_ pair: String) {
if selectedPairs.contains(pair) {
selectedPairs.remove(pair)
} else if selectedPairs.count < maxSelection {
selectedPairs.insert(pair)
}
}
private func savePairs() {
let defaults = UserDefaults(suiteName: "group.robonen.rapira-market-widget.crypto")
defaults?.set(Array(selectedPairs), forKey: "selectedPairs")
WidgetCenter.shared.reloadAllTimelines()
withAnimation(.easeInOut(duration: 0.2)) { saved = true }
}
private func loadSavedPairs() {
let defaults = UserDefaults(suiteName: "group.robonen.rapira-market-widget.crypto")
if let s = defaults?.stringArray(forKey: "selectedPairs") {
selectedPairs = Set(s)
} else {
selectedPairs = ["BTC/USDT", "ETH/USDT", "SOL/USDT"]
}
}
private func loadData() async {
isLoading = true
defer { isLoading = false }
guard let data = try? await RapiraAPI.fetch() else { return }
allSymbols = data
.filter { $0.baseCurrency == "USDT" }
.sorted { $0.turnover > $1.turnover }
}
}
// MARK: - Pair Row
private struct AppPairRow: View {
let symbol: SymbolThumb
let isSelected: Bool
let canSelect: Bool
let onToggle: () -> Void
var body: some View {
Button(action: onToggle) {
HStack(spacing: 10) {
// Selection dot
Circle()
.fill(isSelected ? Color.accentColor : Color.clear)
.frame(width: 8, height: 8)
.overlay(Circle().strokeBorder(isSelected ? Color.clear : Color.secondary.opacity(0.3), lineWidth: 1))
CoinIcon(symbol: symbol.quoteCurrency, size: 30)
VStack(alignment: .leading, spacing: 1) {
Text(symbol.quoteCurrency)
.font(.system(size: 13, weight: .semibold))
Text(symbol.quoteCurrencyName)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 4)
Text(symbol.priceFormatted)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(symbol.changePercent)
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(symbol.isPositive ? appGreen : appRed, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
}
.padding(.horizontal, 16)
.padding(.vertical, 7)
.contentShape(Rectangle())
.opacity((!canSelect && !isSelected) ? 0.35 : 1.0)
}
.buttonStyle(.plain)
.disabled(!canSelect && !isSelected)
}
}
// MARK: - Preview
#Preview {
CryptoWidgetSettingsView()
}
@@ -4,7 +4,7 @@ import SwiftUI
struct RapiraMarketWidgetApp: App {
var body: some Scene {
Window("Rapira Crypto", id: "main") {
CryptoWidgetSettingsView()
SettingsView()
}
.windowResizability(.contentSize)
}
+79
View File
@@ -0,0 +1,79 @@
import Foundation
import Observation
import WidgetKit
/// Состояние окна настроек: загрузка списка пар, выбор и сохранение.
@MainActor
@Observable
final class SettingsModel {
enum Phase: Equatable {
case loading
case loaded
case failed
}
private(set) var phase: Phase = .loading
private(set) var symbols: [SymbolThumb] = []
/// Выбор с сохранением порядка: первая пара «герой» виджета.
private(set) var selection: [String]
var searchText = ""
private(set) var isSaved = false
private let api = RapiraAPI.shared
private let store = PairSelectionStore.shared
init() {
selection = PairSelectionStore.shared.load()
}
// MARK: - Данные
var maxSelection: Int { PairSelectionStore.maxSelection }
var filtered: [SymbolThumb] {
guard !searchText.isEmpty else { return symbols }
let query = searchText.lowercased()
return symbols.filter {
$0.symbol.lowercased().contains(query) ||
$0.quoteCurrencyName.lowercased().contains(query)
}
}
func load() async {
phase = .loading
do {
symbols = try await api.symbolThumbs().sorted { $0.turnover > $1.turnover }
phase = .loaded
} catch {
phase = .failed
}
}
// MARK: - Выбор
func isSelected(_ symbol: String) -> Bool {
selection.contains(symbol)
}
/// Снять выбор можно всегда, добавить пока есть свободные слоты.
func canToggle(_ symbol: String) -> Bool {
isSelected(symbol) || selection.count < maxSelection
}
func toggle(_ symbol: String) {
if let index = selection.firstIndex(of: symbol) {
selection.remove(at: index)
} else if selection.count < maxSelection {
selection.append(symbol)
} else {
return
}
isSaved = false
}
func save() {
store.save(selection)
WidgetCenter.shared.reloadAllTimelines()
isSaved = true
}
}
+187
View File
@@ -0,0 +1,187 @@
import SwiftUI
/// Окно выбора пар для виджета.
struct SettingsView: View {
@State private var model = SettingsModel()
var body: some View {
VStack(spacing: 0) {
header
searchField
Divider()
content
}
.frame(width: 420, height: 560)
.background(Color(.windowBackgroundColor))
.task { await model.load() }
}
// MARK: - Шапка
private var header: some View {
HStack {
Text("Rapira Crypto")
.font(.system(size: 16, weight: .bold))
Text("\(model.selection.count)/\(model.maxSelection)")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.quaternary, in: Capsule())
Spacer()
saveButton
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var saveButton: some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
model.save()
}
} label: {
HStack(spacing: 4) {
if model.isSaved {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
Text("Сохранено")
} else {
Text("Сохранить")
}
}
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(.borderedProminent)
.tint(model.isSaved ? .green : .accentColor)
.controlSize(.small)
}
// MARK: - Поиск
private var searchField: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.tertiary)
.font(.system(size: 12))
TextField("Поиск пары...", text: $model.searchText)
.textFieldStyle(.plain)
.font(.system(size: 13))
}
.padding(8)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
// MARK: - Список
@ViewBuilder
private var content: some View {
switch model.phase {
case .loading:
Spacer()
ProgressView()
.scaleEffect(0.8)
Spacer()
case .failed:
Spacer()
VStack(spacing: 10) {
Text("Не удалось загрузить список пар")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Button("Повторить") {
Task { await model.load() }
}
.controlSize(.small)
}
Spacer()
case .loaded:
ScrollView {
LazyVStack(spacing: 0) {
ForEach(model.filtered) { thumb in
PairRow(
thumb: thumb,
isSelected: model.isSelected(thumb.symbol),
isEnabled: model.canToggle(thumb.symbol)
) {
model.toggle(thumb.symbol)
}
Divider().padding(.leading, 56).opacity(0.4)
}
}
.padding(.vertical, 4)
}
}
}
}
// MARK: - Строка пары
private struct PairRow: View {
let thumb: SymbolThumb
let isSelected: Bool
let isEnabled: Bool
let onToggle: () -> Void
var body: some View {
Button(action: onToggle) {
HStack(spacing: 10) {
Circle()
.fill(isSelected ? Color.accentColor : Color.clear)
.frame(width: 8, height: 8)
.overlay(
Circle().strokeBorder(
isSelected ? Color.clear : Color.secondary.opacity(0.3),
lineWidth: 1
)
)
RemoteCoinIcon(currency: thumb.quoteCurrency, size: 30)
VStack(alignment: .leading, spacing: 1) {
Text(thumb.symbol)
.font(.system(size: 13, weight: .semibold))
Text(thumb.quoteCurrencyName)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 4)
Text(thumb.priceFormatted)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(thumb.changePercent)
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
thumb.isPositive ? RapiraPalette.accent : RapiraPalette.negative,
in: RoundedRectangle(cornerRadius: 5, style: .continuous)
)
}
.padding(.horizontal, 16)
.padding(.vertical, 7)
.contentShape(Rectangle())
.opacity(isEnabled || isSelected ? 1.0 : 0.35)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
}
}
// MARK: - Превью
#Preview {
SettingsView()
}
-95
View File
@@ -1,95 +0,0 @@
import SwiftUI
// MARK: - Models (shared with widget extension)
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)
}
}
// MARK: - Coin Icon (App async CDN loading)
struct CoinIcon: View {
let symbol: String
var size: CGFloat = 32
@State private var image: NSImage?
var body: some View {
Group {
if let image {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(Circle())
} else {
ZStack {
Circle().fill(fallbackColor.opacity(0.15))
Text(String(symbol.prefix(1)))
.font(.system(size: size * 0.4, weight: .bold, design: .rounded))
.foregroundStyle(fallbackColor)
}
}
}
.frame(width: size, height: size)
.task(id: symbol) {
guard let url = URL(string: "https://cdn.rapira.net/media/crypto/\(symbol).svg") else { return }
guard let (data, _) = try? await URLSession.shared.data(from: url),
let img = NSImage(data: data) else { return }
image = img
}
}
private var fallbackColor: Color {
switch symbol {
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 "TON": return Color(red: 0.04, green: 0.65, blue: 0.93)
default: return Color(red: 0.55, green: 0.55, blue: 0.60)
}
}
}