This commit is contained in:
2026-08-12 07:06:01 +07:00
parent 1025658518
commit 9bc96dbc2d
39 changed files with 1682 additions and 658 deletions
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

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