Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 180 additions & 2 deletions Bitkit.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

7 changes: 0 additions & 7 deletions Bitkit/Components/Widgets/PriceWidget.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import Charts
import SwiftUI

/// Options for configuring the PriceWidget
struct PriceWidgetOptions: Codable, Equatable {
var selectedPairs: [String] = ["BTC/USD"]
var selectedPeriod: GraphPeriod = .oneDay
var showSource: Bool = false
}

/// A widget that displays cryptocurrency price information with chart
struct PriceWidget: View {
/// Configuration options for the widget
Expand Down
10 changes: 10 additions & 0 deletions Bitkit/Constants/WidgetEnv.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

/// Lightweight constants shared between the main app and the WidgetKit extension.
///
/// Kept free of BitkitCore / LDKNode imports so it can be a member of both targets via
/// `PBXFileSystemSynchronizedBuildFileExceptionSet`. `Env.swift` cannot fill this role
/// because it depends on framework types that aren't linked into the widget extension.
enum WidgetEnv {
static let priceFeedBaseUrl = "https://feeds.synonym.to/price-feed/api"
}
116 changes: 116 additions & 0 deletions Bitkit/Models/PriceWidgetData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Foundation

// MARK: - Public Models

public struct TradingPair {
public let name: String
public let base: String
public let quote: String
public let symbol: String
}

public let tradingPairs: [TradingPair] = [
TradingPair(name: "BTC/USD", base: "BTC", quote: "USD", symbol: "$"),
TradingPair(name: "BTC/EUR", base: "BTC", quote: "EUR", symbol: "€"),
TradingPair(name: "BTC/GBP", base: "BTC", quote: "GBP", symbol: "£"),
TradingPair(name: "BTC/JPY", base: "BTC", quote: "JPY", symbol: "¥"),
]

/// Convenience array for just the pair names.
public let tradingPairNames: [String] = tradingPairs.map(\.name)

enum GraphPeriod: String, CaseIterable, Codable {
case oneDay = "1D"
case oneWeek = "1W"
case oneMonth = "1M"
case oneYear = "1Y"
}

struct PriceChange: Equatable {
let isPositive: Bool
let formatted: String
}

struct PriceData: Equatable {
let name: String
let change: PriceChange
let price: String
let pastValues: [Double]
}

// MARK: - Cache Representation

/// Persistable representation of `PriceData` shared between the main app and the widget extension via App Group.
struct CachedPriceData: Codable, Equatable {
let name: String
let changeIsPositive: Bool
let changeFormatted: String
let price: String
let pastValues: [Double]

init(from data: PriceData) {
name = data.name
changeIsPositive = data.change.isPositive
changeFormatted = data.change.formatted
price = data.price
pastValues = data.pastValues
}

func toPriceData() -> PriceData {
PriceData(
name: name,
change: PriceChange(isPositive: changeIsPositive, formatted: changeFormatted),
price: price,
pastValues: pastValues
)
}
}

// MARK: - Cache Helpers (App Group)

/// Cache reader/writer used by both the main app and the widget extension.
enum PriceWidgetCache {
static let appGroupSuiteName = "group.bitkit"
private static let keyPrefix = "price_widget_cache_"

private static func cacheKey(pair: String, period: GraphPeriod) -> String {
"\(keyPrefix)\(pair)_\(period.rawValue)"
}

private static func defaults() -> UserDefaults {
UserDefaults(suiteName: appGroupSuiteName) ?? .standard
}

static func save(_ data: PriceData, period: GraphPeriod) {
guard let encoded = try? JSONEncoder().encode(CachedPriceData(from: data)) else { return }
defaults().set(encoded, forKey: cacheKey(pair: data.name, period: period))
}

static func load(pair: String, period: GraphPeriod) -> PriceData? {
let key = cacheKey(pair: pair, period: period)
let group = defaults()

if let data = group.data(forKey: key),
let decoded = try? JSONDecoder().decode(CachedPriceData.self, from: data)
{
return decoded.toPriceData()
}

// One-time migration from the pre-App-Group standard suite.
if group !== UserDefaults.standard,
let data = UserDefaults.standard.data(forKey: key),
let decoded = try? JSONDecoder().decode(CachedPriceData.self, from: data)
{
group.set(data, forKey: key)
UserDefaults.standard.removeObject(forKey: key)
return decoded.toPriceData()
}

return nil
}

static func loadAll(pairs: [String], period: GraphPeriod) -> [PriceData]? {
let items = pairs.compactMap { load(pair: $0, period: period) }
return items.count == pairs.count ? items : nil
}
}
8 changes: 8 additions & 0 deletions Bitkit/Models/PriceWidgetOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

/// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension).
struct PriceWidgetOptions: Codable, Equatable {
var selectedPairs: [String] = ["BTC/USD"]
var selectedPeriod: GraphPeriod = .oneDay
var showSource: Bool = false
}
36 changes: 36 additions & 0 deletions Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation
import WidgetKit

/// Mirrors in-app price widget options into the App Group so the WidgetKit extension can read them,
/// and centralizes the WidgetKit reload trigger for the price home-screen widget.
enum PriceHomeScreenWidgetOptionsStore {
/// WidgetKit `kind` for the home-screen price widget (must match `BitkitPriceWidget`).
static let priceHomeScreenWidgetKind = "BitkitPriceWidget"

private static let suiteName = "group.bitkit"
private static let key = "home_screen_price_widget_options_v1"

static func save(_ options: PriceWidgetOptions) {
guard let defaults = UserDefaults(suiteName: suiteName),
let data = try? JSONEncoder().encode(options)
else { return }
defaults.set(data, forKey: key)
}

static func load() -> PriceWidgetOptions {
guard let defaults = UserDefaults(suiteName: suiteName),
let data = defaults.data(forKey: key),
let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: data)
else {
return PriceWidgetOptions()
}
return options
}

/// Call after updating options or cache so the home-screen widget timeline refreshes.
/// No-op when running inside the widget extension itself (`appex`).
static func reloadHomeScreenWidgetIfNeeded() {
guard Bundle.main.bundleURL.pathExtension != "appex" else { return }
WidgetCenter.shared.reloadTimelines(ofKind: priceHomeScreenWidgetKind)
}
}
112 changes: 5 additions & 107 deletions Bitkit/Services/Widgets/PriceService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,6 @@ import Foundation

