diff --git a/Shared/AppGroup.swift b/Shared/AppGroup.swift new file mode 100644 index 0000000..c385436 --- /dev/null +++ b/Shared/AppGroup.swift @@ -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) + } +} diff --git a/Shared/CoinIconView.swift b/Shared/CoinIconView.swift new file mode 100644 index 0000000..6a37546 --- /dev/null +++ b/Shared/CoinIconView.swift @@ -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) + } + } +} diff --git a/Shared/IconStore.swift b/Shared/IconStore.swift new file mode 100644 index 0000000..3c48306 --- /dev/null +++ b/Shared/IconStore.swift @@ -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) 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 + } + } +} diff --git a/Shared/MarketFormatting.swift b/Shared/MarketFormatting.swift new file mode 100644 index 0000000..e4880bc --- /dev/null +++ b/Shared/MarketFormatting.swift @@ -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 + } +} diff --git a/Shared/RapiraAPI.swift b/Shared/RapiraAPI.swift new file mode 100644 index 0000000..d008bda --- /dev/null +++ b/Shared/RapiraAPI.swift @@ -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(_ 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")! + } + } +} diff --git a/Shared/RapiraBrand.swift b/Shared/RapiraBrand.swift new file mode 100644 index 0000000..856352e --- /dev/null +++ b/Shared/RapiraBrand.swift @@ -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) + } + } +} diff --git a/Shared/SymbolThumb.swift b/Shared/SymbolThumb.swift new file mode 100644 index 0000000..71bb5a8 --- /dev/null +++ b/Shared/SymbolThumb.swift @@ -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 + } + } +} diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/Contents.json b/crypto/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..c9ddaab 100644 --- a/crypto/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/crypto/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/crypto/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..fc072b6 Binary files /dev/null and b/crypto/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/icon_128.png b/crypto/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..b4694b3 Binary files /dev/null and b/crypto/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/icon_16.png b/crypto/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..684f484 Binary files /dev/null and b/crypto/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/icon_256.png b/crypto/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..a712e89 Binary files /dev/null and b/crypto/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/icon_32.png b/crypto/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..87f380b Binary files /dev/null and b/crypto/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/icon_512.png b/crypto/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..eca1989 Binary files /dev/null and b/crypto/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/crypto/Assets.xcassets/AppIcon.appiconset/icon_64.png b/crypto/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..6afa230 Binary files /dev/null and b/crypto/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/crypto/CryptoProvider.swift b/crypto/CryptoProvider.swift new file mode 100644 index 0000000..570e6a2 --- /dev/null +++ b/crypto/CryptoProvider.swift @@ -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) -> 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]) + } + } +} diff --git a/crypto/CryptoWidget.swift b/crypto/CryptoWidget.swift index 2bc5b52..1a450cb 100644 --- a/crypto/CryptoWidget.swift +++ b/crypto/CryptoWidget.swift @@ -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) -> 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: [:]) -} \ No newline at end of file + PreviewData.entry +} diff --git a/crypto/MarketSnapshotCache.swift b/crypto/MarketSnapshotCache.swift new file mode 100644 index 0000000..e512e6a --- /dev/null +++ b/crypto/MarketSnapshotCache.swift @@ -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)") + } + } +} diff --git a/crypto/PreviewData.swift b/crypto/PreviewData.swift new file mode 100644 index 0000000..073894f --- /dev/null +++ b/crypto/PreviewData.swift @@ -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.. 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 + } +} diff --git a/crypto/Sparkline.swift b/crypto/Sparkline.swift new file mode 100644 index 0000000..e99281a --- /dev/null +++ b/crypto/Sparkline.swift @@ -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.. 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) + } + } +} diff --git a/crypto/WidgetComponents.swift b/crypto/WidgetComponents.swift new file mode 100644 index 0000000..8f9d16b --- /dev/null +++ b/crypto/WidgetComponents.swift @@ -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Проверьте соединение" + } + } +} diff --git a/crypto/WidgetViews.swift b/crypto/WidgetViews.swift new file mode 100644 index 0000000..4fa9563 --- /dev/null +++ b/crypto/WidgetViews.swift @@ -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 + } +} diff --git a/rapira-market-widget.xcodeproj/project.pbxproj b/rapira-market-widget.xcodeproj/project.pbxproj index 07391c9..aa22ee4 100644 --- a/rapira-market-widget.xcodeproj/project.pbxproj +++ b/rapira-market-widget.xcodeproj/project.pbxproj @@ -68,6 +68,11 @@ path = crypto; sourceTree = ""; }; + 48E90CE02F73510000D6ABD4 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* 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 = ( diff --git a/rapira-market-widget.xcodeproj/xcuserdata/robonen.xcuserdatad/xcschemes/xcschememanagement.plist b/rapira-market-widget.xcodeproj/xcuserdata/robonen.xcuserdatad/xcschemes/xcschememanagement.plist index fbc5e61..5f36630 100644 --- a/rapira-market-widget.xcodeproj/xcuserdata/robonen.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/rapira-market-widget.xcodeproj/xcuserdata/robonen.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ cryptoExtension.xcscheme_^#shared#^_ orderHint - 0 + 1 rapira-market-widget.xcscheme_^#shared#^_ orderHint - 1 + 0 diff --git a/rapira-market-widget/Assets.xcassets/AccentColor.colorset/Contents.json b/rapira-market-widget/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..86ebf04 100644 --- a/rapira-market-widget/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/rapira-market-widget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,15 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x37", + "green" : "0xD5", + "red" : "0x40" + } + }, "idiom" : "universal" } ], diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/Contents.json b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..c9ddaab 100644 --- a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..fc072b6 Binary files /dev/null and b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_128.png b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..b4694b3 Binary files /dev/null and b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_16.png b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..684f484 Binary files /dev/null and b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_256.png b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..a712e89 Binary files /dev/null and b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_32.png b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..87f380b Binary files /dev/null and b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_512.png b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..eca1989 Binary files /dev/null and b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_64.png b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..6afa230 Binary files /dev/null and b/rapira-market-widget/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/rapira-market-widget/CryptoWidgetSettings.swift b/rapira-market-widget/CryptoWidgetSettings.swift deleted file mode 100644 index 09c49b7..0000000 --- a/rapira-market-widget/CryptoWidgetSettings.swift +++ /dev/null @@ -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 = [] - @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() -} \ No newline at end of file diff --git a/rapira-market-widget/RapiraMarketWidgetApp.swift b/rapira-market-widget/RapiraMarketWidgetApp.swift index 2bbccae..3fa6d2a 100644 --- a/rapira-market-widget/RapiraMarketWidgetApp.swift +++ b/rapira-market-widget/RapiraMarketWidgetApp.swift @@ -4,7 +4,7 @@ import SwiftUI struct RapiraMarketWidgetApp: App { var body: some Scene { Window("Rapira Crypto", id: "main") { - CryptoWidgetSettingsView() + SettingsView() } .windowResizability(.contentSize) } diff --git a/rapira-market-widget/SettingsModel.swift b/rapira-market-widget/SettingsModel.swift new file mode 100644 index 0000000..c4e2704 --- /dev/null +++ b/rapira-market-widget/SettingsModel.swift @@ -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 + } +} diff --git a/rapira-market-widget/SettingsView.swift b/rapira-market-widget/SettingsView.swift new file mode 100644 index 0000000..bdec5ab --- /dev/null +++ b/rapira-market-widget/SettingsView.swift @@ -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() +} diff --git a/rapira-market-widget/SharedModels.swift b/rapira-market-widget/SharedModels.swift deleted file mode 100644 index fe6c9ab..0000000 --- a/rapira-market-widget/SharedModels.swift +++ /dev/null @@ -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) - } - } -}