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