feat: v2
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_16.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_64.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_128.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"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 WidgetKit
|
||||||
import SwiftUI
|
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
|
@main
|
||||||
struct CryptoWidget: Widget {
|
struct CryptoWidget: Widget {
|
||||||
let kind = "CryptoWidget"
|
/// Идентификатор вида — менять нельзя, иначе слетят установленные виджеты.
|
||||||
|
static let kind = "CryptoWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CryptoProvider()) { entry in
|
StaticConfiguration(kind: Self.kind, provider: CryptoProvider()) { entry in
|
||||||
CryptoWidgetEntryView(entry: entry)
|
CryptoWidgetEntryView(entry: entry)
|
||||||
}
|
}
|
||||||
.configurationDisplayName("Rapira Crypto")
|
.configurationDisplayName("Rapira Crypto")
|
||||||
.description("Курсы криптовалют в реальном времени")
|
.description("Курсы криптовалют в реальном времени")
|
||||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
.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) {
|
#Preview(as: .systemLarge) {
|
||||||
CryptoWidget()
|
CryptoWidget()
|
||||||
} timeline: {
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@
|
|||||||
path = crypto;
|
path = crypto;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
48E90CE02F73510000D6ABD4 /* Shared */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -94,6 +99,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
48E90CDE2F734FB600D6ABD4 /* cryptoExtension.entitlements */,
|
48E90CDE2F734FB600D6ABD4 /* cryptoExtension.entitlements */,
|
||||||
|
48E90CE02F73510000D6ABD4 /* Shared */,
|
||||||
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
|
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
|
||||||
48E90CCC2F734F8800D6ABD4 /* crypto */,
|
48E90CCC2F734F8800D6ABD4 /* crypto */,
|
||||||
48E90CC72F734F8800D6ABD4 /* Frameworks */,
|
48E90CC72F734F8800D6ABD4 /* Frameworks */,
|
||||||
@@ -138,6 +144,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
|
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
|
||||||
|
48E90CE02F73510000D6ABD4 /* Shared */,
|
||||||
);
|
);
|
||||||
name = "rapira-market-widget";
|
name = "rapira-market-widget";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -160,6 +167,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
48E90CCC2F734F8800D6ABD4 /* crypto */,
|
48E90CCC2F734F8800D6ABD4 /* crypto */,
|
||||||
|
48E90CE02F73510000D6ABD4 /* Shared */,
|
||||||
);
|
);
|
||||||
name = cryptoExtension;
|
name = cryptoExtension;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
<key>cryptoExtension.xcscheme_^#shared#^_</key>
|
<key>cryptoExtension.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>rapira-market-widget.xcscheme_^#shared#^_</key>
|
<key>rapira-market-widget.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"colors" : [
|
"colors" : [
|
||||||
{
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x37",
|
||||||
|
"green" : "0xD5",
|
||||||
|
"red" : "0x40"
|
||||||
|
}
|
||||||
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,51 +1,61 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_16.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_64.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_128.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"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 |
@@ -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 {
|
struct RapiraMarketWidgetApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
Window("Rapira Crypto", id: "main") {
|
Window("Rapira Crypto", id: "main") {
|
||||||
CryptoWidgetSettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||