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" : [
|
||||
{
|
||||
"filename" : "icon_16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
||||
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 630 B |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,144 @@
|
||||
import WidgetKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Модель отображения
|
||||
|
||||
/// Готовая к отрисовке пара: рыночные данные + график + иконка.
|
||||
struct Quote: Identifiable, Sendable {
|
||||
let thumb: SymbolThumb
|
||||
/// Цены за 24ч; последняя точка — текущая цена, чтобы график был «живым».
|
||||
/// Пусто, если история недоступна.
|
||||
let series: [Double]
|
||||
let icon: Data?
|
||||
|
||||
var id: String { thumb.id }
|
||||
}
|
||||
|
||||
// MARK: - Entry
|
||||
|
||||
struct CryptoEntry: TimelineEntry, Sendable {
|
||||
let date: Date
|
||||
/// Пары в порядке, выбранном пользователем.
|
||||
let quotes: [Quote]
|
||||
/// true — не удалось получить данные ни из сети, ни из кэша.
|
||||
var dataUnavailable = false
|
||||
}
|
||||
|
||||
// MARK: - Провайдер
|
||||
|
||||
struct CryptoProvider: TimelineProvider {
|
||||
private let api = RapiraAPI.shared
|
||||
private let store = PairSelectionStore.shared
|
||||
private let cache = MarketSnapshotCache()
|
||||
|
||||
private static let logger = Logger(subsystem: AppGroup.id, category: "provider")
|
||||
|
||||
/// Плановое обновление и ускоренный повтор после ошибки.
|
||||
private static let refreshInterval: TimeInterval = 5 * 60
|
||||
private static let retryInterval: TimeInterval = 2 * 60
|
||||
|
||||
// MARK: TimelineProvider
|
||||
|
||||
func placeholder(in context: Context) -> CryptoEntry {
|
||||
PreviewData.entry
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (CryptoEntry) -> Void) {
|
||||
// Галерея виджетов ждать сеть не должна.
|
||||
guard !context.isPreview else {
|
||||
completion(PreviewData.entry)
|
||||
return
|
||||
}
|
||||
Task { completion(await makeEntry().entry) }
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<CryptoEntry>) -> Void) {
|
||||
Task {
|
||||
let (entry, isLive) = await makeEntry()
|
||||
let interval = isLive ? Self.refreshInterval : Self.retryInterval
|
||||
let timeline = Timeline(
|
||||
entries: [entry],
|
||||
policy: .after(Date.now.addingTimeInterval(interval))
|
||||
)
|
||||
completion(timeline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Сборка entry
|
||||
|
||||
/// Живые данные → при ошибке последний удачный снимок → пустое состояние.
|
||||
private func makeEntry() async -> (entry: CryptoEntry, isLive: Bool) {
|
||||
let pairs = store.load()
|
||||
|
||||
do {
|
||||
let thumbs = try await api.symbolThumbs()
|
||||
let selected = Self.select(pairs, from: thumbs)
|
||||
async let histories = loadHistories(for: selected.map(\.symbol))
|
||||
async let icons = IconStore.shared.icons(for: selected.map(\.quoteCurrency))
|
||||
let quotes = Self.makeQuotes(selected, histories: await histories, icons: await icons)
|
||||
|
||||
cache.save(MarketSnapshot(date: .now, thumbs: thumbs, histories: await histories))
|
||||
return (CryptoEntry(date: .now, quotes: quotes), true)
|
||||
} catch {
|
||||
Self.logger.error("Рынок недоступен: \(String(describing: error), privacy: .public)")
|
||||
return (await fallbackEntry(pairs: pairs), false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry из дискового кэша; date — честное время тех данных.
|
||||
private func fallbackEntry(pairs: [String]) async -> CryptoEntry {
|
||||
guard let snapshot = cache.load() else {
|
||||
return CryptoEntry(date: .now, quotes: [], dataUnavailable: true)
|
||||
}
|
||||
let selected = Self.select(pairs, from: snapshot.thumbs)
|
||||
let icons = await IconStore.shared.icons(for: selected.map(\.quoteCurrency))
|
||||
let quotes = Self.makeQuotes(selected, histories: snapshot.histories, icons: icons)
|
||||
return CryptoEntry(date: snapshot.date, quotes: quotes)
|
||||
}
|
||||
|
||||
/// История по каждой паре; сбой одной пары не валит остальные —
|
||||
/// пара просто остаётся без графика.
|
||||
private func loadHistories(for symbols: [String]) async -> [String: [Double]] {
|
||||
await withTaskGroup(of: (String, [Double]).self) { group in
|
||||
for symbol in symbols {
|
||||
group.addTask {
|
||||
do {
|
||||
return (symbol, try await api.closes(for: symbol))
|
||||
} catch {
|
||||
Self.logger.notice(
|
||||
"Нет истории \(symbol, privacy: .public): \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
return (symbol, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result: [String: [Double]] = [:]
|
||||
for await (symbol, closes) in group where closes.count > 1 {
|
||||
result[symbol] = closes
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// Отбирает выбранные пары, сохраняя порядок выбора.
|
||||
private static func select(_ pairs: [String], from thumbs: [SymbolThumb]) -> [SymbolThumb] {
|
||||
let bySymbol = Dictionary(thumbs.map { ($0.symbol, $0) }) { first, _ in first }
|
||||
return pairs.compactMap { bySymbol[$0] }
|
||||
}
|
||||
|
||||
private static func makeQuotes(
|
||||
_ thumbs: [SymbolThumb],
|
||||
histories: [String: [Double]],
|
||||
icons: [String: Data]
|
||||
) -> [Quote] {
|
||||
thumbs.map { thumb in
|
||||
var series = histories[thumb.symbol] ?? []
|
||||
if !series.isEmpty {
|
||||
series.append(thumb.close)
|
||||
}
|
||||
return Quote(thumb: thumb, series: series, icon: icons[thumb.quoteCurrency])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,373 +1,38 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct SymbolThumb: Codable, Identifiable, Sendable {
|
||||
var id: String { symbol }
|
||||
let symbol: String
|
||||
let open: Double
|
||||
let high: Double
|
||||
let low: Double
|
||||
let close: Double
|
||||
let chg: Double
|
||||
let change: Double
|
||||
let volume: Double
|
||||
let turnover: Double
|
||||
let quoteCurrencyName: String
|
||||
let baseCurrency: String
|
||||
let quoteCurrency: String
|
||||
|
||||
var isPositive: Bool { chg >= 0 }
|
||||
|
||||
var changePercent: String {
|
||||
String(format: "%+.2f%%", chg * 100)
|
||||
}
|
||||
|
||||
var priceFormatted: String {
|
||||
let fmt = NumberFormatter()
|
||||
fmt.numberStyle = .decimal
|
||||
fmt.minimumFractionDigits = 2
|
||||
if close >= 1000 {
|
||||
fmt.maximumFractionDigits = 2
|
||||
} else if close >= 1 {
|
||||
fmt.maximumFractionDigits = 4
|
||||
} else {
|
||||
fmt.maximumFractionDigits = 6
|
||||
}
|
||||
return fmt.string(from: NSNumber(value: close)) ?? String(format: "%.2f", close)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
struct RapiraAPI {
|
||||
static let url = URL(string: "https://api.rapira.net/market/symbol-thumb")!
|
||||
|
||||
static func fetch() async throws -> [SymbolThumb] {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
return try JSONDecoder().decode([SymbolThumb].self, from: data)
|
||||
}
|
||||
|
||||
static func fetchIcon(for currency: String) async -> Data? {
|
||||
guard let url = URL(string: "https://cdn.rapira.net/media/crypto/\(currency).svg") else { return nil }
|
||||
return try? await URLSession.shared.data(from: url).0
|
||||
}
|
||||
|
||||
static func fetchIcons(for currencies: [String]) async -> [String: Data] {
|
||||
await withTaskGroup(of: (String, Data?).self) { group in
|
||||
for cur in Set(currencies) {
|
||||
group.addTask { (cur, await fetchIcon(for: cur)) }
|
||||
}
|
||||
var result: [String: Data] = [:]
|
||||
for await (cur, data) in group {
|
||||
if let data { result[cur] = data }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry
|
||||
|
||||
struct CryptoEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let symbols: [SymbolThumb]
|
||||
let selectedPairs: [String]
|
||||
let icons: [String: Data]
|
||||
|
||||
var filtered: [SymbolThumb] {
|
||||
let order = Dictionary(uniqueKeysWithValues: selectedPairs.enumerated().map { ($1, $0) })
|
||||
return symbols
|
||||
.filter { selectedPairs.contains($0.symbol) }
|
||||
.sorted { (order[$0.symbol] ?? 99) < (order[$1.symbol] ?? 99) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider
|
||||
|
||||
struct CryptoProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> CryptoEntry {
|
||||
CryptoEntry(date: .now, symbols: Self.mock, selectedPairs: ["BTC/USDT", "ETH/USDT", "SOL/USDT"], icons: [:])
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (CryptoEntry) -> Void) {
|
||||
Task {
|
||||
let symbols = (try? await RapiraAPI.fetch()) ?? Self.mock
|
||||
let pairs = loadPairs()
|
||||
let filtered = symbols.filter { pairs.contains($0.symbol) }
|
||||
let icons = await RapiraAPI.fetchIcons(for: filtered.map(\.quoteCurrency))
|
||||
completion(CryptoEntry(date: .now, symbols: symbols, selectedPairs: pairs, icons: icons))
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<CryptoEntry>) -> Void) {
|
||||
Task {
|
||||
let symbols = (try? await RapiraAPI.fetch()) ?? Self.mock
|
||||
let pairs = loadPairs()
|
||||
let filtered = symbols.filter { pairs.contains($0.symbol) }
|
||||
let icons = await RapiraAPI.fetchIcons(for: filtered.map(\.quoteCurrency))
|
||||
let entry = CryptoEntry(date: .now, symbols: symbols, selectedPairs: pairs, icons: icons)
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 5, to: .now)!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPairs() -> [String] {
|
||||
UserDefaults(suiteName: "group.robonen.rapira-market-widget.crypto")?
|
||||
.stringArray(forKey: "selectedPairs") ?? ["BTC/USDT", "ETH/USDT", "SOL/USDT"]
|
||||
}
|
||||
|
||||
static let mock: [SymbolThumb] = [
|
||||
SymbolThumb(symbol: "BTC/USDT", open: 71000, high: 71381.5, low: 68894.5,
|
||||
close: 70324, chg: 0.0234, change: 1676, volume: 223.9,
|
||||
turnover: 41930552, quoteCurrencyName: "Bitcoin",
|
||||
baseCurrency: "USDT", quoteCurrency: "BTC"),
|
||||
SymbolThumb(symbol: "ETH/USDT", open: 2152.9, high: 2177.4, low: 2105,
|
||||
close: 2143.5, chg: -0.00446, change: -9.4, volume: 27226,
|
||||
turnover: 119347435, quoteCurrencyName: "Ethereum",
|
||||
baseCurrency: "USDT", quoteCurrency: "ETH"),
|
||||
SymbolThumb(symbol: "SOL/USDT", open: 91.77, high: 92.29, low: 88.3,
|
||||
close: 90.08, chg: -0.01913, change: -1.69, volume: 349968,
|
||||
turnover: 71024390, quoteCurrencyName: "Solana",
|
||||
baseCurrency: "USDT", quoteCurrency: "SOL"),
|
||||
SymbolThumb(symbol: "TON/USDT", open: 1.31, high: 1.35, low: 1.29,
|
||||
close: 1.3382, chg: 0.0223, change: 0.0282, volume: 5200000,
|
||||
turnover: 6958640, quoteCurrencyName: "Toncoin",
|
||||
baseCurrency: "USDT", quoteCurrency: "TON"),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Theme
|
||||
|
||||
private enum Theme {
|
||||
static let bgTop = Color(red: 0.08, green: 0.12, blue: 0.32)
|
||||
static let bgBottom = Color(red: 0.03, green: 0.05, blue: 0.18)
|
||||
static let label = Color(red: 0.45, green: 0.65, blue: 1.0)
|
||||
static let positive = Color(red: 0.05, green: 0.82, blue: 0.42)
|
||||
static let negative = Color(red: 1.0, green: 0.30, blue: 0.30)
|
||||
static let divider = Color.white.opacity(0.08)
|
||||
|
||||
static var bgGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [bgTop, bgBottom],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coin Icon (Widget — uses prefetched Data)
|
||||
|
||||
private struct WidgetCoinIcon: View {
|
||||
let symbol: String
|
||||
let data: Data?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
if let data, let img = NSImage(data: data) {
|
||||
Image(nsImage: img)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [coinColor(symbol), coinColor(symbol).opacity(0.7)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
Text(String(symbol.prefix(1)))
|
||||
.font(.system(size: size * 0.42, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func coinColor(_ s: String) -> Color {
|
||||
switch s {
|
||||
case "BTC": return .orange
|
||||
case "ETH": return Color(red: 0.39, green: 0.49, blue: 0.94)
|
||||
case "SOL": return Color(red: 0.56, green: 0.25, blue: 0.95)
|
||||
case "BNB": return Color(red: 0.96, green: 0.72, blue: 0.04)
|
||||
case "XRP": return Color(red: 0.14, green: 0.14, blue: 0.14)
|
||||
case "TON": return Color(red: 0.04, green: 0.65, blue: 0.93)
|
||||
default: return Color(red: 0.55, green: 0.55, blue: 0.60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crypto Row
|
||||
|
||||
private struct CryptoRow: View {
|
||||
let symbol: SymbolThumb
|
||||
let iconData: Data?
|
||||
let compact: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: compact ? 8 : 10) {
|
||||
WidgetCoinIcon(symbol: symbol.quoteCurrency, data: iconData, size: compact ? 26 : 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 2) {
|
||||
Text(symbol.quoteCurrency)
|
||||
.fontWeight(.bold)
|
||||
Text("/")
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
Text(symbol.baseCurrency)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
.font(.system(size: compact ? 12 : 13))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(symbol.quoteCurrencyName)
|
||||
.font(.system(size: compact ? 9 : 10))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text(symbol.priceFormatted)
|
||||
.font(.system(size: compact ? 13 : 14, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
|
||||
Text(symbol.changePercent)
|
||||
.font(.system(size: compact ? 10 : 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(symbol.isPositive ? Theme.positive : Theme.negative)
|
||||
.frame(minWidth: compact ? 48 : 54, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, compact ? 12 : 14)
|
||||
.padding(.vertical, compact ? 6 : 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Small Widget
|
||||
|
||||
struct SmallWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
var body: some View {
|
||||
if let pair = entry.filtered.first {
|
||||
VStack(spacing: 4) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
WidgetCoinIcon(symbol: pair.quoteCurrency, data: entry.icons[pair.quoteCurrency], size: 36)
|
||||
|
||||
HStack(spacing: 2) {
|
||||
Text(pair.quoteCurrency)
|
||||
.fontWeight(.bold)
|
||||
Text("/")
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
Text(pair.baseCurrency)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(pair.priceFormatted)
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(pair.changePercent)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(pair.isPositive ? Theme.positive : Theme.negative)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.containerBackground(for: .widget) { Theme.bgGradient }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Medium Widget
|
||||
|
||||
struct MediumWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
let items = Array(entry.filtered.prefix(3))
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, sym in
|
||||
CryptoRow(symbol: sym, iconData: entry.icons[sym.quoteCurrency], compact: true)
|
||||
if idx < items.count - 1 {
|
||||
Theme.divider.frame(height: 1).padding(.leading, 46)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.containerBackground(for: .widget) { Theme.bgGradient }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Large Widget
|
||||
|
||||
struct LargeWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
let items = Array(entry.filtered.prefix(6))
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, sym in
|
||||
CryptoRow(symbol: sym, iconData: entry.icons[sym.quoteCurrency], compact: false)
|
||||
if idx < items.count - 1 {
|
||||
Theme.divider.frame(height: 1).padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.containerBackground(for: .widget) { Theme.bgGradient }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry View
|
||||
|
||||
struct CryptoWidgetEntryView: View {
|
||||
var entry: CryptoEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemSmall: SmallWidgetView(entry: entry)
|
||||
case .systemMedium: MediumWidgetView(entry: entry)
|
||||
case .systemLarge: LargeWidgetView(entry: entry)
|
||||
default: MediumWidgetView(entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
@main
|
||||
struct CryptoWidget: Widget {
|
||||
let kind = "CryptoWidget"
|
||||
/// Идентификатор вида — менять нельзя, иначе слетят установленные виджеты.
|
||||
static let kind = "CryptoWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CryptoProvider()) { entry in
|
||||
StaticConfiguration(kind: Self.kind, provider: CryptoProvider()) { entry in
|
||||
CryptoWidgetEntryView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Rapira Crypto")
|
||||
.description("Курсы криптовалют в реальном времени")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
// MARK: - Превью
|
||||
|
||||
#Preview(as: .systemSmall) {
|
||||
CryptoWidget()
|
||||
} timeline: {
|
||||
PreviewData.entry
|
||||
}
|
||||
|
||||
#Preview(as: .systemMedium) {
|
||||
CryptoWidget()
|
||||
} timeline: {
|
||||
PreviewData.entry
|
||||
}
|
||||
|
||||
#Preview(as: .systemLarge) {
|
||||
CryptoWidget()
|
||||
} timeline: {
|
||||
CryptoEntry(date: .now, symbols: CryptoProvider.mock, selectedPairs: ["BTC/USDT", "ETH/USDT", "SOL/USDT", "TON/USDT"], icons: [:])
|
||||
}
|
||||
PreviewData.entry
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Последний удачный ответ API.
|
||||
struct MarketSnapshot: Codable, Sendable {
|
||||
let date: Date
|
||||
let thumbs: [SymbolThumb]
|
||||
let histories: [String: [Double]]
|
||||
}
|
||||
|
||||
/// Дисковый кэш снимка рынка в контейнере App Group.
|
||||
///
|
||||
/// Офлайн-фолбэк: без сети виджет показывает последние реальные котировки
|
||||
/// с честной меткой времени, а не фиктивные данные.
|
||||
struct MarketSnapshotCache: Sendable {
|
||||
private let fileURL: URL?
|
||||
|
||||
private static let logger = Logger(subsystem: AppGroup.id, category: "snapshot-cache")
|
||||
|
||||
init() {
|
||||
fileURL = AppGroup.cachesURL?.appendingPathComponent("market-snapshot.json")
|
||||
}
|
||||
|
||||
func load() -> MarketSnapshot? {
|
||||
guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
do {
|
||||
return try JSONDecoder().decode(MarketSnapshot.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("Кэш повреждён: \(String(describing: error), privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ snapshot: MarketSnapshot) {
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try JSONEncoder().encode(snapshot).write(to: fileURL, options: .atomic)
|
||||
} catch {
|
||||
Self.logger.error("Кэш не сохранён: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// Демо-данные для плейсхолдера, галереи виджетов и превью.
|
||||
/// Плейсхолдер нужен и в релизе, поэтому файл не под `#if DEBUG`.
|
||||
enum PreviewData {
|
||||
static var entry: CryptoEntry {
|
||||
CryptoEntry(
|
||||
date: .now,
|
||||
quotes: thumbs.map { thumb in
|
||||
Quote(thumb: thumb, series: series(for: thumb), icon: nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
static let thumbs: [SymbolThumb] = [
|
||||
SymbolThumb(symbol: "BTC/USDT", open: 71000, high: 71381.5, low: 68894.5,
|
||||
close: 70324, chg: 0.0234, change: 1676, volume: 223.9,
|
||||
turnover: 41_930_552, quoteCurrencyName: "Bitcoin",
|
||||
baseCurrency: "USDT", quoteCurrency: "BTC"),
|
||||
SymbolThumb(symbol: "ETH/USDT", open: 2152.9, high: 2177.4, low: 2105,
|
||||
close: 2143.5, chg: -0.00446, change: -9.4, volume: 27226,
|
||||
turnover: 119_347_435, quoteCurrencyName: "Ethereum",
|
||||
baseCurrency: "USDT", quoteCurrency: "ETH"),
|
||||
SymbolThumb(symbol: "SOL/USDT", open: 91.77, high: 92.29, low: 88.3,
|
||||
close: 90.08, chg: -0.01913, change: -1.69, volume: 349_968,
|
||||
turnover: 71_024_390, quoteCurrencyName: "Solana",
|
||||
baseCurrency: "USDT", quoteCurrency: "SOL"),
|
||||
SymbolThumb(symbol: "TON/USDT", open: 1.31, high: 1.35, low: 1.29,
|
||||
close: 1.3382, chg: 0.0223, change: 0.0282, volume: 5_200_000,
|
||||
turnover: 6_958_640, quoteCurrencyName: "Toncoin",
|
||||
baseCurrency: "USDT", quoteCurrency: "TON"),
|
||||
]
|
||||
|
||||
/// Синтетический ряд, похожий на дневной график.
|
||||
private static func series(for thumb: SymbolThumb, points: Int = 48) -> [Double] {
|
||||
var values = (0..<points).map { index -> Double in
|
||||
let t = Double(index) / Double(points - 1)
|
||||
let wave = sin(t * 7) * 0.4 + sin(t * 17 + 1.2) * 0.18 + sin(t * 3.3) * 0.5
|
||||
return thumb.open + (thumb.close - thumb.open) * t + wave * (thumb.high - thumb.low) * 0.35
|
||||
}
|
||||
values.append(thumb.close)
|
||||
return values
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Форма линии
|
||||
|
||||
/// Сглаженная ломаная по точкам; `closed` замыкает контур вниз для заливки.
|
||||
private struct SparkShape: Shape {
|
||||
let points: [CGPoint]
|
||||
let closed: Bool
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
guard points.count > 1 else { return path }
|
||||
|
||||
path.move(to: points[0])
|
||||
for index in 0..<(points.count - 1) {
|
||||
let current = points[index]
|
||||
let next = points[index + 1]
|
||||
let mid = CGPoint(x: (current.x + next.x) / 2, y: (current.y + next.y) / 2)
|
||||
if index == 0 {
|
||||
path.addLine(to: mid)
|
||||
} else {
|
||||
path.addQuadCurve(to: mid, control: current)
|
||||
}
|
||||
}
|
||||
path.addLine(to: points[points.count - 1])
|
||||
|
||||
if closed {
|
||||
path.addLine(to: CGPoint(x: points[points.count - 1].x, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: points[0].x, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Спарклайн
|
||||
|
||||
/// График в стиле макета: линия с горизонтальным градиентом (яркая точка
|
||||
/// на 77%), свечение-заливка под линией и «живая» точка на конце.
|
||||
struct Sparkline: View {
|
||||
let values: [Double]
|
||||
let positive: Bool
|
||||
var lineWidth: CGFloat = 2
|
||||
var showPulse: Bool = true
|
||||
var pulseSize: CGFloat = 16
|
||||
var areaOpacity: Double = 0.25
|
||||
/// Отступ справа в диаметрах точки: линия обрывается, не доходя до края.
|
||||
var trailingRoom: CGFloat = 1.4
|
||||
|
||||
/// Больше точек глазом уже не различить, а форма тяжелеет.
|
||||
private static let maxPoints = 96
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let points = points(in: geo.size)
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Заливка: вертикальный уход в прозрачность + горизонтальная
|
||||
// маска — свечение сосредоточено слева, как в макете.
|
||||
SparkShape(points: points, closed: true)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
RapiraPalette.line(positive).opacity(areaOpacity),
|
||||
RapiraPalette.line(positive).opacity(0),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
colors: [.white, .white.opacity(0.25)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
|
||||
// Гало точки лежит под линией, ядро — над ней.
|
||||
if showPulse, let last = points.last {
|
||||
Circle()
|
||||
.fill(RapiraPalette.halo(positive))
|
||||
.frame(width: pulseSize, height: pulseSize)
|
||||
.position(last)
|
||||
}
|
||||
|
||||
SparkShape(points: points, closed: false)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: RapiraPalette.lineEdge(positive), location: 0),
|
||||
.init(color: RapiraPalette.line(positive), location: 0.77),
|
||||
.init(color: RapiraPalette.lineEdge(positive), location: 1),
|
||||
]),
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
),
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
|
||||
if showPulse, let last = points.last {
|
||||
Circle()
|
||||
.fill(RapiraPalette.line(positive))
|
||||
.frame(width: pulseSize * 0.487, height: pulseSize * 0.487)
|
||||
.position(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Геометрия
|
||||
|
||||
private func points(in size: CGSize) -> [CGPoint] {
|
||||
let series = downsampled(values, limit: Self.maxPoints)
|
||||
guard series.count > 1, size.width > 0, size.height > 0 else { return [] }
|
||||
|
||||
let trailingInset = showPulse ? pulseSize * trailingRoom : lineWidth / 2
|
||||
let verticalPad = max(lineWidth, showPulse ? pulseSize / 2 : lineWidth)
|
||||
let width = max(size.width - trailingInset, 1)
|
||||
let height = max(size.height - verticalPad * 2, 1)
|
||||
|
||||
let low = series.min() ?? 0
|
||||
let high = series.max() ?? 1
|
||||
let span = high - low
|
||||
|
||||
return series.enumerated().map { index, value in
|
||||
let x = width * CGFloat(index) / CGFloat(series.count - 1)
|
||||
let normalized = span > 0 ? (value - low) / span : 0.5
|
||||
let y = verticalPad + height * (1 - CGFloat(normalized))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
}
|
||||
|
||||
private func downsampled(_ values: [Double], limit: Int) -> [Double] {
|
||||
guard values.count > limit else { return values }
|
||||
let step = Double(values.count - 1) / Double(limit - 1)
|
||||
return (0..<limit).map { values[Int((Double($0) * step).rounded())] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Карточка-герой
|
||||
|
||||
/// Одна пара крупно: заголовок, сквозной график, цена и изменение за 24ч.
|
||||
struct HeroCard: View {
|
||||
let quote: Quote
|
||||
let updatedAt: Date
|
||||
/// Горизонтальный отступ контейнера — график компенсирует его
|
||||
/// и уходит под края виджета, как в макете.
|
||||
let horizontalPadding: CGFloat
|
||||
let iconSize: CGFloat
|
||||
let titleSize: CGFloat
|
||||
let priceSize: CGFloat
|
||||
let chartHeight: CGFloat
|
||||
var showFullDate = true
|
||||
var showAbsolute = true
|
||||
|
||||
private var thumb: SymbolThumb { quote.thumb }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
PairHeader(thumb: thumb, icon: quote.icon, iconSize: iconSize, titleSize: titleSize)
|
||||
|
||||
UpdatedAt(date: updatedAt, fontSize: titleSize * 0.62, withDate: showFullDate)
|
||||
.padding(.top, titleSize * 0.45)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
chart
|
||||
.frame(height: chartHeight)
|
||||
.padding(.horizontal, -horizontalPadding)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
PriceLabel(thumb: thumb, size: priceSize)
|
||||
|
||||
StatsLine(thumb: thumb, fontSize: titleSize * 0.78, showAbsolute: showAbsolute)
|
||||
.padding(.top, titleSize * 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chart: some View {
|
||||
if quote.series.count > 1 {
|
||||
Sparkline(
|
||||
values: quote.series,
|
||||
positive: thumb.isPositive,
|
||||
lineWidth: 2,
|
||||
pulseSize: iconSize * 0.52
|
||||
)
|
||||
} else {
|
||||
// Нет истории — держим ритм макета ровной линией.
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
RapiraPalette.lineEdge(thumb.isPositive),
|
||||
RapiraPalette.line(thumb.isPositive).opacity(0.5),
|
||||
RapiraPalette.lineEdge(thumb.isPositive),
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(height: 1.5)
|
||||
.frame(maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Строка списка
|
||||
|
||||
/// Компактная строка: иконка, пара, мини-график, цена и процент.
|
||||
struct CryptoRow: View {
|
||||
let quote: Quote
|
||||
let compact: Bool
|
||||
|
||||
private var thumb: SymbolThumb { quote.thumb }
|
||||
private var iconSize: CGFloat { compact ? 24 : 26 }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
CoinIconView(currency: thumb.quoteCurrency, data: quote.icon, size: iconSize)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 1) {
|
||||
Text(thumb.quoteCurrency)
|
||||
.foregroundStyle(.white)
|
||||
Text("/\(thumb.baseCurrency)")
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
.font(.system(size: compact ? 12 : 13, weight: .bold))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(thumb.quoteCurrencyName)
|
||||
.font(.system(size: compact ? 9 : 10, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if quote.series.count > 1 {
|
||||
Sparkline(
|
||||
values: quote.series,
|
||||
positive: thumb.isPositive,
|
||||
lineWidth: 1.5,
|
||||
pulseSize: 7,
|
||||
areaOpacity: 0.16,
|
||||
trailingRoom: 0.6
|
||||
)
|
||||
.frame(width: compact ? 46 : 56, height: 22)
|
||||
}
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(thumb.priceFormatted)
|
||||
.font(.system(size: compact ? 12 : 13, weight: .bold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
|
||||
Text(thumb.changePercent)
|
||||
.font(.system(size: compact ? 10 : 10.5, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(RapiraPalette.stat(thumb.isPositive))
|
||||
}
|
||||
.frame(minWidth: compact ? 62 : 70, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Заголовок пары
|
||||
|
||||
/// Иконка монеты + «BTC/USDT» + знак Rapira справа.
|
||||
struct PairHeader: View {
|
||||
let thumb: SymbolThumb
|
||||
let icon: Data?
|
||||
let iconSize: CGFloat
|
||||
let titleSize: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: iconSize * 0.3) {
|
||||
CoinIconView(currency: thumb.quoteCurrency, data: icon, size: iconSize)
|
||||
|
||||
HStack(spacing: 1) {
|
||||
Text(thumb.quoteCurrency)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.white, .white.opacity(0.85)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
Text("/\(thumb.baseCurrency)")
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
.font(.system(size: titleSize, weight: .bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
RapiraMark(size: iconSize * 0.52)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Цена
|
||||
|
||||
/// Крупная цена с приглушённым символом валюты: «70 324,00 USDT».
|
||||
struct PriceLabel: View {
|
||||
let thumb: SymbolThumb
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: size * 0.16) {
|
||||
Text(thumb.priceFormatted)
|
||||
.font(.system(size: size, weight: .bold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.white, .white.opacity(0.7)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
|
||||
Text(thumb.baseSymbol)
|
||||
.font(.system(size: size * 0.42, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Изменение за 24ч
|
||||
|
||||
/// «+1 676,00 USDT · 2.34%» одной зелёной/красной строкой, без подложки.
|
||||
struct StatsLine: View {
|
||||
let thumb: SymbolThumb
|
||||
let fontSize: CGFloat
|
||||
var showAbsolute = true
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: fontSize * 0.4) {
|
||||
if showAbsolute {
|
||||
Text(thumb.changeFormatted)
|
||||
Text("·")
|
||||
.foregroundStyle(RapiraPalette.stat(thumb.isPositive).opacity(0.45))
|
||||
Text(thumb.changePercentPlain)
|
||||
} else {
|
||||
Text(thumb.changePercent)
|
||||
}
|
||||
}
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(RapiraPalette.stat(thumb.isPositive))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Время обновления
|
||||
|
||||
/// Подпись «27 октября 17:10» под парой — как в макете.
|
||||
struct UpdatedAt: View {
|
||||
let date: Date
|
||||
let fontSize: CGFloat
|
||||
var withDate = true
|
||||
|
||||
var body: some View {
|
||||
Text(MarketFormatting.updated(date, withDate: withDate))
|
||||
.font(.system(size: fontSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Пустое состояние
|
||||
|
||||
struct EmptyState: View {
|
||||
enum Reason {
|
||||
/// Ни одна выбранная пара не найдена в данных.
|
||||
case noSelection
|
||||
/// Ни сети, ни кэша.
|
||||
case noData
|
||||
}
|
||||
|
||||
let reason: Reason
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
RapiraMark(size: 26)
|
||||
Text(message)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch reason {
|
||||
case .noSelection: "Выберите пары\nв приложении"
|
||||
case .noData: "Нет данных.\nПроверьте соединение"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Маленький
|
||||
|
||||
/// Герой-карточка первой пары в сжатой компоновке.
|
||||
struct SmallWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
private let padding: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let hero = entry.quotes.first {
|
||||
HeroCard(
|
||||
quote: hero,
|
||||
updatedAt: entry.date,
|
||||
horizontalPadding: padding,
|
||||
iconSize: 24,
|
||||
titleSize: 13,
|
||||
priceSize: 21,
|
||||
chartHeight: 38,
|
||||
showFullDate: false,
|
||||
showAbsolute: false
|
||||
)
|
||||
.padding(padding)
|
||||
} else {
|
||||
EmptyState(reason: entry.emptyReason)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.containerBackground(for: .widget) { RapiraBackground() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Средний
|
||||
|
||||
/// Список до трёх пар со временем обновления в шапке.
|
||||
struct MediumWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.quotes.isEmpty {
|
||||
EmptyState(reason: entry.emptyReason)
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { RapiraBackground() }
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
UpdatedAt(date: entry.date, fontSize: 9.5)
|
||||
Spacer()
|
||||
RapiraMark(size: 13)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
|
||||
let quotes = Array(entry.quotes.prefix(3))
|
||||
ForEach(Array(quotes.enumerated()), id: \.element.id) { index, quote in
|
||||
CryptoRow(quote: quote, compact: true)
|
||||
.frame(maxHeight: .infinity)
|
||||
if index < quotes.count - 1 {
|
||||
RapiraPalette.divider.frame(height: 1).padding(.leading, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Большой
|
||||
|
||||
/// Герой первой пары + до четырёх остальных списком.
|
||||
struct LargeWidgetView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
private let padding: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let hero = entry.quotes.first {
|
||||
content(hero: hero, rest: Array(entry.quotes.dropFirst().prefix(4)))
|
||||
} else {
|
||||
EmptyState(reason: entry.emptyReason)
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { RapiraBackground() }
|
||||
}
|
||||
|
||||
private func content(hero: Quote, rest: [Quote]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HeroCard(
|
||||
quote: hero,
|
||||
updatedAt: entry.date,
|
||||
horizontalPadding: padding,
|
||||
iconSize: 36,
|
||||
titleSize: 19,
|
||||
priceSize: 32,
|
||||
chartHeight: 72
|
||||
)
|
||||
|
||||
if !rest.isEmpty {
|
||||
RapiraPalette.divider
|
||||
.frame(height: 1)
|
||||
.padding(.top, 14)
|
||||
.padding(.horizontal, -padding)
|
||||
|
||||
ForEach(Array(rest.enumerated()), id: \.element.id) { index, quote in
|
||||
CryptoRow(quote: quote, compact: false)
|
||||
.frame(maxHeight: .infinity)
|
||||
if index < rest.count - 1 {
|
||||
RapiraPalette.divider.frame(height: 1).padding(.leading, 34)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(padding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Диспетчер размеров
|
||||
|
||||
struct CryptoWidgetEntryView: View {
|
||||
let entry: CryptoEntry
|
||||
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemSmall: SmallWidgetView(entry: entry)
|
||||
case .systemMedium: MediumWidgetView(entry: entry)
|
||||
case .systemLarge: LargeWidgetView(entry: entry)
|
||||
default: MediumWidgetView(entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CryptoEntry {
|
||||
var emptyReason: EmptyState.Reason {
|
||||
dataUnavailable ? .noData : .noSelection
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,11 @@
|
||||
path = crypto;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
48E90CE02F73510000D6ABD4 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -94,6 +99,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
48E90CDE2F734FB600D6ABD4 /* cryptoExtension.entitlements */,
|
||||
48E90CE02F73510000D6ABD4 /* Shared */,
|
||||
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
|
||||
48E90CCC2F734F8800D6ABD4 /* crypto */,
|
||||
48E90CC72F734F8800D6ABD4 /* Frameworks */,
|
||||
@@ -138,6 +144,7 @@
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
|
||||
48E90CE02F73510000D6ABD4 /* Shared */,
|
||||
);
|
||||
name = "rapira-market-widget";
|
||||
packageProductDependencies = (
|
||||
@@ -160,6 +167,7 @@
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
48E90CCC2F734F8800D6ABD4 /* crypto */,
|
||||
48E90CE02F73510000D6ABD4 /* Shared */,
|
||||
);
|
||||
name = cryptoExtension;
|
||||
packageProductDependencies = (
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<key>cryptoExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>rapira-market-widget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x37",
|
||||
"green" : "0xD5",
|
||||
"red" : "0x40"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,51 +1,61 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
||||
|
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 {
|
||||
var body: some Scene {
|
||||
Window("Rapira Crypto", id: "main") {
|
||||
CryptoWidgetSettingsView()
|
||||
SettingsView()
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||