diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index df861a506..799da4457 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; 3D7626104C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; + 4A319B512E8F24F2002B9AC9 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */; }; + 4A319B532E8F24F2002B9AC9 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */; }; + 4A319B622E8F24F4002B9AC9 /* BitkitWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; @@ -25,6 +28,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 4A319B602E8F24F4002B9AC9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 96FE1F592C2DE6AA006D0C8B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4A319B4E2E8F24F2002B9AC9; + remoteInfo = BitkitWidgetExtension; + }; 961058E12C355B5500E1F1D8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 96FE1F592C2DE6AA006D0C8B /* Project object */; @@ -56,6 +66,7 @@ dstSubfolderSpec = 13; files = ( 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */, + 4A319B622E8F24F4002B9AC9 /* BitkitWidgetExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -64,6 +75,10 @@ /* Begin PBXFileReference section */ 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; + 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BitkitWidgetExtension.entitlements; sourceTree = ""; }; 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitNotification.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bitkit.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitkitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -71,6 +86,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + }; 96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -141,16 +163,45 @@ ); target = 961058DB2C355B5500E1F1D8 /* BitkitNotification */; }; + 4A319B672E8F24F5002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Fonts/InterTight-Black.ttf, + Fonts/InterTight-Bold.ttf, + Fonts/InterTight-ExtraBold.ttf, + Fonts/InterTight-Medium.ttf, + Fonts/InterTight-Regular.ttf, + Constants/WidgetEnv.swift, + Fonts/InterTight-SemiBold.ttf, + Models/PriceWidgetData.swift, + Models/PriceWidgetOptions.swift, + Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, + Styles/Colors.swift, + Styles/Fonts.swift, + Styles/TextStyle.swift, + ); + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 96A44E912CEF5EA700FBACFF /* Bitkit */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3E2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3F2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bitkit; sourceTree = ""; }; + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BitkitWidget; sourceTree = ""; }; + 96A44E912CEF5EA700FBACFF /* Bitkit */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3E2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3F2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4A319B672E8F24F5002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bitkit; sourceTree = ""; }; 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BitkitTests; sourceTree = ""; }; 96A44F562CEF5F5400FBACFF /* BitkitUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BitkitUITests; sourceTree = ""; }; 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F5E2CEF5F5800FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BitkitNotification; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 4A319B4C2E8F24F2002B9AC9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4A319B532E8F24F2002B9AC9 /* SwiftUI.framework in Frameworks */, + 4A319B512E8F24F2002B9AC9 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058D92C355B5500E1F1D8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -200,6 +251,8 @@ isa = PBXGroup; children = ( 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */, + 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */, + 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -207,10 +260,12 @@ 96FE1F582C2DE6AA006D0C8B = { isa = PBXGroup; children = ( + 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */, 96A44E912CEF5EA700FBACFF /* Bitkit */, 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */, 96A44F562CEF5F5400FBACFF /* BitkitUITests */, 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */, + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, 96FE1F622C2DE6AA006D0C8B /* Products */, 961058EC2C35798C00E1F1D8 /* Frameworks */, ); @@ -223,6 +278,7 @@ 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */, 96FE1F7C2C2DE6AC006D0C8B /* BitkitUITests.xctest */, 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */, + 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -230,6 +286,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4A319B652E8F24F4002B9AC9 /* Build configuration list for PBXNativeTarget "BitkitWidgetExtension" */; + buildPhases = ( + 4A319B4B2E8F24F2002B9AC9 /* Sources */, + 4A319B4C2E8F24F2002B9AC9 /* Frameworks */, + 4A319B4D2E8F24F2002B9AC9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, + ); + name = BitkitWidgetExtension; + packageProductDependencies = ( + ); + productName = BitkitWidgetExtension; + productReference = 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 961058DB2C355B5500E1F1D8 /* BitkitNotification */ = { isa = PBXNativeTarget; buildConfigurationList = 961058E42C355B5500E1F1D8 /* Build configuration list for PBXNativeTarget "BitkitNotification" */; @@ -267,6 +345,7 @@ ); dependencies = ( 961058E22C355B5500E1F1D8 /* PBXTargetDependency */, + 4A319B612E8F24F4002B9AC9 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 96A44E912CEF5EA700FBACFF /* Bitkit */, @@ -337,9 +416,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1540; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1540; TargetAttributes = { + 4A319B4E2E8F24F2002B9AC9 = { + CreatedOnToolsVersion = 26.0; + }; 961058DB2C355B5500E1F1D8 = { CreatedOnToolsVersion = 15.4; }; @@ -396,11 +478,19 @@ 96FE1F712C2DE6AC006D0C8B /* BitkitTests */, 96FE1F7B2C2DE6AC006D0C8B /* BitkitUITests */, 961058DB2C355B5500E1F1D8 /* BitkitNotification */, + 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 4A319B4D2E8F24F2002B9AC9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058DA2C355B5500E1F1D8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -455,6 +545,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4A319B4B2E8F24F2002B9AC9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058D82C355B5500E1F1D8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -486,6 +583,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 4A319B612E8F24F4002B9AC9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + targetProxy = 4A319B602E8F24F4002B9AC9 /* PBXContainerItemProxy */; + }; 961058E22C355B5500E1F1D8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 961058DB2C355B5500E1F1D8 /* BitkitNotification */; @@ -504,6 +606,73 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 4A319B632E8F24F4002B9AC9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 186; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + 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; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4A319B642E8F24F4002B9AC9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 186; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + 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; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 961058E52C355B5500E1F1D8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -854,6 +1023,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 4A319B652E8F24F4002B9AC9 /* Build configuration list for PBXNativeTarget "BitkitWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4A319B632E8F24F4002B9AC9 /* Debug */, + 4A319B642E8F24F4002B9AC9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 961058E42C355B5500E1F1D8 /* Build configuration list for PBXNativeTarget "BitkitNotification" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index aeb8a6a01..8da348dab 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -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 diff --git a/Bitkit/Constants/WidgetEnv.swift b/Bitkit/Constants/WidgetEnv.swift new file mode 100644 index 000000000..1c7e30320 --- /dev/null +++ b/Bitkit/Constants/WidgetEnv.swift @@ -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" +} diff --git a/Bitkit/Models/PriceWidgetData.swift b/Bitkit/Models/PriceWidgetData.swift new file mode 100644 index 000000000..decdbea6d --- /dev/null +++ b/Bitkit/Models/PriceWidgetData.swift @@ -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 + } +} diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift new file mode 100644 index 000000000..94310d05f --- /dev/null +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -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 +} diff --git a/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..b812a5bc6 --- /dev/null +++ b/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift @@ -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) + } +} diff --git a/Bitkit/Services/Widgets/PriceService.swift b/Bitkit/Services/Widgets/PriceService.swift index 0569c0e77..411d0f22e 100644 --- a/Bitkit/Services/Widgets/PriceService.swift +++ b/Bitkit/Services/Widgets/PriceService.swift @@ -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 @@ -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 @@ -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(_ 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() {} @@ -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 { @@ -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() } } diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 2624981a2..403564a8f 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -619,10 +619,10 @@ private struct FlexibleTextView: View { #Preview { ScrollView { HStack { - DisplayText(t("onboarding__empty_wallet")) + DisplayText("Display Text With An\nAccent Over Here") .background(Color.red.opacity(0.1)) - DisplayText(t("onboarding__welcome_title")) + DisplayText("Display Text With An\nAccent Over Here") .background(Color.blue.opacity(0.1)) } .padding(.bottom, 20) @@ -636,7 +636,7 @@ private struct FlexibleTextView: View { } .padding(.bottom, 20) - DisplayText(t("onboarding__slide0_header")) + DisplayText("Display Text With An\nAccent Over Here") .background(Color.orange.opacity(0.1)) .padding(.bottom, 20) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index cd954ff90..67a6a804e 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -301,6 +301,7 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } + syncPriceOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -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() } } diff --git a/BitkitWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/BitkitWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/BitkitWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/Contents.json b/BitkitWidget/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/BitkitWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/BitkitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json b/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json new file mode 100644 index 000000000..50f875c92 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "btc.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf b/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf new file mode 100644 index 000000000..c40a91328 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf differ diff --git a/BitkitWidget/BitkitWidget.entitlements b/BitkitWidget/BitkitWidget.entitlements new file mode 100644 index 000000000..f5cc9b7c2 --- /dev/null +++ b/BitkitWidget/BitkitWidget.entitlements @@ -0,0 +1,11 @@ + + + + + com.apple.security.application-groups + + group.bitkit + + + + diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift new file mode 100644 index 000000000..737864ecf --- /dev/null +++ b/BitkitWidget/BitkitWidget.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct BitkitWidgetBundle: WidgetBundle { + var body: some Widget { + BitkitPriceWidget() + } +} diff --git a/BitkitWidget/Info.plist b/BitkitWidget/Info.plist new file mode 100644 index 000000000..b12fe2e15 --- /dev/null +++ b/BitkitWidget/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Bitcoin Facts + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + UIAppFonts + + InterTight-Black.ttf + InterTight-Bold.ttf + InterTight-ExtraBold.ttf + InterTight-Medium.ttf + InterTight-Regular.ttf + InterTight-SemiBold.ttf + + + diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift new file mode 100644 index 000000000..b53fea3f8 --- /dev/null +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -0,0 +1,312 @@ +import Charts +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct PriceWidgetEntry: TimelineEntry { + let date: Date + let prices: [PriceData] + let options: PriceWidgetOptions + /// True when no fresh data could be fetched and there is nothing in cache to fall back to. + let showsError: Bool +} + +// MARK: - Timeline Provider + +struct PriceWidgetProvider: TimelineProvider { + /// Stable mock for widget gallery / placeholder snapshots — fast, deterministic, no network. + private static let mockEntry: PriceWidgetEntry = { + let mockSeries = stride(from: 0.0, to: 24.0, by: 1.0).map { 60000 + 1000 * sin($0 / 4) } + return PriceWidgetEntry( + date: Date(), + prices: [ + PriceData( + name: "BTC/USD", + change: PriceChange(isPositive: true, formatted: "+1.23%"), + price: "$ 60,000", + pastValues: mockSeries + ), + ], + options: PriceWidgetOptions(), + showsError: false + ) + }() + + func placeholder(in _: Context) -> PriceWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { + let options = PriceHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(PriceWidgetEntry( + date: Self.mockEntry.date, + prices: Self.mockEntry.prices, + options: options, + showsError: false + )) + return + } + + let cached = PriceWidgetService.cachedPrices(pairs: options.selectedPairs, period: options.selectedPeriod) ?? [] + completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = PriceHomeScreenWidgetOptionsStore.load() + + Task { + let entry: PriceWidgetEntry + do { + let fresh = try await PriceWidgetService.fetchFreshPrices( + pairs: options.selectedPairs, + period: options.selectedPeriod + ) + entry = PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false) + } catch { + let cached = PriceWidgetService.cachedPrices(pairs: options.selectedPairs, period: options.selectedPeriod) ?? [] + entry = PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: cached.isEmpty) + } + + let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) + ?? Date().addingTimeInterval(15 * 60) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct PriceHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: PriceWidgetProvider.Entry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + content + if entry.options.showSource, !entry.prices.isEmpty { + HStack { + Spacer() + CaptionBText("Bitfinex.com", textColor: secondaryTextColor) + } + } + } + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if entry.prices.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + switch widgetFamily { + case .systemSmall: + smallContent + default: + rowsAndChart + } + } + } + + // MARK: - Variants + + private var smallContent: some View { + let primary = entry.prices.first + return VStack(alignment: .leading, spacing: 4) { + BodySSBText(primary?.name ?? "BTC/USD", textColor: secondaryTextColor) + .lineLimit(1) + + TitleText(primary?.price ?? "—", textColor: valueTextColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + .widgetAccentable() + + if let change = primary?.change { + BodySSBText(change.formatted, textColor: changeColor(isPositive: change.isPositive)) + .lineLimit(1) + .widgetAccentable() + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var rowsAndChart: some View { + VStack(spacing: 0) { + ForEach(visibleRows, id: \.name) { data in + priceRow(data: data) + } + + if let firstPair = entry.prices.first { + PriceWidgetChart( + values: firstPair.pastValues, + isPositive: firstPair.change.isPositive, + period: entry.options.selectedPeriod.rawValue, + renderingMode: widgetRenderingMode + ) + .frame(height: chartHeight) + .padding(.top, 8) + } + } + } + + private var visibleRows: [PriceData] { + switch widgetFamily { + case .systemSmall: Array(entry.prices.prefix(1)) + case .systemMedium: Array(entry.prices.prefix(2)) + case .systemLarge, .systemExtraLarge: Array(entry.prices.prefix(4)) + default: Array(entry.prices.prefix(1)) + } + } + + private var chartHeight: CGFloat { + switch widgetFamily { + case .systemMedium: 64 + case .systemLarge, .systemExtraLarge: 120 + default: 96 + } + } + + private var errorView: some View { + BodySText("Couldn’t load price.", textColor: secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Row + + private func priceRow(data: PriceData) -> some View { + HStack(spacing: 0) { + BodySSBText(data.name, textColor: secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + + BodySSBText(data.change.formatted, textColor: changeColor(isPositive: data.change.isPositive)) + .padding(.trailing, 8) + .lineLimit(1) + .widgetAccentable() + + BodySSBText(data.price, textColor: valueTextColor) + .lineLimit(1) + .minimumScaleFactor(0.75) + .widgetAccentable() + } + .frame(minHeight: 24) + } + + // MARK: - Colors + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var secondaryTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary + } + + private var valueTextColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private func changeColor(isPositive: Bool) -> Color { + guard widgetRenderingMode == .fullColor else { return .primary } + return isPositive ? .greenAccent : .redAccent + } +} + +// MARK: - Chart + +private struct PriceWidgetChart: View { + let values: [Double] + let isPositive: Bool + let period: String + let renderingMode: WidgetRenderingMode + + private var normalizedValues: [Double] { + guard values.count > 1 else { return values } + let minValue = values.min() ?? 0 + let maxValue = values.max() ?? 0 + let range = maxValue - minValue + guard range > 0 else { return values.map { _ in 0.5 } } + return values.map { 0.15 + (($0 - minValue) / range) * 0.7 } + } + + private var lineColor: Color { + guard renderingMode == .fullColor else { return .primary } + return isPositive ? .greenAccent : .redAccent + } + + private var gradientColors: [Color] { + guard renderingMode == .fullColor else { return [.primary.opacity(0.3), .clear] } + let base: Color = isPositive ? .greenAccent : .redAccent + return [base.opacity(0.64), base.opacity(0.08)] + } + + private var labelColor: Color { + guard renderingMode == .fullColor else { return .secondary } + return isPositive ? .green50 : .red50 + } + + var body: some View { + ZStack(alignment: .bottomLeading) { + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + AreaMark( + x: .value("Index", index), + y: .value("Price", value) + ) + .foregroundStyle( + LinearGradient(colors: gradientColors, startPoint: .top, endPoint: .bottom) + ) + .interpolationMethod(.catmullRom) + + LineMark( + x: .value("Index", index), + y: .value("Price", value) + ) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: 1.3)) + .interpolationMethod(.catmullRom) + } + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0.1 ... 0.9) + .clipShape( + .rect( + topLeadingRadius: 0, + bottomLeadingRadius: 8, + bottomTrailingRadius: 8, + topTrailingRadius: 0 + ) + ) + .widgetAccentable() + + CaptionBText(period, textColor: labelColor) + .padding(7) + } + } +} + +// MARK: - Widget Configuration + +struct BitkitPriceWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: PriceHomeScreenWidgetOptionsStore.priceHomeScreenWidgetKind, + provider: PriceWidgetProvider() + ) { entry in + PriceHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Price") + .description("Latest Bitcoin price and chart, mirroring the in-app price widget.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/BitkitWidget/PriceWidgetService.swift b/BitkitWidget/PriceWidgetService.swift new file mode 100644 index 000000000..0574bdf22 --- /dev/null +++ b/BitkitWidget/PriceWidgetService.swift @@ -0,0 +1,128 @@ +import Foundation + +/// Slim price fetcher used inside the WidgetKit extension. +/// +/// Reads cached `PriceData` from the App Group (written by the main app's `PriceService`) +/// and falls back to a direct network fetch when no cache is available or when explicitly +/// asked to refresh. The cache itself is owned by the main app — this service intentionally +/// does not write back to it, to keep the extension's footprint minimal. +enum PriceWidgetService { + enum FetchError: Error { + case invalidURL + case invalidPair + case noPriceDataAvailable + } + + // MARK: - Cache + + static func cachedPrices(pairs: [String], period: GraphPeriod) -> [PriceData]? { + PriceWidgetCache.loadAll(pairs: pairs, period: period) + } + + // MARK: - Fresh Fetch + + static func fetchFreshPrices(pairs: [String], period: GraphPeriod) async throws -> [PriceData] { + let results = await withTaskGroup(of: PriceData?.self) { group -> [PriceData] in + for pair in pairs { + group.addTask { try? await fetchPair(pairName: pair, period: period) } + } + + var collected: [PriceData] = [] + for await result in group { + if let result { collected.append(result) } + } + return collected + } + + guard !results.isEmpty else { throw FetchError.noPriceDataAvailable } + return results + } + + // MARK: - Per-pair pipeline + + private static func fetchPair(pairName: String, period: GraphPeriod) async throws -> PriceData { + guard let pair = tradingPairs.first(where: { $0.name == pairName }) else { + throw FetchError.invalidPair + } + + let ticker = "\(pair.base)\(pair.quote)" + let candles = try await fetchCandles(ticker: ticker, period: period) + let pastValues = candles.sorted(by: { $0.timestamp < $1.timestamp }).map(\.close) + + let latest = try await fetchLatestPrice(ticker: ticker) + let updated = Array(pastValues.dropLast()) + [latest] + + return PriceData( + name: pairName, + change: priceChange(from: updated), + price: formatPrice(pair: pair, price: latest), + pastValues: updated + ) + } + + private static func fetchLatestPrice(ticker: String) async throws -> Double { + guard let url = URL(string: "\(WidgetEnv.priceFeedBaseUrl)/price/\(ticker)/latest") else { + throw FetchError.invalidURL + } + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(LatestPriceResponse.self, from: data).price + } + + private static func fetchCandles(ticker: String, period: GraphPeriod) async throws -> [Candle] { + guard let url = URL(string: "\(WidgetEnv.priceFeedBaseUrl)/price/\(ticker)/history/\(period.rawValue)") else { + throw FetchError.invalidURL + } + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode([Candle].self, from: data) + } + + private static func priceChange(from values: [Double]) -> PriceChange { + guard let first = values.first, let last = values.last, first != 0, values.count >= 2 else { + return PriceChange(isPositive: true, formatted: "+0%") + } + let change = last / first - 1 + let sign = change >= 0 ? "+" : "" + return PriceChange( + isPositive: change >= 0, + formatted: "\(sign)\(String(format: "%.2f", change * 100))%" + ) + } + + private static func formatPrice(pair: TradingPair, price: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + let formatted = formatter.string(from: NSNumber(value: price)) ?? String(format: "%.0f", price) + return "\(pair.symbol) \(formatted)" + } +} + +// MARK: - Wire Models + +private struct LatestPriceResponse: Codable { + let price: Double + let timestamp: Double + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + timestamp = try container.decode(Double.self, forKey: .timestamp) + + // Server may serialize price as either string or number. + if let priceString = try? container.decode(String.self, forKey: .price), + let parsed = Double(priceString) + { + price = parsed + } else { + price = try container.decode(Double.self, forKey: .price) + } + } +} + +private struct Candle: Codable { + let timestamp: Double + let open: Double + let close: Double + let high: Double + let low: Double + let volume: Double +} diff --git a/BitkitWidgetExtension.entitlements b/BitkitWidgetExtension.entitlements new file mode 100644 index 000000000..4fca2ce32 --- /dev/null +++ b/BitkitWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.bitkit + + + diff --git a/changelog.d/next/538.added.md b/changelog.d/next/538.added.md new file mode 100644 index 000000000..a42bd08e6 --- /dev/null +++ b/changelog.d/next/538.added.md @@ -0,0 +1 @@ +Added a Bitcoin Price home-screen widget that mirrors the in-app price widget.