Initial Commit

This commit is contained in:
2026-04-29 20:59:39 +07:00
commit 1025658518
17 changed files with 1471 additions and 0 deletions
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+373
View File
@@ -0,0 +1,373 @@
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"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CryptoProvider()) { entry in
CryptoWidgetEntryView(entry: entry)
}
.configurationDisplayName("Rapira Crypto")
.description("Курсы криптовалют в реальном времени")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
// MARK: - Preview
#Preview(as: .systemLarge) {
CryptoWidget()
} timeline: {
CryptoEntry(date: .now, symbols: CryptoProvider.mock, selectedPairs: ["BTC/USDT", "ETH/USDT", "SOL/USDT", "TON/USDT"], icons: [:])
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.robonen.rapira-market-widget.crypto</string>
</array>
</dict>
</plist>
@@ -0,0 +1,563 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
48E90CC92F734F8800D6ABD4 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 48E90CC82F734F8800D6ABD4 /* WidgetKit.framework */; };
48E90CCB2F734F8800D6ABD4 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 48E90CCA2F734F8800D6ABD4 /* SwiftUI.framework */; };
48E90CD82F734F8900D6ABD4 /* cryptoExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 48E90CC62F734F8800D6ABD4 /* cryptoExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
48E90CD62F734F8900D6ABD4 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 48E90CAC2F734DBF00D6ABD4 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 48E90CC52F734F8800D6ABD4;
remoteInfo = cryptoExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
48E90CDD2F734F8900D6ABD4 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
48E90CD82F734F8900D6ABD4 /* cryptoExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
48E90CB42F734DBF00D6ABD4 /* rapira-market-widget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "rapira-market-widget.app"; sourceTree = BUILT_PRODUCTS_DIR; };
48E90CC62F734F8800D6ABD4 /* cryptoExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = cryptoExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
48E90CC82F734F8800D6ABD4 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
48E90CCA2F734F8800D6ABD4 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
48E90CDE2F734FB600D6ABD4 /* cryptoExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = cryptoExtension.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
48E90CD92F734F8900D6ABD4 /* Exceptions for "crypto" folder in "cryptoExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 48E90CC52F734F8800D6ABD4 /* cryptoExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "rapira-market-widget";
sourceTree = "<group>";
};
48E90CCC2F734F8800D6ABD4 /* crypto */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
48E90CD92F734F8900D6ABD4 /* Exceptions for "crypto" folder in "cryptoExtension" target */,
);
path = crypto;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
48E90CB12F734DBF00D6ABD4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
48E90CC32F734F8800D6ABD4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
48E90CCB2F734F8800D6ABD4 /* SwiftUI.framework in Frameworks */,
48E90CC92F734F8800D6ABD4 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
48E90CAB2F734DBF00D6ABD4 = {
isa = PBXGroup;
children = (
48E90CDE2F734FB600D6ABD4 /* cryptoExtension.entitlements */,
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
48E90CCC2F734F8800D6ABD4 /* crypto */,
48E90CC72F734F8800D6ABD4 /* Frameworks */,
48E90CB52F734DBF00D6ABD4 /* Products */,
);
sourceTree = "<group>";
};
48E90CB52F734DBF00D6ABD4 /* Products */ = {
isa = PBXGroup;
children = (
48E90CB42F734DBF00D6ABD4 /* rapira-market-widget.app */,
48E90CC62F734F8800D6ABD4 /* cryptoExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
48E90CC72F734F8800D6ABD4 /* Frameworks */ = {
isa = PBXGroup;
children = (
48E90CC82F734F8800D6ABD4 /* WidgetKit.framework */,
48E90CCA2F734F8800D6ABD4 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
48E90CB32F734DBF00D6ABD4 /* rapira-market-widget */ = {
isa = PBXNativeTarget;
buildConfigurationList = 48E90CBF2F734DC100D6ABD4 /* Build configuration list for PBXNativeTarget "rapira-market-widget" */;
buildPhases = (
48E90CB02F734DBF00D6ABD4 /* Sources */,
48E90CB12F734DBF00D6ABD4 /* Frameworks */,
48E90CB22F734DBF00D6ABD4 /* Resources */,
48E90CDD2F734F8900D6ABD4 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
48E90CD72F734F8900D6ABD4 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
48E90CB62F734DBF00D6ABD4 /* rapira-market-widget */,
);
name = "rapira-market-widget";
packageProductDependencies = (
);
productName = "rapira-market-widget";
productReference = 48E90CB42F734DBF00D6ABD4 /* rapira-market-widget.app */;
productType = "com.apple.product-type.application";
};
48E90CC52F734F8800D6ABD4 /* cryptoExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 48E90CDA2F734F8900D6ABD4 /* Build configuration list for PBXNativeTarget "cryptoExtension" */;
buildPhases = (
48E90CC22F734F8800D6ABD4 /* Sources */,
48E90CC32F734F8800D6ABD4 /* Frameworks */,
48E90CC42F734F8800D6ABD4 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
48E90CCC2F734F8800D6ABD4 /* crypto */,
);
name = cryptoExtension;
packageProductDependencies = (
);
productName = cryptoExtension;
productReference = 48E90CC62F734F8800D6ABD4 /* cryptoExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
48E90CAC2F734DBF00D6ABD4 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2630;
TargetAttributes = {
48E90CB32F734DBF00D6ABD4 = {
CreatedOnToolsVersion = 26.3;
};
48E90CC52F734F8800D6ABD4 = {
CreatedOnToolsVersion = 26.3;
};
};
};
buildConfigurationList = 48E90CAF2F734DBF00D6ABD4 /* Build configuration list for PBXProject "rapira-market-widget" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 48E90CAB2F734DBF00D6ABD4;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 48E90CB52F734DBF00D6ABD4 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
48E90CB32F734DBF00D6ABD4 /* rapira-market-widget */,
48E90CC52F734F8800D6ABD4 /* cryptoExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
48E90CB22F734DBF00D6ABD4 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
48E90CC42F734F8800D6ABD4 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
48E90CB02F734DBF00D6ABD4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
48E90CC22F734F8800D6ABD4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
48E90CD72F734F8900D6ABD4 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 48E90CC52F734F8800D6ABD4 /* cryptoExtension */;
targetProxy = 48E90CD62F734F8900D6ABD4 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
48E90CBD2F734DC100D6ABD4 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = WA8SWY233K;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
48E90CBE2F734DC100D6ABD4 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = WA8SWY233K;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
48E90CC02F734DC100D6ABD4 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "rapira-market-widget/rapira-market-widget.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WA8SWY233K;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "robonen.rapira-market-widget";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
48E90CC12F734DC100D6ABD4 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "rapira-market-widget/rapira-market-widget.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WA8SWY233K;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "robonen.rapira-market-widget";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
48E90CDB2F734F8900D6ABD4 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = cryptoExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WA8SWY233K;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = crypto/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = crypto;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "robonen.rapira-market-widget.crypto";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
48E90CDC2F734F8900D6ABD4 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = cryptoExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WA8SWY233K;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = crypto/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = crypto;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "robonen.rapira-market-widget.crypto";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
48E90CAF2F734DBF00D6ABD4 /* Build configuration list for PBXProject "rapira-market-widget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
48E90CBD2F734DC100D6ABD4 /* Debug */,
48E90CBE2F734DC100D6ABD4 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
48E90CBF2F734DC100D6ABD4 /* Build configuration list for PBXNativeTarget "rapira-market-widget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
48E90CC02F734DC100D6ABD4 /* Debug */,
48E90CC12F734DC100D6ABD4 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
48E90CDA2F734F8900D6ABD4 /* Build configuration list for PBXNativeTarget "cryptoExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
48E90CDB2F734F8900D6ABD4 /* Debug */,
48E90CDC2F734F8900D6ABD4 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 48E90CAC2F734DBF00D6ABD4 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>cryptoExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>rapira-market-widget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,206 @@
import SwiftUI
import WidgetKit
private let appGreen = Color(red: 0.20, green: 0.78, blue: 0.35)
private let appRed = Color(red: 1.0, green: 0.23, blue: 0.19)
// MARK: - Settings View
struct CryptoWidgetSettingsView: View {
@State private var allSymbols: [SymbolThumb] = []
@State private var selectedPairs: Set<String> = []
@State private var searchText = ""
@State private var isLoading = false
@State private var saved = false
private let maxSelection = 6
private var filtered: [SymbolThumb] {
if searchText.isEmpty { return allSymbols }
let q = searchText.lowercased()
return allSymbols.filter {
$0.symbol.lowercased().contains(q) ||
$0.quoteCurrencyName.lowercased().contains(q)
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Rapira Crypto")
.font(.system(size: 16, weight: .bold))
Text("\(selectedPairs.count)/\(maxSelection)")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.quaternary, in: Capsule())
Spacer()
Button {
savePairs()
} label: {
HStack(spacing: 4) {
if saved {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
Text("Сохранено")
} else {
Text("Сохранить")
}
}
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(.borderedProminent)
.tint(saved ? .green : .accentColor)
.controlSize(.small)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
// Search
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.tertiary)
.font(.system(size: 12))
TextField("Поиск пары...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 13))
}
.padding(8)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.padding(.horizontal, 16)
.padding(.bottom, 8)
Divider()
// List
if isLoading {
Spacer()
ProgressView()
.scaleEffect(0.8)
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(filtered) { sym in
AppPairRow(
symbol: sym,
isSelected: selectedPairs.contains(sym.symbol),
canSelect: selectedPairs.count < maxSelection || selectedPairs.contains(sym.symbol)
) {
togglePair(sym.symbol)
}
Divider().padding(.leading, 56).opacity(0.4)
}
}
.padding(.vertical, 4)
}
}
}
.frame(width: 420, height: 560)
.background(Color(.windowBackgroundColor))
.onChange(of: selectedPairs) { _, _ in
saved = false
}
.task {
loadSavedPairs()
await loadData()
}
}
// MARK: - Actions
private func togglePair(_ pair: String) {
if selectedPairs.contains(pair) {
selectedPairs.remove(pair)
} else if selectedPairs.count < maxSelection {
selectedPairs.insert(pair)
}
}
private func savePairs() {
let defaults = UserDefaults(suiteName: "group.robonen.rapira-market-widget.crypto")
defaults?.set(Array(selectedPairs), forKey: "selectedPairs")
WidgetCenter.shared.reloadAllTimelines()
withAnimation(.easeInOut(duration: 0.2)) { saved = true }
}
private func loadSavedPairs() {
let defaults = UserDefaults(suiteName: "group.robonen.rapira-market-widget.crypto")
if let s = defaults?.stringArray(forKey: "selectedPairs") {
selectedPairs = Set(s)
} else {
selectedPairs = ["BTC/USDT", "ETH/USDT", "SOL/USDT"]
}
}
private func loadData() async {
isLoading = true
defer { isLoading = false }
guard let data = try? await RapiraAPI.fetch() else { return }
allSymbols = data
.filter { $0.baseCurrency == "USDT" }
.sorted { $0.turnover > $1.turnover }
}
}
// MARK: - Pair Row
private struct AppPairRow: View {
let symbol: SymbolThumb
let isSelected: Bool
let canSelect: Bool
let onToggle: () -> Void
var body: some View {
Button(action: onToggle) {
HStack(spacing: 10) {
// Selection dot
Circle()
.fill(isSelected ? Color.accentColor : Color.clear)
.frame(width: 8, height: 8)
.overlay(Circle().strokeBorder(isSelected ? Color.clear : Color.secondary.opacity(0.3), lineWidth: 1))
CoinIcon(symbol: symbol.quoteCurrency, size: 30)
VStack(alignment: .leading, spacing: 1) {
Text(symbol.quoteCurrency)
.font(.system(size: 13, weight: .semibold))
Text(symbol.quoteCurrencyName)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 4)
Text(symbol.priceFormatted)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(symbol.changePercent)
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(symbol.isPositive ? appGreen : appRed, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
}
.padding(.horizontal, 16)
.padding(.vertical, 7)
.contentShape(Rectangle())
.opacity((!canSelect && !isSelected) ? 0.35 : 1.0)
}
.buttonStyle(.plain)
.disabled(!canSelect && !isSelected)
}
}
// MARK: - Preview
#Preview {
CryptoWidgetSettingsView()
}
@@ -0,0 +1,11 @@
import SwiftUI
@main
struct RapiraMarketWidgetApp: App {
var body: some Scene {
Window("Rapira Crypto", id: "main") {
CryptoWidgetSettingsView()
}
.windowResizability(.contentSize)
}
}
+95
View File
@@ -0,0 +1,95 @@
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)
}
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.robonen.rapira-market-widget.crypto</string>
</array>
</dict>
</plist>