feat: v2
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user