// MARK: - Data Models

public struct TradingPair {
public let name: String
public let base: String
public let quote: String
public let symbol: String
}

struct PriceResponse: Codable {
let price: Double
let timestamp: Double
Expand Down Expand Up @@ -38,25 +31,6 @@ struct CandleResponse: Codable {
let volume: Double
}

struct PriceChange {
let isPositive: Bool
let formatted: String
}

struct PriceData {
let name: String
let change: PriceChange
let price: String
let pastValues: [Double]
}

enum GraphPeriod: String, CaseIterable, Codable {
case oneDay = "1D"
case oneWeek = "1W"
case oneMonth = "1M"
case oneYear = "1Y"
}

enum PriceServiceError: Error {
case invalidURL
case invalidPair
Expand All @@ -65,66 +39,11 @@ enum PriceServiceError: Error {
case noPriceDataAvailable
}

// MARK: - Trading Pairs Constants

public let tradingPairs: [TradingPair] = [
TradingPair(name: "BTC/USD", base: "BTC", quote: "USD", symbol: "$"),
TradingPair(name: "BTC/EUR", base: "BTC", quote: "EUR", symbol: "€"),
TradingPair(name: "BTC/GBP", base: "BTC", quote: "GBP", symbol: "£"),
TradingPair(name: "BTC/JPY", base: "BTC", quote: "JPY", symbol: "¥"),
]

/// Convenience array for just the pair names
public let tradingPairNames: [String] = tradingPairs.map(\.name)

// MARK: - Helper Models

private struct CachedPriceData: Codable {
let name: String
let changeIsPositive: Bool
let changeFormatted: String
let price: String
let pastValues: [Double]
}

// MARK: - Caching System

class PriceWidgetCache {
static let shared = PriceWidgetCache()
private let userDefaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()

private init() {}

func set(_ value: some Codable, forKey key: String) {
do {
let data = try encoder.encode(value)
userDefaults.set(data, forKey: "price_widget_cache_\(key)")
} catch {
print("Failed to cache price data for key \(key): \(error)")
}
}

func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
guard let data = userDefaults.data(forKey: "price_widget_cache_\(key)") else {
return nil
}

do {
return try decoder.decode(type, from: data)
} catch {
print("Failed to decode cached price data for key \(key): \(error)")
return nil
}
}
}

// MARK: - Price Service

class PriceService {
static let shared = PriceService()
private let baseURL = "https://feeds.synonym.to/price-feed/api"
private let baseURL = WidgetEnv.priceFeedBaseUrl

private init() {}

Expand Down Expand Up @@ -190,21 +109,7 @@ class PriceService {
}

private func getCachedData(pairs: [String], period: GraphPeriod) -> [PriceData]? {
let cache = PriceWidgetCache.shared
let cachedItems = pairs.compactMap { pairName in
cache.get(CachedPriceData.self, forKey: "\(pairName)_\(period.rawValue)")
}

guard cachedItems.count == pairs.count else { return nil }

return cachedItems.map { cached in
PriceData(
name: cached.name,
change: PriceChange(isPositive: cached.changeIsPositive, formatted: cached.changeFormatted),
price: cached.price,
pastValues: cached.pastValues
)
}
PriceWidgetCache.loadAll(pairs: pairs, period: period)
}

private func fetchPairData(pairName: String, period: GraphPeriod) async throws -> PriceData {
Expand Down Expand Up @@ -288,15 +193,8 @@ class PriceService {
return "\(pair.symbol) \(formatted)"
}

private func cacheData(pairName: String, period: GraphPeriod, data: PriceData) {
let cacheKey = "\(pairName)_\(period.rawValue)"
let cachedData = CachedPriceData(
name: data.name,
changeIsPositive: data.change.isPositive,
changeFormatted: data.change.formatted,
price: data.price,
pastValues: data.pastValues
)
PriceWidgetCache.shared.set(cachedData, forKey: cacheKey)
private func cacheData(pairName _: String, period: GraphPeriod, data: PriceData) {
PriceWidgetCache.save(data, period: period)
PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded()
}
}
6 changes: 3 additions & 3 deletions Bitkit/Styles/TextStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,10 @@ private struct FlexibleTextView: View {
#Preview {
ScrollView {
HStack {
DisplayText(t("onboarding__empty_wallet"))
DisplayText("Display Text With An\n<accent>Accent</accent> Over Here")
.background(Color.red.opacity(0.1))

DisplayText(t("onboarding__welcome_title"))
DisplayText("Display Text With An\n<accent>Accent</accent> Over Here")
.background(Color.blue.opacity(0.1))
}
.padding(.bottom, 20)
Expand All @@ -636,7 +636,7 @@ private struct FlexibleTextView: View {
}
.padding(.bottom, 20)

DisplayText(t("onboarding__slide0_header"))
DisplayText("Display Text With An\n<accent>Accent</accent> Over Here")
.background(Color.orange.opacity(0.1))
.padding(.bottom, 20)

Expand Down
9 changes: 9 additions & 0 deletions Bitkit/ViewModels/WidgetsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ class WidgetsViewModel: ObservableObject {
savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() }
persistSavedWidgets()
}
syncPriceOptionsToHomeScreenWidget()
}

private func persistSavedWidgets() {
Expand All @@ -310,5 +311,13 @@ class WidgetsViewModel: ObservableObject {
} catch {
print("Failed to persist widgets: \(error)")
}
syncPriceOptionsToHomeScreenWidget()
}

/// Keeps the home-screen WidgetKit price widget in sync with in-app price widget options (App Group).
private func syncPriceOptionsToHomeScreenWidget() {
let options: PriceWidgetOptions = getOptions(for: .price, as: PriceWidgetOptions.self)
PriceHomeScreenWidgetOptionsStore.save(options)
PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded()
}
}
11 changes: 11 additions & 0 deletions BitkitWidget/Assets.xcassets/AccentColor.colorset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Loading