From c43ab28a513a5b7d9f92808638090a75a634b442 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Wed, 8 Apr 2026 13:48:09 +0200 Subject: [PATCH 01/10] WIP --- Bitkit.xcodeproj/project.pbxproj | 161 +++++++++++++++- Bitkit/Services/Widgets/FactsService.swift | 16 +- .../AccentColor.colorset/Contents.json | 11 ++ .../AppIcon.appiconset/Contents.json | 35 ++++ BitkitWidget/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 ++ BitkitWidget/BitkitWidget.entitlements | 11 ++ BitkitWidget/BitkitWidget.swift | 178 ++++++++++++++++++ BitkitWidget/Info.plist | 29 +++ BitkitWidget/README.md | 55 ++++++ BitkitWidget/WidgetFactsService.swift | 112 +++++++++++ BitkitWidgetExtension.entitlements | 10 + WIDGET_SETUP.md | 147 +++++++++++++++ 13 files changed, 780 insertions(+), 2 deletions(-) create mode 100644 BitkitWidget/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 BitkitWidget/BitkitWidget.entitlements create mode 100644 BitkitWidget/BitkitWidget.swift create mode 100644 BitkitWidget/Info.plist create mode 100644 BitkitWidget/README.md create mode 100644 BitkitWidget/WidgetFactsService.swift create mode 100644 BitkitWidgetExtension.entitlements create mode 100644 WIDGET_SETUP.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 3a1e2992b..6cc752e5b 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 */; }; 4AFCA3702E05933800205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA36F2E05933800205CAE /* Zip */; }; 4AFCA3722E0596D900205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA3712E0596D900205CAE /* Zip */; }; @@ -27,6 +30,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 */; @@ -58,6 +68,7 @@ dstSubfolderSpec = 13; files = ( 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */, + 4A319B622E8F24F4002B9AC9 /* BitkitWidgetExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -66,6 +77,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; }; @@ -73,6 +88,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + }; 96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -144,6 +166,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BitkitWidget; sourceTree = ""; }; 96A44E912CEF5EA700FBACFF /* Bitkit */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3E2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3F2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bitkit; sourceTree = ""; }; 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BitkitTests; sourceTree = ""; }; 96A44F562CEF5F5400FBACFF /* BitkitUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BitkitUITests; sourceTree = ""; }; @@ -151,6 +174,15 @@ /* 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; @@ -202,6 +234,8 @@ isa = PBXGroup; children = ( 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */, + 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */, + 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -209,10 +243,12 @@ 96FE1F582C2DE6AA006D0C8B = { isa = PBXGroup; children = ( + 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */, 96A44E912CEF5EA700FBACFF /* Bitkit */, 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */, 96A44F562CEF5F5400FBACFF /* BitkitUITests */, 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */, + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, 96FE1F622C2DE6AA006D0C8B /* Products */, 961058EC2C35798C00E1F1D8 /* Frameworks */, ); @@ -225,6 +261,7 @@ 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */, 96FE1F7C2C2DE6AC006D0C8B /* BitkitUITests.xctest */, 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */, + 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -232,6 +269,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" */; @@ -270,6 +329,7 @@ ); dependencies = ( 961058E22C355B5500E1F1D8 /* PBXTargetDependency */, + 4A319B612E8F24F4002B9AC9 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 96A44E912CEF5EA700FBACFF /* Bitkit */, @@ -341,9 +401,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1540; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1540; TargetAttributes = { + 4A319B4E2E8F24F2002B9AC9 = { + CreatedOnToolsVersion = 26.0; + }; 961058DB2C355B5500E1F1D8 = { CreatedOnToolsVersion = 15.4; }; @@ -401,11 +464,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; @@ -460,6 +531,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4A319B4B2E8F24F2002B9AC9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058D82C355B5500E1F1D8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -491,6 +569,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 4A319B612E8F24F4002B9AC9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + targetProxy = 4A319B602E8F24F4002B9AC9 /* PBXContainerItemProxy */; + }; 961058E22C355B5500E1F1D8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 961058DB2C355B5500E1F1D8 /* BitkitNotification */; @@ -509,6 +592,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 = 1; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.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 = 1; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.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 = { @@ -859,6 +1009,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/Services/Widgets/FactsService.swift b/Bitkit/Services/Widgets/FactsService.swift index 5ccfddca3..c5ae7d852 100644 --- a/Bitkit/Services/Widgets/FactsService.swift +++ b/Bitkit/Services/Widgets/FactsService.swift @@ -3,8 +3,13 @@ import Foundation /// Service for managing Bitcoin facts class FactsService { static let shared = FactsService() + + private let appGroupIdentifier = "group.bitkit" - private init() {} + private init() { + // Share facts with widget on initialization + saveFactsForWidget() + } /// Returns a random Bitcoin fact /// - Returns: A Bitcoin fact string @@ -17,6 +22,15 @@ class FactsService { func getAllFacts() -> [String] { return facts } + + /// Saves facts to App Group shared storage for widget access + private func saveFactsForWidget() { + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + return + } + + userDefaults.set(facts, forKey: "widget_facts") + } // MARK: - Private Properties 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/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..78fe7ed34 --- /dev/null +++ b/BitkitWidget/BitkitWidget.swift @@ -0,0 +1,178 @@ +import WidgetKit +import SwiftUI + +// MARK: - Widget Entry + +struct FactsWidgetEntry: TimelineEntry { + let date: Date + let fact: String +} + +// MARK: - Timeline Provider + +struct FactsWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> FactsWidgetEntry { + FactsWidgetEntry( + date: Date(), + fact: "Bitcoin operates without central authority." + ) + } + + func getSnapshot(in context: Context, completion: @escaping (FactsWidgetEntry) -> Void) { + let entry = FactsWidgetEntry( + date: Date(), + fact: WidgetFactsService.shared.getRandomFact() + ) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + var entries: [FactsWidgetEntry] = [] + let currentDate = Date() + + // Create entries for the next 2 hours, one every 15 minutes + for hourOffset in 0..<8 { + let entryDate = Calendar.current.date(byAdding: .minute, value: hourOffset * 15, to: currentDate)! + let entry = FactsWidgetEntry( + date: entryDate, + fact: WidgetFactsService.shared.getRandomFact() + ) + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } +} + +// MARK: - Widget View + +struct BitkitWidgetEntryView: View { + var entry: FactsWidgetProvider.Entry + @Environment(\.widgetFamily) var widgetFamily + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header + HStack { + Image(systemName: "bitcoinsign.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.orange) + + Text("Bitcoin Fact") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + + Spacer() + } + + // Fact text + Text(entry.fact) + .font(fontForFamily()) + .foregroundColor(.white) + .lineLimit(lineLimit()) + .minimumScaleFactor(0.8) + + Spacer() + + // Source footer + HStack { + Spacer() + Text("synonym.to") + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.5)) + } + } + // .padding(16) + .containerBackground(for: .widget) { + // Background gradient + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.1, green: 0.1, blue: 0.15), + Color(red: 0.15, green: 0.15, blue: 0.2) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + + private func fontForFamily() -> Font { + switch widgetFamily { + case .systemSmall: + return .system(size: 14, weight: .medium) + case .systemMedium: + return .system(size: 16, weight: .medium) + case .systemLarge, .systemExtraLarge: + return .system(size: 18, weight: .medium) + @unknown default: + return .system(size: 14, weight: .medium) + } + } + + private func lineLimit() -> Int { + switch widgetFamily { + case .systemSmall: + return 4 + case .systemMedium: + return 3 + case .systemLarge, .systemExtraLarge: + return 8 + @unknown default: + return 4 + } + } +} + +// MARK: - Widget Configuration + +struct BitkitWidget: Widget { + let kind: String = "BitkitWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: FactsWidgetProvider()) { entry in + BitkitWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Facts") + .description("Display interesting Bitcoin facts on your home screen.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +// MARK: - Widget Bundle + +@main +struct BitkitWidgetBundle: WidgetBundle { + var body: some Widget { + BitkitWidget() + } +} + +// MARK: - Preview + +struct BitkitWidget_Previews: PreviewProvider { + static var previews: some View { + Group { + BitkitWidgetEntryView(entry: FactsWidgetEntry( + date: Date(), + fact: "Satoshi Nakamoto mined more than 1M Bitcoin." + )) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .previewDisplayName("Small") + + BitkitWidgetEntryView(entry: FactsWidgetEntry( + date: Date(), + fact: "You don't need permission to use Bitcoin." + )) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + .previewDisplayName("Medium") + + BitkitWidgetEntryView(entry: FactsWidgetEntry( + date: Date(), + fact: "Bitcoin operates without central authority. No company controls Bitcoin." + )) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + .previewDisplayName("Large") + } + } +} diff --git a/BitkitWidget/Info.plist b/BitkitWidget/Info.plist new file mode 100644 index 000000000..d48a60e06 --- /dev/null +++ b/BitkitWidget/Info.plist @@ -0,0 +1,29 @@ + + + + + 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 + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/BitkitWidget/README.md b/BitkitWidget/README.md new file mode 100644 index 000000000..014ba5e22 --- /dev/null +++ b/BitkitWidget/README.md @@ -0,0 +1,55 @@ +# BitkitWidget - iOS Home Screen Widget + +Bitcoin Facts widget for iOS home screen using WidgetKit. + +## Quick Start + +See the main **WIDGET_SETUP.md** file in the project root for detailed setup instructions. + +## Files in this Directory + +- **BitkitWidget.swift** - Main widget implementation with timeline provider and views +- **WidgetFactsService.swift** - Service for managing and providing Bitcoin facts +- **Info.plist** - Widget extension configuration +- **BitkitWidget.entitlements** - App Groups entitlement for data sharing +- **Assets.xcassets/** - Widget-specific assets + +## Architecture + +### Timeline Provider +The `FactsWidgetProvider` creates a timeline of widget entries that update every 15 minutes. + +### Widget Entry +Each `FactsWidgetEntry` contains: +- A timestamp for when it should be displayed +- A Bitcoin fact string + +### Widget View +The `BitkitWidgetEntryView` displays the fact with: +- A gradient background +- Bitcoin icon header +- Fact text (responsive to widget size) +- Source attribution footer + +### Data Sharing +Facts are shared between the main app and widget via App Groups (`group.bitkit`), allowing the widget to display the same facts as the in-app widget. + +## Widget Sizes + +- **Small (2x2)**: Shows 4 lines of text +- **Medium (4x2)**: Shows 3 lines of text +- **Large (4x4)**: Shows 8 lines of text + +## Testing + +1. Build and run the **BitkitWidget** scheme to preview in Xcode +2. Run the main **Bitkit** app, then add the widget to your home screen +3. The widget will update automatically every 15 minutes + +## Future Enhancements + +- Add interactive widget actions (iOS 17+) +- Support for Live Activities +- Additional widget families (extra large, lock screen widgets) +- Configuration options (font size, colors, update frequency) + diff --git a/BitkitWidget/WidgetFactsService.swift b/BitkitWidget/WidgetFactsService.swift new file mode 100644 index 000000000..426052824 --- /dev/null +++ b/BitkitWidget/WidgetFactsService.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Service for managing Bitcoin facts for the widget +/// This is a simplified version that works independently from the main app +class WidgetFactsService { + static let shared = WidgetFactsService() + + private let appGroupIdentifier = "group.bitkit" + private let factsKey = "widget_facts" + + private init() {} + + /// Returns a random Bitcoin fact + func getRandomFact() -> String { + // Try to get facts from App Group shared storage first + if let sharedFacts = getSharedFacts(), !sharedFacts.isEmpty { + return sharedFacts.randomElement() ?? defaultFacts.randomElement()! + } + + // Fallback to default facts + return defaultFacts.randomElement()! + } + + /// Get facts shared from the main app via App Groups + private func getSharedFacts() -> [String]? { + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + return nil + } + + return userDefaults.stringArray(forKey: factsKey) + } + + /// Save facts to App Group shared storage (called from main app) + func saveSharedFacts(_ facts: [String]) { + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + return + } + + userDefaults.set(facts, forKey: factsKey) + } + + // MARK: - Default Facts + + private let defaultFacts = [ + "Satoshi Nakamoto mined more than 1M Bitcoin.", + "You don't need permission to use Bitcoin.", + "You don't need a bank account to use Bitcoin.", + "Bitcoin is a public ledger.", + "Bitcoin can use otherwise wasted energy.", + "Priced in Bitcoin, products can become cheaper over time.", + "Your node, your rules.", + "Bitcoin does not discriminate.", + "About 20% of Bitcoin may be lost forever.", + "A Bitcoin faucet gave out 5 BTC per visitor.", + "Every 210,000 blocks, mining rewards are cut in half.", + "It takes about 10 minutes to mine a new block.", + "The largest transaction was 500,000 bitcoin.", + "Bitcoin is legal tender in El Salvador.", + "Not your keys, not your coins.", + "'Bitcoin' is the network, 'bitcoin' is the currency.", + "Bitcoin was not the first digital currency.", + "Bitcoin was first created with 31,000 lines of code.", + "Bitcoin does not have a CEO.", + "Initially you could send Bitcoin to an IP address.", + "Bitcoin did not always have a block size limit.", + "The first Bitcoin purchase was for a pizza.", + "May 22 is celebrated as Bitcoin Pizza Day.", + "Somebody paid 10,000 bitcoins for pizza.", + "The identity of Bitcoin's inventor is unknown.", + "If you lose your keys, you lose your coins.", + "Bitcoins don't grow on trees.", + "There can only be 21 million bitcoins.", + "Bitcoins are created when a block is mined.", + "One bitcoin is 100,000,000 satoshis.", + "The smallest unit of Bitcoin is a 'satoshi.'", + "Bitcoins live on the blockchain, not in wallets.", + "You can hold keys, but you cannot hold bitcoin.", + "Private keys allow you to sign transactions.", + "Public keys are used to create payment addresses.", + "Satoshi Nakamoto wrote the Bitcoin whitepaper.", + "Satoshi Nakamoto mined the 'genesis' block.", + "The whitepaper was published Oct 31, 2008.", + "The genesis block was mined Jan 3, 2009.", + "It takes energy to mine a new Bitcoin block.", + "Mining a block is solving a cryptographic puzzle.", + "Mining is guessing numbers.", + "The last Bitcoin will be mined in 2140.", + "Bitcoin operates without central authority.", + "No company controls Bitcoin.", + "The block reward halves every four years.", + "Bitcoin inflation rate declines over time.", + "Bitcoin is censorship-resistant.", + "The Bitcoin protocol is trustless.", + "You can verify all bitcoin transactions.", + "The Bitcoin network is open to anyone.", + "Draft of Lightning white paper: Feb 2015.", + "First Lightning payment: May 10, 2017.", + "The Lightning protocol is a payment layer.", + "Lightning enables instant bitcoin payments.", + "Lightning channels are peer-to-peer.", + "Full nodes store the entire transaction history.", + "You can generate a Bitcoin address offline.", + "Bitcoin is natively measured in integers.", + "Technically there are no bitcoins, only sats.", + "The genesis block reward is not spendable.", + "You can count 1 day of blocks on 2 hands.", + "There are enough sats for everyone.", + "More computing power ≠ more bitcoin.", + "Bitcoin doesn't need your personal info.", + "Satoshi considered calling it Netcoin.", + ] +} 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/WIDGET_SETUP.md b/WIDGET_SETUP.md new file mode 100644 index 000000000..37f8c086a --- /dev/null +++ b/WIDGET_SETUP.md @@ -0,0 +1,147 @@ +# iOS Home Screen Widget Setup Guide + +This guide will help you add the Bitcoin Facts widget as an iOS home screen widget using WidgetKit. + +## Overview + +All the necessary widget files have been created in the `BitkitWidget/` directory: +- `BitkitWidget.swift` - Main widget implementation +- `WidgetFactsService.swift` - Service for providing facts to the widget +- `Info.plist` - Widget extension configuration +- `BitkitWidget.entitlements` - App Groups entitlement +- `Assets.xcassets/` - Widget assets + +## Setup Steps in Xcode + +### 1. Add Widget Extension Target + +1. Open `Bitkit.xcodeproj` in Xcode +2. Click on the project in the Project Navigator +3. At the bottom of the Targets list, click the **"+"** button +4. Select **"Widget Extension"** from the template chooser +5. Configure the new target: + - **Product Name**: `BitkitWidget` + - **Include Configuration Intent**: Leave **unchecked** (we don't need configuration) + - Click **Finish** +6. When prompted "Activate BitkitWidget scheme?", click **Activate** + +### 2. Replace Template Files + +Xcode will have created template files. Replace/delete them: + +1. **Delete** the auto-generated files in the `BitkitWidget` folder: + - `BitkitWidget.swift` (template version) + - `BitkitWidgetBundle.swift` (if separate) + - `BitkitWidgetLiveActivity.swift` (if created) + - `AppIntent.swift` (if created) + +2. **Add** the files we created to the BitkitWidget target: + - Right-click on the `BitkitWidget` folder in Xcode + - Select **"Add Files to Bitkit..."** + - Navigate to the `BitkitWidget` folder + - Select: + - `BitkitWidget.swift` + - `WidgetFactsService.swift` + - Make sure **"BitkitWidget"** target is checked + - Click **Add** + +### 3. Configure Target Settings + +#### Bundle Identifier +1. Select the **BitkitWidget** target +2. Go to **General** tab +3. Set **Bundle Identifier** to: `to.bitkit.BitkitWidget` (or match your main app's bundle ID + `.BitkitWidget`) + +#### Deployment Target +1. In the **General** tab +2. Set **Minimum Deployments** to match your main app (iOS 16.0 or higher recommended for widgets) + +#### Entitlements +1. Select the **BitkitWidget** target +2. Go to **Signing & Capabilities** tab +3. Click **"+ Capability"** +4. Add **App Groups** +5. Check/add the app group: `group.bitkit` + +### 4. Update Main App Entitlements (If Needed) + +Make sure the main **Bitkit** target also has the App Groups capability: +1. Select the **Bitkit** target +2. Go to **Signing & Capabilities** tab +3. Verify **App Groups** capability exists with `group.bitkit` + +### 5. Configure Info.plist + +The `BitkitWidget/Info.plist` file should already be configured, but verify: +- `CFBundleDisplayName`: "Bitcoin Facts" +- `NSExtension` → `NSExtensionPointIdentifier`: "com.apple.widgetkit-extension" + +### 6. Build and Run + +1. Select the **BitkitWidget** scheme in Xcode +2. Choose a simulator or device +3. Build and run (Cmd+R) +4. Xcode will launch in widget preview mode +5. You should see the Bitcoin Facts widget in different sizes + +### 7. Test on Device/Simulator + +1. Switch back to the **Bitkit** scheme +2. Run the main app +3. On your home screen, long-press to enter edit mode +4. Tap the **"+"** button in the top-left corner +5. Search for **"Bitkit"** or **"Bitcoin Facts"** +6. Select the Bitcoin Facts widget +7. Choose a size (Small, Medium, or Large) +8. Tap **"Add Widget"** + +## Widget Features + +### Sizes Supported +- **Small**: Shows a single Bitcoin fact (4 lines max) +- **Medium**: Shows a Bitcoin fact (3 lines max) +- **Large**: Shows a Bitcoin fact (8 lines max) + +### Update Frequency +- The widget automatically updates every 15 minutes with a new random fact +- Creates a 2-hour timeline with 8 entries + +### Data Sharing +- The main app shares all Bitcoin facts with the widget via App Groups +- The widget falls back to built-in facts if App Groups aren't accessible + +## Troubleshooting + +### Widget Not Appearing +- Make sure both the main app and widget extension have App Groups enabled +- Verify the app group identifier is exactly `group.bitkit` +- Clean build folder (Cmd+Shift+K) and rebuild + +### Facts Not Updating +- Ensure the main app has been launched at least once to populate shared data +- Check that App Groups entitlement is properly configured +- Try removing and re-adding the widget + +### Build Errors +- Ensure the BitkitWidget target has the correct Deployment Target +- Verify all files are added to the BitkitWidget target (check Target Membership) +- Make sure WidgetKit framework is linked + +## Customization + +You can customize the widget appearance by editing `BitkitWidget.swift`: +- Colors: Modify the `LinearGradient` colors +- Fonts: Adjust the font sizes in `fontForFamily()` +- Layout: Customize the `VStack` spacing and padding +- Update interval: Change the timeline intervals in `getTimeline()` + +## Next Steps + +Consider adding more widget types: +- **Price Widget**: Show current Bitcoin price +- **Balance Widget**: Show wallet balance (with privacy considerations) +- **Activity Widget**: Show recent transactions +- **Block Height Widget**: Show current block height + +Each would follow the same pattern as the Facts widget. + From 08347c93ab5ba30f7527c4d8ffdf000d4c9dc9d6 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Wed, 8 Apr 2026 15:20:44 +0200 Subject: [PATCH 02/10] WIP --- BitkitWidget/BitkitWidget.swift | 42 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 78fe7ed34..5fd7e08ae 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -50,6 +50,7 @@ struct FactsWidgetProvider: TimelineProvider { struct BitkitWidgetEntryView: View { var entry: FactsWidgetProvider.Entry @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -57,11 +58,12 @@ struct BitkitWidgetEntryView: View { HStack { Image(systemName: "bitcoinsign.circle.fill") .font(.system(size: 20)) - .foregroundColor(.orange) + .foregroundColor(iconColor) + .widgetAccentable() Text("Bitcoin Fact") .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.white.opacity(0.9)) + .foregroundColor(headerColor) Spacer() } @@ -69,7 +71,7 @@ struct BitkitWidgetEntryView: View { // Fact text Text(entry.fact) .font(fontForFamily()) - .foregroundColor(.white) + .foregroundColor(factColor) .lineLimit(lineLimit()) .minimumScaleFactor(0.8) @@ -80,12 +82,19 @@ struct BitkitWidgetEntryView: View { Spacer() Text("synonym.to") .font(.system(size: 10)) - .foregroundColor(.white.opacity(0.5)) + .foregroundColor(footerColor) } } // .padding(16) .containerBackground(for: .widget) { - // Background gradient + backgroundView + } + } + + @ViewBuilder + private var backgroundView: some View { + if widgetRenderingMode == .fullColor { + // Keep custom styling only in full-color mode. LinearGradient( gradient: Gradient(colors: [ Color(red: 0.1, green: 0.1, blue: 0.15), @@ -94,8 +103,27 @@ struct BitkitWidgetEntryView: View { startPoint: .topLeading, endPoint: .bottomTrailing ) + } else { + // Let the system provide tinted/Liquid Glass treatment. + Color.clear } } + + private var iconColor: Color { + widgetRenderingMode == .fullColor ? .orange : .primary + } + + private var headerColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.9) : .primary + } + + private var factColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var footerColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.5) : .secondary + } private func fontForFamily() -> Font { switch widgetFamily { @@ -105,6 +133,8 @@ struct BitkitWidgetEntryView: View { return .system(size: 16, weight: .medium) case .systemLarge, .systemExtraLarge: return .system(size: 18, weight: .medium) + case .accessoryCircular, .accessoryRectangular, .accessoryInline: + return .system(size: 14, weight: .medium) @unknown default: return .system(size: 14, weight: .medium) } @@ -118,6 +148,8 @@ struct BitkitWidgetEntryView: View { return 3 case .systemLarge, .systemExtraLarge: return 8 + case .accessoryCircular, .accessoryRectangular, .accessoryInline: + return 1 @unknown default: return 4 } From 9fd90cd6e825e047ff8f0d0f10875500c58d8772 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 1 May 2026 15:02:48 +0200 Subject: [PATCH 03/10] WIP --- Bitkit.xcodeproj/project.pbxproj | 28 ++- Bitkit/Components/Widgets/BlocksWidget.swift | 64 +----- Bitkit/Models/BlocksWidgetOptions.swift | 62 ++++++ .../BlocksHomeScreenWidgetOptionsStore.swift | 31 +++ Bitkit/Services/Widgets/BlocksService.swift | 46 +++- Bitkit/Styles/TextStyle.swift | 8 +- Bitkit/ViewModels/WidgetsViewModel.swift | 9 + .../blocks-widget.imageset/Contents.json | 12 ++ .../blocks-widget.imageset/blocks-widget.pdf | Bin 0 -> 4766 bytes .../btc.imageset/Contents.json | 12 ++ .../Assets.xcassets/btc.imageset/btc.pdf | Bin 0 -> 11533 bytes .../facts-widget.imageset/Contents.json | 12 ++ .../facts-widget.imageset/facts-widget.pdf | Bin 0 -> 4819 bytes BitkitWidget/BitkitWidget.swift | 204 +----------------- BitkitWidget/BlocksHomeScreenWidget.swift | 204 ++++++++++++++++++ BitkitWidget/FactsHomeScreenWidget.swift | 153 +++++++++++++ BitkitWidget/Info.plist | 13 +- BitkitWidget/README.md | 14 +- 18 files changed, 578 insertions(+), 294 deletions(-) create mode 100644 Bitkit/Models/BlocksWidgetOptions.swift create mode 100644 Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift create mode 100644 BitkitWidget/Assets.xcassets/blocks-widget.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/blocks-widget.imageset/blocks-widget.pdf create mode 100644 BitkitWidget/Assets.xcassets/btc.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf create mode 100644 BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/facts-widget.imageset/facts-widget.pdf create mode 100644 BitkitWidget/BlocksHomeScreenWidget.swift create mode 100644 BitkitWidget/FactsHomeScreenWidget.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 6cc752e5b..be387912f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -163,11 +163,29 @@ ); 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, + Fonts/InterTight-SemiBold.ttf, + Models/BlocksWidgetOptions.swift, + Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift, + Services/Widgets/BlocksService.swift, + Styles/Colors.swift, + Styles/Fonts.swift, + Styles/TextStyle.swift, + ); + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 4A319B542E8F24F2002B9AC9 /* BitkitWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BitkitWidget; sourceTree = ""; }; - 96A44E912CEF5EA700FBACFF /* Bitkit */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3E2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3F2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bitkit; 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 = ""; }; @@ -599,7 +617,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 186; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -611,7 +629,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -632,7 +650,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 186; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -644,7 +662,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index 5b6cda4bf..11e7536ad 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -1,19 +1,5 @@ import SwiftUI -/// Options for configuring the BlocksWidget -struct BlocksWidgetOptions: Codable, Equatable { - var height: Bool = true - var time: Bool = true - var date: Bool = true - var transactionCount: Bool = false - var size: Bool = false - var weight: Bool = false - var difficulty: Bool = false - var hash: Bool = false - var merkleRoot: Bool = false - var showSource: Bool = false -} - /// A widget that displays Bitcoin block information struct BlocksWidget: View { /// Configuration options for the widget @@ -39,19 +25,6 @@ struct BlocksWidget: View { self.onEditingEnd = onEditingEnd } - /// Mapping of block data keys to display labels - private let blocksMapping: [String: String] = [ - "height": "Block", - "time": "Time", - "date": "Date", - "transactionCount": "Transactions", - "size": "Size", - "weight": "Weight", - "difficulty": "Difficulty", - "hash": "Hash", - "merkleRoot": "Merkle Root", - ] - var body: some View { BaseWidget( type: .blocks, @@ -66,7 +39,7 @@ struct BlocksWidget: View { } else if let data = viewModel.blockData { VStack(spacing: 0) { // Display block data rows based on options - ForEach(getDisplayableData(data), id: \.key) { item in + ForEach(options.displayRows(for: data), id: \.key) { item in HStack(spacing: 0) { HStack { BodySSBText(item.label, textColor: .textSecondary) @@ -95,41 +68,6 @@ struct BlocksWidget: View { viewModel.startUpdates() } } - - /// Get displayable data based on current options - private func getDisplayableData(_ data: BlockData) -> [(key: String, label: String, value: String)] { - var items: [(key: String, label: String, value: String)] = [] - - if options.height { - items.append((key: "height", label: blocksMapping["height"]!, value: data.height)) - } - if options.time { - items.append((key: "time", label: blocksMapping["time"]!, value: data.time)) - } - if options.date { - items.append((key: "date", label: blocksMapping["date"]!, value: data.date)) - } - if options.transactionCount { - items.append((key: "transactionCount", label: blocksMapping["transactionCount"]!, value: data.transactionCount)) - } - if options.size { - items.append((key: "size", label: blocksMapping["size"]!, value: data.size)) - } - if options.weight { - items.append((key: "weight", label: blocksMapping["weight"]!, value: data.weight)) - } - if options.difficulty { - items.append((key: "difficulty", label: blocksMapping["difficulty"]!, value: data.difficulty)) - } - if options.hash { - items.append((key: "hash", label: blocksMapping["hash"]!, value: data.hash)) - } - if options.merkleRoot { - items.append((key: "merkleRoot", label: blocksMapping["merkleRoot"]!, value: data.merkleRoot)) - } - - return items - } } #Preview { diff --git a/Bitkit/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift new file mode 100644 index 000000000..36d09d9f8 --- /dev/null +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Options for configuring the in-app and home screen blocks widgets (shared via App Group for the extension). +struct BlocksWidgetOptions: Codable, Equatable { + var height: Bool = true + var time: Bool = true + var date: Bool = true + var transactionCount: Bool = false + var size: Bool = false + var weight: Bool = false + var difficulty: Bool = false + var hash: Bool = false + var merkleRoot: Bool = false + var showSource: Bool = false + + private static let fieldLabels: [String: String] = [ + "height": "Block", + "time": "Time", + "date": "Date", + "transactionCount": "Transactions", + "size": "Size", + "weight": "Weight", + "difficulty": "Difficulty", + "hash": "Hash", + "merkleRoot": "Merkle Root", + ] + + /// Rows to show, in stable order (matches in-app `BlocksWidget`). + func displayRows(for data: BlockData) -> [(key: String, label: String, value: String)] { + var items: [(key: String, label: String, value: String)] = [] + + if height { + items.append((key: "height", label: Self.fieldLabels["height"]!, value: data.height)) + } + if time { + items.append((key: "time", label: Self.fieldLabels["time"]!, value: data.time)) + } + if date { + items.append((key: "date", label: Self.fieldLabels["date"]!, value: data.date)) + } + if transactionCount { + items.append((key: "transactionCount", label: Self.fieldLabels["transactionCount"]!, value: data.transactionCount)) + } + if size { + items.append((key: "size", label: Self.fieldLabels["size"]!, value: data.size)) + } + if weight { + items.append((key: "weight", label: Self.fieldLabels["weight"]!, value: data.weight)) + } + if difficulty { + items.append((key: "difficulty", label: Self.fieldLabels["difficulty"]!, value: data.difficulty)) + } + if hash { + items.append((key: "hash", label: Self.fieldLabels["hash"]!, value: data.hash)) + } + if merkleRoot { + items.append((key: "merkleRoot", label: Self.fieldLabels["merkleRoot"]!, value: data.merkleRoot)) + } + + return items + } +} diff --git a/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..3b2557e83 --- /dev/null +++ b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,31 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app blocks widget options into the App Group so the WidgetKit extension can read them. +enum BlocksHomeScreenWidgetOptionsStore { + private static let suiteName = "group.bitkit" + private static let key = "home_screen_blocks_widget_options_v1" + + static func save(_ options: BlocksWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> BlocksWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(BlocksWidgetOptions.self, from: data) + else { + return BlocksWidgetOptions() + } + return options + } + + /// Call after updating options so the home screen widget timeline refreshes (main app only). + static func reloadHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: BlocksService.blocksHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/BlocksService.swift b/Bitkit/Services/Widgets/BlocksService.swift index 4f00895c3..3ce69e1df 100644 --- a/Bitkit/Services/Widgets/BlocksService.swift +++ b/Bitkit/Services/Widgets/BlocksService.swift @@ -1,9 +1,14 @@ import Foundation +import WidgetKit /// Service for fetching and caching Bitcoin block data class BlocksService { static let shared = BlocksService() - private let cache = UserDefaults.standard + + /// WidgetKit `kind` for the home screen blocks widget (keep in sync with `BitkitBlocksWidget`). + static let blocksHomeScreenWidgetKind = "BitkitBlocksWidget" + + private static let appGroupSuiteName = "group.bitkit" private let cacheKey = "blocks_widget_cache" private let baseUrl = "https://mempool.space/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes @@ -86,13 +91,18 @@ class BlocksService { } } - /// Caches block data to UserDefaults + /// Caches block data to the App Group `UserDefaults` (shared with the widget extension). /// - Parameter data: Block data to cache func cacheData(_ data: BlockData) { do { let encoder = JSONEncoder() let encoded = try encoder.encode(data) - cache.set(encoded, forKey: cacheKey) + if let group = UserDefaults(suiteName: Self.appGroupSuiteName) { + group.set(encoded, forKey: cacheKey) + } else { + UserDefaults.standard.set(encoded, forKey: cacheKey) + } + reloadBlocksHomeScreenWidgetIfNeeded() } catch { // Handle silently } @@ -101,16 +111,32 @@ class BlocksService { /// Retrieves cached block data /// - Returns: Block data if available func getCachedData() -> BlockData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil + if let group = UserDefaults(suiteName: Self.appGroupSuiteName), + let data = group.data(forKey: cacheKey), + let decoded = Self.decodeCachedBlockData(data) + { + return decoded } - do { - let decoder = JSONDecoder() - return try decoder.decode(BlockData.self, from: data) - } catch { - return nil + // One-time migration from pre–App Group cache + if let data = UserDefaults.standard.data(forKey: cacheKey), + let decoded = Self.decodeCachedBlockData(data) + { + cacheData(decoded) + UserDefaults.standard.removeObject(forKey: cacheKey) + return decoded } + + return nil + } + + private static func decodeCachedBlockData(_ data: Data) -> BlockData? { + try? JSONDecoder().decode(BlockData.self, from: data) + } + + private func reloadBlocksHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: Self.blocksHomeScreenWidgetKind) } /// Formats raw block info into display-friendly format diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 2624981a2..d656cd6bc 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,11 +636,11 @@ 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) - DisplayText("Display Style With An\nAccent Over Here") + DisplayText("Display Text With An\nAccent Over Here") .background(Color.green.opacity(0.1)) .padding(.bottom, 20) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index cd954ff90..0f925559c 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -301,6 +301,7 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } + syncBlocksOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -310,5 +311,13 @@ class WidgetsViewModel: ObservableObject { } catch { print("Failed to persist widgets: \(error)") } + syncBlocksOptionsToHomeScreenWidget() + } + + /// Keeps home screen WidgetKit blocks widget in sync with in-app blocks widget options (App Group). + private func syncBlocksOptionsToHomeScreenWidget() { + let options: BlocksWidgetOptions = getOptions(for: .blocks, as: BlocksWidgetOptions.self) + BlocksHomeScreenWidgetOptionsStore.save(options) + BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } } diff --git a/BitkitWidget/Assets.xcassets/blocks-widget.imageset/Contents.json b/BitkitWidget/Assets.xcassets/blocks-widget.imageset/Contents.json new file mode 100644 index 000000000..4b14f265b --- /dev/null +++ b/BitkitWidget/Assets.xcassets/blocks-widget.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "blocks-widget.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/blocks-widget.imageset/blocks-widget.pdf b/BitkitWidget/Assets.xcassets/blocks-widget.imageset/blocks-widget.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0297e9a4f44ec32e5c79e7a87f25d67feb7bf298 GIT binary patch literal 4766 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 Vq2aFRAC(Xm7J~xW+2u7A{s(fITNVHS literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c40a91328362b5fb2c56574a782e4ff114855f4c GIT binary patch literal 11533 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 TL7*)<6bgYNfP#XmdTRdxv>wJd literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json b/BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json new file mode 100644 index 000000000..fe6653d7c --- /dev/null +++ b/BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "facts-widget.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/facts-widget.imageset/facts-widget.pdf b/BitkitWidget/Assets.xcassets/facts-widget.imageset/facts-widget.pdf new file mode 100644 index 0000000000000000000000000000000000000000..195d1eb3d004a07584eb3c079a3bb145c92a3d83 GIT binary patch literal 4819 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 Vl#M6qXC}nJqLP4X*OYXW{|6kpYT*C? literal 0 HcmV?d00001 diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 5fd7e08ae..6eee31fef 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -1,210 +1,12 @@ -import WidgetKit import SwiftUI - -// MARK: - Widget Entry - -struct FactsWidgetEntry: TimelineEntry { - let date: Date - let fact: String -} - -// MARK: - Timeline Provider - -struct FactsWidgetProvider: TimelineProvider { - func placeholder(in context: Context) -> FactsWidgetEntry { - FactsWidgetEntry( - date: Date(), - fact: "Bitcoin operates without central authority." - ) - } - - func getSnapshot(in context: Context, completion: @escaping (FactsWidgetEntry) -> Void) { - let entry = FactsWidgetEntry( - date: Date(), - fact: WidgetFactsService.shared.getRandomFact() - ) - completion(entry) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - var entries: [FactsWidgetEntry] = [] - let currentDate = Date() - - // Create entries for the next 2 hours, one every 15 minutes - for hourOffset in 0..<8 { - let entryDate = Calendar.current.date(byAdding: .minute, value: hourOffset * 15, to: currentDate)! - let entry = FactsWidgetEntry( - date: entryDate, - fact: WidgetFactsService.shared.getRandomFact() - ) - entries.append(entry) - } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) - } -} - -// MARK: - Widget View - -struct BitkitWidgetEntryView: View { - var entry: FactsWidgetProvider.Entry - @Environment(\.widgetFamily) var widgetFamily - @Environment(\.widgetRenderingMode) var widgetRenderingMode - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header - HStack { - Image(systemName: "bitcoinsign.circle.fill") - .font(.system(size: 20)) - .foregroundColor(iconColor) - .widgetAccentable() - - Text("Bitcoin Fact") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(headerColor) - - Spacer() - } - - // Fact text - Text(entry.fact) - .font(fontForFamily()) - .foregroundColor(factColor) - .lineLimit(lineLimit()) - .minimumScaleFactor(0.8) - - Spacer() - - // Source footer - HStack { - Spacer() - Text("synonym.to") - .font(.system(size: 10)) - .foregroundColor(footerColor) - } - } - // .padding(16) - .containerBackground(for: .widget) { - backgroundView - } - } - - @ViewBuilder - private var backgroundView: some View { - if widgetRenderingMode == .fullColor { - // Keep custom styling only in full-color mode. - LinearGradient( - gradient: Gradient(colors: [ - Color(red: 0.1, green: 0.1, blue: 0.15), - Color(red: 0.15, green: 0.15, blue: 0.2) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } else { - // Let the system provide tinted/Liquid Glass treatment. - Color.clear - } - } - - private var iconColor: Color { - widgetRenderingMode == .fullColor ? .orange : .primary - } - - private var headerColor: Color { - widgetRenderingMode == .fullColor ? .white.opacity(0.9) : .primary - } - - private var factColor: Color { - widgetRenderingMode == .fullColor ? .white : .primary - } - - private var footerColor: Color { - widgetRenderingMode == .fullColor ? .white.opacity(0.5) : .secondary - } - - private func fontForFamily() -> Font { - switch widgetFamily { - case .systemSmall: - return .system(size: 14, weight: .medium) - case .systemMedium: - return .system(size: 16, weight: .medium) - case .systemLarge, .systemExtraLarge: - return .system(size: 18, weight: .medium) - case .accessoryCircular, .accessoryRectangular, .accessoryInline: - return .system(size: 14, weight: .medium) - @unknown default: - return .system(size: 14, weight: .medium) - } - } - - private func lineLimit() -> Int { - switch widgetFamily { - case .systemSmall: - return 4 - case .systemMedium: - return 3 - case .systemLarge, .systemExtraLarge: - return 8 - case .accessoryCircular, .accessoryRectangular, .accessoryInline: - return 1 - @unknown default: - return 4 - } - } -} - -// MARK: - Widget Configuration - -struct BitkitWidget: Widget { - let kind: String = "BitkitWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: FactsWidgetProvider()) { entry in - BitkitWidgetEntryView(entry: entry) - } - .configurationDisplayName("Bitcoin Facts") - .description("Display interesting Bitcoin facts on your home screen.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) - } -} +import WidgetKit // MARK: - Widget Bundle @main struct BitkitWidgetBundle: WidgetBundle { var body: some Widget { - BitkitWidget() - } -} - -// MARK: - Preview - -struct BitkitWidget_Previews: PreviewProvider { - static var previews: some View { - Group { - BitkitWidgetEntryView(entry: FactsWidgetEntry( - date: Date(), - fact: "Satoshi Nakamoto mined more than 1M Bitcoin." - )) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - .previewDisplayName("Small") - - BitkitWidgetEntryView(entry: FactsWidgetEntry( - date: Date(), - fact: "You don't need permission to use Bitcoin." - )) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - .previewDisplayName("Medium") - - BitkitWidgetEntryView(entry: FactsWidgetEntry( - date: Date(), - fact: "Bitcoin operates without central authority. No company controls Bitcoin." - )) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - .previewDisplayName("Large") - } + BitkitFactsWidget() + BitkitBlocksWidget() } } diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift new file mode 100644 index 000000000..03c928e7a --- /dev/null +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -0,0 +1,204 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct BlocksWidgetEntry: TimelineEntry { + let date: Date + let blockData: BlockData? + /// True when the timeline could not load data and there is nothing in cache. + let showsError: Bool + /// Mirrored from in-app blocks widget settings (App Group). + let options: BlocksWidgetOptions +} + +// MARK: - Timeline Provider + +struct BlocksWidgetProvider: TimelineProvider { + static let previewBlockData = BlockData( + hash: "0000000000000000000000000000000000000000000000000000000000000000", + difficulty: "0.00", + size: "0 KB", + weight: "0 MWU", + height: "900,000", + time: "12:00:00 PM", + date: "4/10/26", + transactionCount: "1024", + merkleRoot: "0000000000000000000000000000000000000000000000000000000000000000" + ) + + func placeholder(in _: Context) -> BlocksWidgetEntry { + BlocksWidgetEntry(date: Date(), blockData: Self.previewBlockData, showsError: false, options: BlocksWidgetOptions()) + } + + func getSnapshot(in context: Context, completion: @escaping (BlocksWidgetEntry) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(BlocksWidgetEntry(date: Date(), blockData: Self.previewBlockData, showsError: false, options: options)) + return + } + let cached = BlocksService.shared.getCachedData() + completion(BlocksWidgetEntry(date: Date(), blockData: cached, showsError: false, options: options)) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + Task { + let entry: BlocksWidgetEntry + do { + let data = try await BlocksService.shared.fetchBlockData(returnCachedImmediately: false) + entry = BlocksWidgetEntry(date: Date(), blockData: data, showsError: false, options: options) + } catch { + let cached = BlocksService.shared.getCachedData() + entry = BlocksWidgetEntry(date: Date(), blockData: cached, showsError: cached == nil, options: options) + } + + let nextRefresh = Calendar.current.date(byAdding: .minute, value: 20, to: Date()) ?? Date().addingTimeInterval(1200) + let timeline = Timeline(entries: [entry], policy: .after(nextRefresh)) + completion(timeline) + } + } +} + +// MARK: - View + +struct BlocksHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: BlocksWidgetProvider.Entry + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // HStack { + // Image("blocks-widget") + // .resizable() + // .frame(width: 32, height: 32) + + // BodyMSBText("Latest block", textColor: titleColor) + // .lineLimit(1) + + // Spacer() + // } + + if entry.showsError, entry.blockData == nil { + Text("Couldn’t load block data.") + .font(Fonts.medium(size: bodyFontSize)) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else if let data = entry.blockData { + blockDataContent(data: data) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .containerBackground(for: .widget) { + backgroundView + } + } + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + @ViewBuilder + private func blockDataContent(data: BlockData) -> some View { + let allRows = entry.options.displayRows(for: data) + let visibleRows = Array(allRows.prefix(maxVisibleBlockRows)) + + VStack(spacing: 0) { + if visibleRows.isEmpty { + Text("Choose fields in Bitkit (blocks widget).") + .font(Fonts.medium(size: bodyFontSize)) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ForEach(visibleRows, id: \.key) { item in + blockRow(label: item.label, value: item.value) + } + + if allRows.count > visibleRows.count { + CaptionBText("+\(allRows.count - visibleRows.count) more in Bitkit", textColor: secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + } + } + + Spacer(minLength: 0) + + if entry.options.showSource, !visibleRows.isEmpty { + HStack { + Spacer() + CaptionBText("mempool.space", textColor: secondaryTextColor) + } + } + } + + private var secondaryTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary + } + + private var valueTextColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var bodyFontSize: CGFloat { + switch widgetFamily { + case .systemSmall: 14 + case .systemMedium: 15 + case .systemLarge, .systemExtraLarge: 16 + default: 14 + } + } + + /// Home screen widgets do not scroll; cap rows and point users to Bitkit for the rest. + private var maxVisibleBlockRows: Int { + switch widgetFamily { + case .systemSmall: 3 + case .systemMedium: 4 + case .systemLarge, .systemExtraLarge: 11 + default: 4 + } + } + + private func blockRow(label: String, value: String) -> some View { + HStack(spacing: 0) { + BodySSBText(label, textColor: secondaryTextColor) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(value) + .font(Fonts.semiBold(size: bodyFontSize)) + .foregroundColor(valueTextColor) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(minHeight: rowMinHeight) + } + + private var rowMinHeight: CGFloat { + switch widgetFamily { + case .systemSmall: 22 + case .systemMedium, .systemLarge, .systemExtraLarge: 26 + default: 22 + } + } +} + +// MARK: - Widget Configuration + +struct BitkitBlocksWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: BlocksService.blocksHomeScreenWidgetKind, provider: BlocksWidgetProvider()) { entry in + BlocksHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Blocks") + .description("Latest block data from the Bitcoin chain. Rows match the blocks widget in Bitkit.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/BitkitWidget/FactsHomeScreenWidget.swift b/BitkitWidget/FactsHomeScreenWidget.swift new file mode 100644 index 000000000..65d2d2538 --- /dev/null +++ b/BitkitWidget/FactsHomeScreenWidget.swift @@ -0,0 +1,153 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct FactsWidgetEntry: TimelineEntry { + let date: Date + let fact: String +} + +// MARK: - Timeline Provider + +struct FactsWidgetProvider: TimelineProvider { + /// Stable copy for the widget gallery / `isPreview` snapshots (fast, deterministic). + private static let galleryPreviewFact = "Bitcoin operates without central authority." + + func placeholder(in _: Context) -> FactsWidgetEntry { + FactsWidgetEntry( + date: Date(), + fact: Self.galleryPreviewFact + ) + } + + func getSnapshot(in context: Context, completion: @escaping (FactsWidgetEntry) -> Void) { + if context.isPreview { + completion(FactsWidgetEntry(date: Date(), fact: Self.galleryPreviewFact)) + return + } + let entry = FactsWidgetEntry( + date: Date(), + fact: WidgetFactsService.shared.getRandomFact() + ) + completion(entry) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + var entries: [FactsWidgetEntry] = [] + let currentDate = Date() + + for hourOffset in 0 ..< 8 { + let entryDate = Calendar.current.date(byAdding: .minute, value: hourOffset * 15, to: currentDate)! + let entry = FactsWidgetEntry( + date: entryDate, + fact: WidgetFactsService.shared.getRandomFact() + ) + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } +} + +// MARK: - View + +struct FactsHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: FactsWidgetProvider.Entry + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // HStack { + // Image("facts-widget") + // .resizable() + // .frame(width: 32, height: 32) + + // BodyMSBText("Bitcoin Fact", textColor: titleColor) + // .lineLimit(1) + + // Spacer() + // } + + Text(entry.fact) + .font(fontForFamily()) + .foregroundColor(factColor) + .lineLimit(lineLimit()) + .minimumScaleFactor(0.8) + + Spacer() + + HStack { + Image("btc") + .resizable() + .frame(width: 32, height: 32) + + Spacer() + + CaptionBText("synonym.to", textColor: secondaryTextColor) + } + } + .containerBackground(for: .widget) { + backgroundView + } + } + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var titleColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.9) : .primary + } + + private var factColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var secondaryTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary + } + + private func fontForFamily() -> Font { + switch widgetFamily { + case .systemSmall: Fonts.semiBold(size: 17) + case .systemMedium, .systemLarge, .systemExtraLarge: Fonts.bold(size: 22) + case .accessoryCircular, .accessoryRectangular, .accessoryInline: Fonts.medium(size: 14) + @unknown default: Fonts.medium(size: 14) + } + } + + private func lineLimit() -> Int { + switch widgetFamily { + case .systemSmall: + return 4 + case .systemMedium, .systemLarge, .systemExtraLarge: + // Large matches medium: we only list `.systemLarge` in `supportedFamilies` so the add-widget gallery can render a real preview (omitting + // it often shows skeletons). + return 3 + case .accessoryCircular, .accessoryRectangular, .accessoryInline: + return 1 + @unknown default: + return 4 + } + } +} + +// MARK: - Widget Configuration + +/// Home screen “Bitcoin Facts” widget. `kind` must stay `BitkitWidget` so existing placements keep working. +struct BitkitFactsWidget: Widget { + let kind: String = "BitkitWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: FactsWidgetProvider()) { entry in + FactsHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Facts") + .description("Display interesting Bitcoin facts on your home screen.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/BitkitWidget/Info.plist b/BitkitWidget/Info.plist index d48a60e06..b12fe2e15 100644 --- a/BitkitWidget/Info.plist +++ b/BitkitWidget/Info.plist @@ -17,13 +17,22 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(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/README.md b/BitkitWidget/README.md index 014ba5e22..ec03329c7 100644 --- a/BitkitWidget/README.md +++ b/BitkitWidget/README.md @@ -8,7 +8,9 @@ See the main **WIDGET_SETUP.md** file in the project root for detailed setup ins ## Files in this Directory -- **BitkitWidget.swift** - Main widget implementation with timeline provider and views +- **BitkitWidget.swift** - `WidgetBundle` entry point (`@main`) +- **FactsHomeScreenWidget.swift** - Bitcoin Facts timeline, view, and `BitkitFactsWidget` configuration +- **BlocksHomeScreenWidget.swift** - Bitcoin blocks timeline, view, and `BitkitBlocksWidget` configuration - **WidgetFactsService.swift** - Service for managing and providing Bitcoin facts - **Info.plist** - Widget extension configuration - **BitkitWidget.entitlements** - App Groups entitlement for data sharing @@ -25,8 +27,8 @@ Each `FactsWidgetEntry` contains: - A Bitcoin fact string ### Widget View -The `BitkitWidgetEntryView` displays the fact with: -- A gradient background +`FactsHomeScreenWidgetEntryView` displays the fact with: +- Background tuned for full-color vs accented (Liquid Glass) mode - Bitcoin icon header - Fact text (responsive to widget size) - Source attribution footer @@ -34,12 +36,6 @@ The `BitkitWidgetEntryView` displays the fact with: ### Data Sharing Facts are shared between the main app and widget via App Groups (`group.bitkit`), allowing the widget to display the same facts as the in-app widget. -## Widget Sizes - -- **Small (2x2)**: Shows 4 lines of text -- **Medium (4x2)**: Shows 3 lines of text -- **Large (4x4)**: Shows 8 lines of text - ## Testing 1. Build and run the **BitkitWidget** scheme to preview in Xcode From 23a74cf8b89f239905931a3f8c1a7d828e496d28 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 10:27:34 -0300 Subject: [PATCH 04/10] feat: OS widgets foundation --- Bitkit/Components/Widgets/BlocksWidget.swift | 64 +++++++++++++++++++- Bitkit/Services/Widgets/BlocksService.swift | 46 +++----------- Bitkit/Services/Widgets/FactsService.swift | 16 +---- Bitkit/Styles/TextStyle.swift | 8 +-- Bitkit/ViewModels/WidgetsViewModel.swift | 9 --- 5 files changed, 78 insertions(+), 65 deletions(-) diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index 11e7536ad..5b6cda4bf 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -1,5 +1,19 @@ import SwiftUI +/// Options for configuring the BlocksWidget +struct BlocksWidgetOptions: Codable, Equatable { + var height: Bool = true + var time: Bool = true + var date: Bool = true + var transactionCount: Bool = false + var size: Bool = false + var weight: Bool = false + var difficulty: Bool = false + var hash: Bool = false + var merkleRoot: Bool = false + var showSource: Bool = false +} + /// A widget that displays Bitcoin block information struct BlocksWidget: View { /// Configuration options for the widget @@ -25,6 +39,19 @@ struct BlocksWidget: View { self.onEditingEnd = onEditingEnd } + /// Mapping of block data keys to display labels + private let blocksMapping: [String: String] = [ + "height": "Block", + "time": "Time", + "date": "Date", + "transactionCount": "Transactions", + "size": "Size", + "weight": "Weight", + "difficulty": "Difficulty", + "hash": "Hash", + "merkleRoot": "Merkle Root", + ] + var body: some View { BaseWidget( type: .blocks, @@ -39,7 +66,7 @@ struct BlocksWidget: View { } else if let data = viewModel.blockData { VStack(spacing: 0) { // Display block data rows based on options - ForEach(options.displayRows(for: data), id: \.key) { item in + ForEach(getDisplayableData(data), id: \.key) { item in HStack(spacing: 0) { HStack { BodySSBText(item.label, textColor: .textSecondary) @@ -68,6 +95,41 @@ struct BlocksWidget: View { viewModel.startUpdates() } } + + /// Get displayable data based on current options + private func getDisplayableData(_ data: BlockData) -> [(key: String, label: String, value: String)] { + var items: [(key: String, label: String, value: String)] = [] + + if options.height { + items.append((key: "height", label: blocksMapping["height"]!, value: data.height)) + } + if options.time { + items.append((key: "time", label: blocksMapping["time"]!, value: data.time)) + } + if options.date { + items.append((key: "date", label: blocksMapping["date"]!, value: data.date)) + } + if options.transactionCount { + items.append((key: "transactionCount", label: blocksMapping["transactionCount"]!, value: data.transactionCount)) + } + if options.size { + items.append((key: "size", label: blocksMapping["size"]!, value: data.size)) + } + if options.weight { + items.append((key: "weight", label: blocksMapping["weight"]!, value: data.weight)) + } + if options.difficulty { + items.append((key: "difficulty", label: blocksMapping["difficulty"]!, value: data.difficulty)) + } + if options.hash { + items.append((key: "hash", label: blocksMapping["hash"]!, value: data.hash)) + } + if options.merkleRoot { + items.append((key: "merkleRoot", label: blocksMapping["merkleRoot"]!, value: data.merkleRoot)) + } + + return items + } } #Preview { diff --git a/Bitkit/Services/Widgets/BlocksService.swift b/Bitkit/Services/Widgets/BlocksService.swift index 3ce69e1df..4f00895c3 100644 --- a/Bitkit/Services/Widgets/BlocksService.swift +++ b/Bitkit/Services/Widgets/BlocksService.swift @@ -1,14 +1,9 @@ import Foundation -import WidgetKit /// Service for fetching and caching Bitcoin block data class BlocksService { static let shared = BlocksService() - - /// WidgetKit `kind` for the home screen blocks widget (keep in sync with `BitkitBlocksWidget`). - static let blocksHomeScreenWidgetKind = "BitkitBlocksWidget" - - private static let appGroupSuiteName = "group.bitkit" + private let cache = UserDefaults.standard private let cacheKey = "blocks_widget_cache" private let baseUrl = "https://mempool.space/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes @@ -91,18 +86,13 @@ class BlocksService { } } - /// Caches block data to the App Group `UserDefaults` (shared with the widget extension). + /// Caches block data to UserDefaults /// - Parameter data: Block data to cache func cacheData(_ data: BlockData) { do { let encoder = JSONEncoder() let encoded = try encoder.encode(data) - if let group = UserDefaults(suiteName: Self.appGroupSuiteName) { - group.set(encoded, forKey: cacheKey) - } else { - UserDefaults.standard.set(encoded, forKey: cacheKey) - } - reloadBlocksHomeScreenWidgetIfNeeded() + cache.set(encoded, forKey: cacheKey) } catch { // Handle silently } @@ -111,32 +101,16 @@ class BlocksService { /// Retrieves cached block data /// - Returns: Block data if available func getCachedData() -> BlockData? { - if let group = UserDefaults(suiteName: Self.appGroupSuiteName), - let data = group.data(forKey: cacheKey), - let decoded = Self.decodeCachedBlockData(data) - { - return decoded + guard let data = cache.data(forKey: cacheKey) else { + return nil } - // One-time migration from pre–App Group cache - if let data = UserDefaults.standard.data(forKey: cacheKey), - let decoded = Self.decodeCachedBlockData(data) - { - cacheData(decoded) - UserDefaults.standard.removeObject(forKey: cacheKey) - return decoded + do { + let decoder = JSONDecoder() + return try decoder.decode(BlockData.self, from: data) + } catch { + return nil } - - return nil - } - - private static func decodeCachedBlockData(_ data: Data) -> BlockData? { - try? JSONDecoder().decode(BlockData.self, from: data) - } - - private func reloadBlocksHomeScreenWidgetIfNeeded() { - guard Bundle.main.bundleURL.pathExtension != "appex" else { return } - WidgetCenter.shared.reloadTimelines(ofKind: Self.blocksHomeScreenWidgetKind) } /// Formats raw block info into display-friendly format diff --git a/Bitkit/Services/Widgets/FactsService.swift b/Bitkit/Services/Widgets/FactsService.swift index c5ae7d852..5ccfddca3 100644 --- a/Bitkit/Services/Widgets/FactsService.swift +++ b/Bitkit/Services/Widgets/FactsService.swift @@ -3,13 +3,8 @@ import Foundation /// Service for managing Bitcoin facts class FactsService { static let shared = FactsService() - - private let appGroupIdentifier = "group.bitkit" - private init() { - // Share facts with widget on initialization - saveFactsForWidget() - } + private init() {} /// Returns a random Bitcoin fact /// - Returns: A Bitcoin fact string @@ -22,15 +17,6 @@ class FactsService { func getAllFacts() -> [String] { return facts } - - /// Saves facts to App Group shared storage for widget access - private func saveFactsForWidget() { - guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { - return - } - - userDefaults.set(facts, forKey: "widget_facts") - } // MARK: - Private Properties diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index d656cd6bc..2624981a2 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -619,10 +619,10 @@ private struct FlexibleTextView: View { #Preview { ScrollView { HStack { - DisplayText("Display Text With An\nAccent Over Here") + DisplayText(t("onboarding__empty_wallet")) .background(Color.red.opacity(0.1)) - DisplayText("Display Text With An\nAccent Over Here") + DisplayText(t("onboarding__welcome_title")) .background(Color.blue.opacity(0.1)) } .padding(.bottom, 20) @@ -636,11 +636,11 @@ private struct FlexibleTextView: View { } .padding(.bottom, 20) - DisplayText("Display Text With An\nAccent Over Here") + DisplayText(t("onboarding__slide0_header")) .background(Color.orange.opacity(0.1)) .padding(.bottom, 20) - DisplayText("Display Text With An\nAccent Over Here") + DisplayText("Display Style With An\nAccent Over Here") .background(Color.green.opacity(0.1)) .padding(.bottom, 20) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 0f925559c..cd954ff90 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -301,7 +301,6 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } - syncBlocksOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -311,13 +310,5 @@ class WidgetsViewModel: ObservableObject { } catch { print("Failed to persist widgets: \(error)") } - syncBlocksOptionsToHomeScreenWidget() - } - - /// Keeps home screen WidgetKit blocks widget in sync with in-app blocks widget options (App Group). - private func syncBlocksOptionsToHomeScreenWidget() { - let options: BlocksWidgetOptions = getOptions(for: .blocks, as: BlocksWidgetOptions.self) - BlocksHomeScreenWidgetOptionsStore.save(options) - BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } } From 11477b67e594d84bf894b25a948441fb933f62e5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 10:28:28 -0300 Subject: [PATCH 05/10] feat: widgets OS foundation --- Bitkit.xcodeproj/project.pbxproj | 6 +- Bitkit/Components/Widgets/PriceWidget.swift | 7 - Bitkit/Models/BlocksWidgetOptions.swift | 62 ---- Bitkit/Models/PriceWidgetData.swift | 116 +++++++ Bitkit/Models/PriceWidgetOptions.swift | 8 + .../BlocksHomeScreenWidgetOptionsStore.swift | 31 -- .../PriceHomeScreenWidgetOptionsStore.swift | 36 ++ Bitkit/Services/Widgets/PriceService.swift | 110 +------ Bitkit/Styles/TextStyle.swift | 6 +- Bitkit/ViewModels/WidgetsViewModel.swift | 9 + .../blocks-widget.imageset/Contents.json | 12 - .../blocks-widget.imageset/blocks-widget.pdf | Bin 4766 -> 0 bytes .../facts-widget.imageset/Contents.json | 12 - .../facts-widget.imageset/facts-widget.pdf | Bin 4819 -> 0 bytes BitkitWidget/BitkitWidget.swift | 5 +- BitkitWidget/BlocksHomeScreenWidget.swift | 204 ------------ BitkitWidget/FactsHomeScreenWidget.swift | 153 --------- BitkitWidget/PriceHomeScreenWidget.swift | 311 ++++++++++++++++++ BitkitWidget/PriceWidgetService.swift | 130 ++++++++ BitkitWidget/README.md | 51 --- BitkitWidget/WidgetFactsService.swift | 112 ------- .../next/os-widgets-foundation.added.md | 1 + 22 files changed, 622 insertions(+), 760 deletions(-) delete mode 100644 Bitkit/Models/BlocksWidgetOptions.swift create mode 100644 Bitkit/Models/PriceWidgetData.swift create mode 100644 Bitkit/Models/PriceWidgetOptions.swift delete mode 100644 Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift delete mode 100644 BitkitWidget/Assets.xcassets/blocks-widget.imageset/Contents.json delete mode 100644 BitkitWidget/Assets.xcassets/blocks-widget.imageset/blocks-widget.pdf delete mode 100644 BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json delete mode 100644 BitkitWidget/Assets.xcassets/facts-widget.imageset/facts-widget.pdf delete mode 100644 BitkitWidget/BlocksHomeScreenWidget.swift delete mode 100644 BitkitWidget/FactsHomeScreenWidget.swift create mode 100644 BitkitWidget/PriceHomeScreenWidget.swift create mode 100644 BitkitWidget/PriceWidgetService.swift delete mode 100644 BitkitWidget/README.md delete mode 100644 BitkitWidget/WidgetFactsService.swift create mode 100644 changelog.d/next/os-widgets-foundation.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index d0f17eb24..3bdc25bef 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -170,9 +170,9 @@ Fonts/InterTight-Medium.ttf, Fonts/InterTight-Regular.ttf, Fonts/InterTight-SemiBold.ttf, - Models/BlocksWidgetOptions.swift, - Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift, - Services/Widgets/BlocksService.swift, + Models/PriceWidgetData.swift, + Models/PriceWidgetOptions.swift, + Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, Styles/Fonts.swift, Styles/TextStyle.swift, 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/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift deleted file mode 100644 index 36d09d9f8..000000000 --- a/Bitkit/Models/BlocksWidgetOptions.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -/// Options for configuring the in-app and home screen blocks widgets (shared via App Group for the extension). -struct BlocksWidgetOptions: Codable, Equatable { - var height: Bool = true - var time: Bool = true - var date: Bool = true - var transactionCount: Bool = false - var size: Bool = false - var weight: Bool = false - var difficulty: Bool = false - var hash: Bool = false - var merkleRoot: Bool = false - var showSource: Bool = false - - private static let fieldLabels: [String: String] = [ - "height": "Block", - "time": "Time", - "date": "Date", - "transactionCount": "Transactions", - "size": "Size", - "weight": "Weight", - "difficulty": "Difficulty", - "hash": "Hash", - "merkleRoot": "Merkle Root", - ] - - /// Rows to show, in stable order (matches in-app `BlocksWidget`). - func displayRows(for data: BlockData) -> [(key: String, label: String, value: String)] { - var items: [(key: String, label: String, value: String)] = [] - - if height { - items.append((key: "height", label: Self.fieldLabels["height"]!, value: data.height)) - } - if time { - items.append((key: "time", label: Self.fieldLabels["time"]!, value: data.time)) - } - if date { - items.append((key: "date", label: Self.fieldLabels["date"]!, value: data.date)) - } - if transactionCount { - items.append((key: "transactionCount", label: Self.fieldLabels["transactionCount"]!, value: data.transactionCount)) - } - if size { - items.append((key: "size", label: Self.fieldLabels["size"]!, value: data.size)) - } - if weight { - items.append((key: "weight", label: Self.fieldLabels["weight"]!, value: data.weight)) - } - if difficulty { - items.append((key: "difficulty", label: Self.fieldLabels["difficulty"]!, value: data.difficulty)) - } - if hash { - items.append((key: "hash", label: Self.fieldLabels["hash"]!, value: data.hash)) - } - if merkleRoot { - items.append((key: "merkleRoot", label: Self.fieldLabels["merkleRoot"]!, value: data.merkleRoot)) - } - - return items - } -} 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/BlocksHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift deleted file mode 100644 index 3b2557e83..000000000 --- a/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import WidgetKit - -/// Mirrors in-app blocks widget options into the App Group so the WidgetKit extension can read them. -enum BlocksHomeScreenWidgetOptionsStore { - private static let suiteName = "group.bitkit" - private static let key = "home_screen_blocks_widget_options_v1" - - static func save(_ options: BlocksWidgetOptions) { - guard let defaults = UserDefaults(suiteName: suiteName), - let data = try? JSONEncoder().encode(options) - else { return } - defaults.set(data, forKey: key) - } - - static func load() -> BlocksWidgetOptions { - guard let defaults = UserDefaults(suiteName: suiteName), - let data = defaults.data(forKey: key), - let options = try? JSONDecoder().decode(BlocksWidgetOptions.self, from: data) - else { - return BlocksWidgetOptions() - } - return options - } - - /// Call after updating options so the home screen widget timeline refreshes (main app only). - static func reloadHomeScreenWidgetIfNeeded() { - guard Bundle.main.bundleURL.pathExtension != "appex" else { return } - WidgetCenter.shared.reloadTimelines(ofKind: BlocksService.blocksHomeScreenWidgetKind) - } -} 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..3a14c8711 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,61 +39,6 @@ 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 { @@ -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/blocks-widget.imageset/Contents.json b/BitkitWidget/Assets.xcassets/blocks-widget.imageset/Contents.json deleted file mode 100644 index 4b14f265b..000000000 --- a/BitkitWidget/Assets.xcassets/blocks-widget.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "blocks-widget.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BitkitWidget/Assets.xcassets/blocks-widget.imageset/blocks-widget.pdf b/BitkitWidget/Assets.xcassets/blocks-widget.imageset/blocks-widget.pdf deleted file mode 100644 index 0297e9a4f44ec32e5c79e7a87f25d67feb7bf298..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4766 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 Vq2aFRAC(Xm7J~xW+2u7A{s(fITNVHS diff --git a/BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json b/BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json deleted file mode 100644 index fe6653d7c..000000000 --- a/BitkitWidget/Assets.xcassets/facts-widget.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "facts-widget.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BitkitWidget/Assets.xcassets/facts-widget.imageset/facts-widget.pdf b/BitkitWidget/Assets.xcassets/facts-widget.imageset/facts-widget.pdf deleted file mode 100644 index 195d1eb3d004a07584eb3c079a3bb145c92a3d83..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4819 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 Vl#M6qXC}nJqLP4X*OYXW{|6kpYT*C? diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 6eee31fef..737864ecf 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -1,12 +1,9 @@ import SwiftUI import WidgetKit -// MARK: - Widget Bundle - @main struct BitkitWidgetBundle: WidgetBundle { var body: some Widget { - BitkitFactsWidget() - BitkitBlocksWidget() + BitkitPriceWidget() } } diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift deleted file mode 100644 index 03c928e7a..000000000 --- a/BitkitWidget/BlocksHomeScreenWidget.swift +++ /dev/null @@ -1,204 +0,0 @@ -import SwiftUI -import WidgetKit - -// MARK: - Entry - -struct BlocksWidgetEntry: TimelineEntry { - let date: Date - let blockData: BlockData? - /// True when the timeline could not load data and there is nothing in cache. - let showsError: Bool - /// Mirrored from in-app blocks widget settings (App Group). - let options: BlocksWidgetOptions -} - -// MARK: - Timeline Provider - -struct BlocksWidgetProvider: TimelineProvider { - static let previewBlockData = BlockData( - hash: "0000000000000000000000000000000000000000000000000000000000000000", - difficulty: "0.00", - size: "0 KB", - weight: "0 MWU", - height: "900,000", - time: "12:00:00 PM", - date: "4/10/26", - transactionCount: "1024", - merkleRoot: "0000000000000000000000000000000000000000000000000000000000000000" - ) - - func placeholder(in _: Context) -> BlocksWidgetEntry { - BlocksWidgetEntry(date: Date(), blockData: Self.previewBlockData, showsError: false, options: BlocksWidgetOptions()) - } - - func getSnapshot(in context: Context, completion: @escaping (BlocksWidgetEntry) -> Void) { - let options = BlocksHomeScreenWidgetOptionsStore.load() - - if context.isPreview { - completion(BlocksWidgetEntry(date: Date(), blockData: Self.previewBlockData, showsError: false, options: options)) - return - } - let cached = BlocksService.shared.getCachedData() - completion(BlocksWidgetEntry(date: Date(), blockData: cached, showsError: false, options: options)) - } - - func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { - let options = BlocksHomeScreenWidgetOptionsStore.load() - - Task { - let entry: BlocksWidgetEntry - do { - let data = try await BlocksService.shared.fetchBlockData(returnCachedImmediately: false) - entry = BlocksWidgetEntry(date: Date(), blockData: data, showsError: false, options: options) - } catch { - let cached = BlocksService.shared.getCachedData() - entry = BlocksWidgetEntry(date: Date(), blockData: cached, showsError: cached == nil, options: options) - } - - let nextRefresh = Calendar.current.date(byAdding: .minute, value: 20, to: Date()) ?? Date().addingTimeInterval(1200) - let timeline = Timeline(entries: [entry], policy: .after(nextRefresh)) - completion(timeline) - } - } -} - -// MARK: - View - -struct BlocksHomeScreenWidgetEntryView: View { - @Environment(\.widgetFamily) var widgetFamily - @Environment(\.widgetRenderingMode) var widgetRenderingMode - - var entry: BlocksWidgetProvider.Entry - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // HStack { - // Image("blocks-widget") - // .resizable() - // .frame(width: 32, height: 32) - - // BodyMSBText("Latest block", textColor: titleColor) - // .lineLimit(1) - - // Spacer() - // } - - if entry.showsError, entry.blockData == nil { - Text("Couldn’t load block data.") - .font(Fonts.medium(size: bodyFontSize)) - .foregroundColor(secondaryTextColor) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } else if let data = entry.blockData { - blockDataContent(data: data) - } else { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } - } - .containerBackground(for: .widget) { - backgroundView - } - } - - private var backgroundView: some View { - widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear - } - - @ViewBuilder - private func blockDataContent(data: BlockData) -> some View { - let allRows = entry.options.displayRows(for: data) - let visibleRows = Array(allRows.prefix(maxVisibleBlockRows)) - - VStack(spacing: 0) { - if visibleRows.isEmpty { - Text("Choose fields in Bitkit (blocks widget).") - .font(Fonts.medium(size: bodyFontSize)) - .foregroundColor(secondaryTextColor) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } else { - ForEach(visibleRows, id: \.key) { item in - blockRow(label: item.label, value: item.value) - } - - if allRows.count > visibleRows.count { - CaptionBText("+\(allRows.count - visibleRows.count) more in Bitkit", textColor: secondaryTextColor) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - } - } - } - - Spacer(minLength: 0) - - if entry.options.showSource, !visibleRows.isEmpty { - HStack { - Spacer() - CaptionBText("mempool.space", textColor: secondaryTextColor) - } - } - } - - private var secondaryTextColor: Color { - widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary - } - - private var valueTextColor: Color { - widgetRenderingMode == .fullColor ? .white : .primary - } - - private var bodyFontSize: CGFloat { - switch widgetFamily { - case .systemSmall: 14 - case .systemMedium: 15 - case .systemLarge, .systemExtraLarge: 16 - default: 14 - } - } - - /// Home screen widgets do not scroll; cap rows and point users to Bitkit for the rest. - private var maxVisibleBlockRows: Int { - switch widgetFamily { - case .systemSmall: 3 - case .systemMedium: 4 - case .systemLarge, .systemExtraLarge: 11 - default: 4 - } - } - - private func blockRow(label: String, value: String) -> some View { - HStack(spacing: 0) { - BodySSBText(label, textColor: secondaryTextColor) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(value) - .font(Fonts.semiBold(size: bodyFontSize)) - .foregroundColor(valueTextColor) - .lineLimit(1) - .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(minHeight: rowMinHeight) - } - - private var rowMinHeight: CGFloat { - switch widgetFamily { - case .systemSmall: 22 - case .systemMedium, .systemLarge, .systemExtraLarge: 26 - default: 22 - } - } -} - -// MARK: - Widget Configuration - -struct BitkitBlocksWidget: Widget { - var body: some WidgetConfiguration { - StaticConfiguration(kind: BlocksService.blocksHomeScreenWidgetKind, provider: BlocksWidgetProvider()) { entry in - BlocksHomeScreenWidgetEntryView(entry: entry) - } - .configurationDisplayName("Bitcoin Blocks") - .description("Latest block data from the Bitcoin chain. Rows match the blocks widget in Bitkit.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) - } -} diff --git a/BitkitWidget/FactsHomeScreenWidget.swift b/BitkitWidget/FactsHomeScreenWidget.swift deleted file mode 100644 index 65d2d2538..000000000 --- a/BitkitWidget/FactsHomeScreenWidget.swift +++ /dev/null @@ -1,153 +0,0 @@ -import SwiftUI -import WidgetKit - -// MARK: - Entry - -struct FactsWidgetEntry: TimelineEntry { - let date: Date - let fact: String -} - -// MARK: - Timeline Provider - -struct FactsWidgetProvider: TimelineProvider { - /// Stable copy for the widget gallery / `isPreview` snapshots (fast, deterministic). - private static let galleryPreviewFact = "Bitcoin operates without central authority." - - func placeholder(in _: Context) -> FactsWidgetEntry { - FactsWidgetEntry( - date: Date(), - fact: Self.galleryPreviewFact - ) - } - - func getSnapshot(in context: Context, completion: @escaping (FactsWidgetEntry) -> Void) { - if context.isPreview { - completion(FactsWidgetEntry(date: Date(), fact: Self.galleryPreviewFact)) - return - } - let entry = FactsWidgetEntry( - date: Date(), - fact: WidgetFactsService.shared.getRandomFact() - ) - completion(entry) - } - - func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { - var entries: [FactsWidgetEntry] = [] - let currentDate = Date() - - for hourOffset in 0 ..< 8 { - let entryDate = Calendar.current.date(byAdding: .minute, value: hourOffset * 15, to: currentDate)! - let entry = FactsWidgetEntry( - date: entryDate, - fact: WidgetFactsService.shared.getRandomFact() - ) - entries.append(entry) - } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) - } -} - -// MARK: - View - -struct FactsHomeScreenWidgetEntryView: View { - @Environment(\.widgetFamily) var widgetFamily - @Environment(\.widgetRenderingMode) var widgetRenderingMode - - var entry: FactsWidgetProvider.Entry - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // HStack { - // Image("facts-widget") - // .resizable() - // .frame(width: 32, height: 32) - - // BodyMSBText("Bitcoin Fact", textColor: titleColor) - // .lineLimit(1) - - // Spacer() - // } - - Text(entry.fact) - .font(fontForFamily()) - .foregroundColor(factColor) - .lineLimit(lineLimit()) - .minimumScaleFactor(0.8) - - Spacer() - - HStack { - Image("btc") - .resizable() - .frame(width: 32, height: 32) - - Spacer() - - CaptionBText("synonym.to", textColor: secondaryTextColor) - } - } - .containerBackground(for: .widget) { - backgroundView - } - } - - private var backgroundView: some View { - widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear - } - - private var titleColor: Color { - widgetRenderingMode == .fullColor ? .white.opacity(0.9) : .primary - } - - private var factColor: Color { - widgetRenderingMode == .fullColor ? .white : .primary - } - - private var secondaryTextColor: Color { - widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary - } - - private func fontForFamily() -> Font { - switch widgetFamily { - case .systemSmall: Fonts.semiBold(size: 17) - case .systemMedium, .systemLarge, .systemExtraLarge: Fonts.bold(size: 22) - case .accessoryCircular, .accessoryRectangular, .accessoryInline: Fonts.medium(size: 14) - @unknown default: Fonts.medium(size: 14) - } - } - - private func lineLimit() -> Int { - switch widgetFamily { - case .systemSmall: - return 4 - case .systemMedium, .systemLarge, .systemExtraLarge: - // Large matches medium: we only list `.systemLarge` in `supportedFamilies` so the add-widget gallery can render a real preview (omitting - // it often shows skeletons). - return 3 - case .accessoryCircular, .accessoryRectangular, .accessoryInline: - return 1 - @unknown default: - return 4 - } - } -} - -// MARK: - Widget Configuration - -/// Home screen “Bitcoin Facts” widget. `kind` must stay `BitkitWidget` so existing placements keep working. -struct BitkitFactsWidget: Widget { - let kind: String = "BitkitWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: FactsWidgetProvider()) { entry in - FactsHomeScreenWidgetEntryView(entry: entry) - } - .configurationDisplayName("Bitcoin Facts") - .description("Display interesting Bitcoin facts on your home screen.") - .supportedFamilies([.systemSmall, .systemMedium]) - } -} diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift new file mode 100644 index 000000000..3328fe4c4 --- /dev/null +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -0,0 +1,311 @@ +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) + + Text(primary?.price ?? "—") + .font(Fonts.bold(size: 22)) + .foregroundColor(valueTextColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + + if let change = primary?.change { + BodySSBText(change.formatted, textColor: changeColor(isPositive: change.isPositive)) + .lineLimit(1) + } + + 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 { + Text("Couldn’t load price.") + .font(Fonts.medium(size: 14)) + .foregroundColor(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) + + BodySSBText(data.price, textColor: valueTextColor) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .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 + ) + ) + + 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..deb5eb766 --- /dev/null +++ b/BitkitWidget/PriceWidgetService.swift @@ -0,0 +1,130 @@ +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 { + private static let baseURL = "https://feeds.synonym.to/price-feed/api" + + 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: "\(baseURL)/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: "\(baseURL)/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/BitkitWidget/README.md b/BitkitWidget/README.md deleted file mode 100644 index ec03329c7..000000000 --- a/BitkitWidget/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# BitkitWidget - iOS Home Screen Widget - -Bitcoin Facts widget for iOS home screen using WidgetKit. - -## Quick Start - -See the main **WIDGET_SETUP.md** file in the project root for detailed setup instructions. - -## Files in this Directory - -- **BitkitWidget.swift** - `WidgetBundle` entry point (`@main`) -- **FactsHomeScreenWidget.swift** - Bitcoin Facts timeline, view, and `BitkitFactsWidget` configuration -- **BlocksHomeScreenWidget.swift** - Bitcoin blocks timeline, view, and `BitkitBlocksWidget` configuration -- **WidgetFactsService.swift** - Service for managing and providing Bitcoin facts -- **Info.plist** - Widget extension configuration -- **BitkitWidget.entitlements** - App Groups entitlement for data sharing -- **Assets.xcassets/** - Widget-specific assets - -## Architecture - -### Timeline Provider -The `FactsWidgetProvider` creates a timeline of widget entries that update every 15 minutes. - -### Widget Entry -Each `FactsWidgetEntry` contains: -- A timestamp for when it should be displayed -- A Bitcoin fact string - -### Widget View -`FactsHomeScreenWidgetEntryView` displays the fact with: -- Background tuned for full-color vs accented (Liquid Glass) mode -- Bitcoin icon header -- Fact text (responsive to widget size) -- Source attribution footer - -### Data Sharing -Facts are shared between the main app and widget via App Groups (`group.bitkit`), allowing the widget to display the same facts as the in-app widget. - -## Testing - -1. Build and run the **BitkitWidget** scheme to preview in Xcode -2. Run the main **Bitkit** app, then add the widget to your home screen -3. The widget will update automatically every 15 minutes - -## Future Enhancements - -- Add interactive widget actions (iOS 17+) -- Support for Live Activities -- Additional widget families (extra large, lock screen widgets) -- Configuration options (font size, colors, update frequency) - diff --git a/BitkitWidget/WidgetFactsService.swift b/BitkitWidget/WidgetFactsService.swift deleted file mode 100644 index 426052824..000000000 --- a/BitkitWidget/WidgetFactsService.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation - -/// Service for managing Bitcoin facts for the widget -/// This is a simplified version that works independently from the main app -class WidgetFactsService { - static let shared = WidgetFactsService() - - private let appGroupIdentifier = "group.bitkit" - private let factsKey = "widget_facts" - - private init() {} - - /// Returns a random Bitcoin fact - func getRandomFact() -> String { - // Try to get facts from App Group shared storage first - if let sharedFacts = getSharedFacts(), !sharedFacts.isEmpty { - return sharedFacts.randomElement() ?? defaultFacts.randomElement()! - } - - // Fallback to default facts - return defaultFacts.randomElement()! - } - - /// Get facts shared from the main app via App Groups - private func getSharedFacts() -> [String]? { - guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { - return nil - } - - return userDefaults.stringArray(forKey: factsKey) - } - - /// Save facts to App Group shared storage (called from main app) - func saveSharedFacts(_ facts: [String]) { - guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { - return - } - - userDefaults.set(facts, forKey: factsKey) - } - - // MARK: - Default Facts - - private let defaultFacts = [ - "Satoshi Nakamoto mined more than 1M Bitcoin.", - "You don't need permission to use Bitcoin.", - "You don't need a bank account to use Bitcoin.", - "Bitcoin is a public ledger.", - "Bitcoin can use otherwise wasted energy.", - "Priced in Bitcoin, products can become cheaper over time.", - "Your node, your rules.", - "Bitcoin does not discriminate.", - "About 20% of Bitcoin may be lost forever.", - "A Bitcoin faucet gave out 5 BTC per visitor.", - "Every 210,000 blocks, mining rewards are cut in half.", - "It takes about 10 minutes to mine a new block.", - "The largest transaction was 500,000 bitcoin.", - "Bitcoin is legal tender in El Salvador.", - "Not your keys, not your coins.", - "'Bitcoin' is the network, 'bitcoin' is the currency.", - "Bitcoin was not the first digital currency.", - "Bitcoin was first created with 31,000 lines of code.", - "Bitcoin does not have a CEO.", - "Initially you could send Bitcoin to an IP address.", - "Bitcoin did not always have a block size limit.", - "The first Bitcoin purchase was for a pizza.", - "May 22 is celebrated as Bitcoin Pizza Day.", - "Somebody paid 10,000 bitcoins for pizza.", - "The identity of Bitcoin's inventor is unknown.", - "If you lose your keys, you lose your coins.", - "Bitcoins don't grow on trees.", - "There can only be 21 million bitcoins.", - "Bitcoins are created when a block is mined.", - "One bitcoin is 100,000,000 satoshis.", - "The smallest unit of Bitcoin is a 'satoshi.'", - "Bitcoins live on the blockchain, not in wallets.", - "You can hold keys, but you cannot hold bitcoin.", - "Private keys allow you to sign transactions.", - "Public keys are used to create payment addresses.", - "Satoshi Nakamoto wrote the Bitcoin whitepaper.", - "Satoshi Nakamoto mined the 'genesis' block.", - "The whitepaper was published Oct 31, 2008.", - "The genesis block was mined Jan 3, 2009.", - "It takes energy to mine a new Bitcoin block.", - "Mining a block is solving a cryptographic puzzle.", - "Mining is guessing numbers.", - "The last Bitcoin will be mined in 2140.", - "Bitcoin operates without central authority.", - "No company controls Bitcoin.", - "The block reward halves every four years.", - "Bitcoin inflation rate declines over time.", - "Bitcoin is censorship-resistant.", - "The Bitcoin protocol is trustless.", - "You can verify all bitcoin transactions.", - "The Bitcoin network is open to anyone.", - "Draft of Lightning white paper: Feb 2015.", - "First Lightning payment: May 10, 2017.", - "The Lightning protocol is a payment layer.", - "Lightning enables instant bitcoin payments.", - "Lightning channels are peer-to-peer.", - "Full nodes store the entire transaction history.", - "You can generate a Bitcoin address offline.", - "Bitcoin is natively measured in integers.", - "Technically there are no bitcoins, only sats.", - "The genesis block reward is not spendable.", - "You can count 1 day of blocks on 2 hands.", - "There are enough sats for everyone.", - "More computing power ≠ more bitcoin.", - "Bitcoin doesn't need your personal info.", - "Satoshi considered calling it Netcoin.", - ] -} diff --git a/changelog.d/next/os-widgets-foundation.added.md b/changelog.d/next/os-widgets-foundation.added.md new file mode 100644 index 000000000..a42bd08e6 --- /dev/null +++ b/changelog.d/next/os-widgets-foundation.added.md @@ -0,0 +1 @@ +Added a Bitcoin Price home-screen widget that mirrors the in-app price widget. From e23894bad2fb7da03f39786358758285659b3e2c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 13:35:03 -0300 Subject: [PATCH 06/10] refactor: extract urls to shared constant --- Bitkit.xcodeproj/project.pbxproj | 1 + Bitkit/Constants/WidgetEnv.swift | 10 ++++++++++ Bitkit/Services/Widgets/PriceService.swift | 2 +- BitkitWidget/PriceWidgetService.swift | 6 ++---- 4 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 Bitkit/Constants/WidgetEnv.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 3bdc25bef..24146938a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -169,6 +169,7 @@ 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, 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/Services/Widgets/PriceService.swift b/Bitkit/Services/Widgets/PriceService.swift index 3a14c8711..411d0f22e 100644 --- a/Bitkit/Services/Widgets/PriceService.swift +++ b/Bitkit/Services/Widgets/PriceService.swift @@ -43,7 +43,7 @@ enum PriceServiceError: Error { class PriceService { static let shared = PriceService() - private let baseURL = "https://feeds.synonym.to/price-feed/api" + private let baseURL = WidgetEnv.priceFeedBaseUrl private init() {} diff --git a/BitkitWidget/PriceWidgetService.swift b/BitkitWidget/PriceWidgetService.swift index deb5eb766..0574bdf22 100644 --- a/BitkitWidget/PriceWidgetService.swift +++ b/BitkitWidget/PriceWidgetService.swift @@ -7,8 +7,6 @@ import Foundation /// 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 { - private static let baseURL = "https://feeds.synonym.to/price-feed/api" - enum FetchError: Error { case invalidURL case invalidPair @@ -63,7 +61,7 @@ enum PriceWidgetService { } private static func fetchLatestPrice(ticker: String) async throws -> Double { - guard let url = URL(string: "\(baseURL)/price/\(ticker)/latest") else { + guard let url = URL(string: "\(WidgetEnv.priceFeedBaseUrl)/price/\(ticker)/latest") else { throw FetchError.invalidURL } let (data, _) = try await URLSession.shared.data(from: url) @@ -71,7 +69,7 @@ enum PriceWidgetService { } private static func fetchCandles(ticker: String, period: GraphPeriod) async throws -> [Candle] { - guard let url = URL(string: "\(baseURL)/price/\(ticker)/history/\(period.rawValue)") else { + 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) From a683f92964f306305c825b7e3daae1f0f2e2f9ed Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 14:03:32 -0300 Subject: [PATCH 07/10] fix: set IPHONEOS_DEPLOYMENT_TARGET to 17.0 --- Bitkit.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 24146938a..5e8d587ec 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -617,7 +617,7 @@ INFOPLIST_FILE = BitkitWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -650,7 +650,7 @@ INFOPLIST_FILE = BitkitWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From 02520524a30bace6b49a1f21b0d1cb468f55120e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 07:14:30 -0300 Subject: [PATCH 08/10] feat: set widgetAccentable --- BitkitWidget/PriceHomeScreenWidget.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index 3328fe4c4..e1a44af7e 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -128,10 +128,12 @@ struct PriceHomeScreenWidgetEntryView: View { .foregroundColor(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) @@ -193,10 +195,12 @@ struct PriceHomeScreenWidgetEntryView: View { 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) } @@ -287,6 +291,7 @@ private struct PriceWidgetChart: View { topTrailingRadius: 0 ) ) + .widgetAccentable() CaptionBText(period, textColor: labelColor) .padding(7) From c3a75fff205c680709e7e5382891e8d0dc0207b4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:41:38 -0300 Subject: [PATCH 09/10] fix: pr commens --- BitkitWidget/PriceHomeScreenWidget.swift | 8 ++------ .../next/{os-widgets-foundation.added.md => 538.added.md} | 0 2 files changed, 2 insertions(+), 6 deletions(-) rename changelog.d/next/{os-widgets-foundation.added.md => 538.added.md} (100%) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index e1a44af7e..b53fea3f8 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -123,9 +123,7 @@ struct PriceHomeScreenWidgetEntryView: View { BodySSBText(primary?.name ?? "BTC/USD", textColor: secondaryTextColor) .lineLimit(1) - Text(primary?.price ?? "—") - .font(Fonts.bold(size: 22)) - .foregroundColor(valueTextColor) + TitleText(primary?.price ?? "—", textColor: valueTextColor) .lineLimit(1) .minimumScaleFactor(0.7) .widgetAccentable() @@ -178,9 +176,7 @@ struct PriceHomeScreenWidgetEntryView: View { } private var errorView: some View { - Text("Couldn’t load price.") - .font(Fonts.medium(size: 14)) - .foregroundColor(secondaryTextColor) + BodySText("Couldn’t load price.", textColor: secondaryTextColor) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } diff --git a/changelog.d/next/os-widgets-foundation.added.md b/changelog.d/next/538.added.md similarity index 100% rename from changelog.d/next/os-widgets-foundation.added.md rename to changelog.d/next/538.added.md From 53123609069f4411f20f70122d432e639a1988a7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:56:20 -0300 Subject: [PATCH 10/10] refactor: remove old doc file --- WIDGET_SETUP.md | 147 ------------------------------------------------ 1 file changed, 147 deletions(-) delete mode 100644 WIDGET_SETUP.md diff --git a/WIDGET_SETUP.md b/WIDGET_SETUP.md deleted file mode 100644 index 37f8c086a..000000000 --- a/WIDGET_SETUP.md +++ /dev/null @@ -1,147 +0,0 @@ -# iOS Home Screen Widget Setup Guide - -This guide will help you add the Bitcoin Facts widget as an iOS home screen widget using WidgetKit. - -## Overview - -All the necessary widget files have been created in the `BitkitWidget/` directory: -- `BitkitWidget.swift` - Main widget implementation -- `WidgetFactsService.swift` - Service for providing facts to the widget -- `Info.plist` - Widget extension configuration -- `BitkitWidget.entitlements` - App Groups entitlement -- `Assets.xcassets/` - Widget assets - -## Setup Steps in Xcode - -### 1. Add Widget Extension Target - -1. Open `Bitkit.xcodeproj` in Xcode -2. Click on the project in the Project Navigator -3. At the bottom of the Targets list, click the **"+"** button -4. Select **"Widget Extension"** from the template chooser -5. Configure the new target: - - **Product Name**: `BitkitWidget` - - **Include Configuration Intent**: Leave **unchecked** (we don't need configuration) - - Click **Finish** -6. When prompted "Activate BitkitWidget scheme?", click **Activate** - -### 2. Replace Template Files - -Xcode will have created template files. Replace/delete them: - -1. **Delete** the auto-generated files in the `BitkitWidget` folder: - - `BitkitWidget.swift` (template version) - - `BitkitWidgetBundle.swift` (if separate) - - `BitkitWidgetLiveActivity.swift` (if created) - - `AppIntent.swift` (if created) - -2. **Add** the files we created to the BitkitWidget target: - - Right-click on the `BitkitWidget` folder in Xcode - - Select **"Add Files to Bitkit..."** - - Navigate to the `BitkitWidget` folder - - Select: - - `BitkitWidget.swift` - - `WidgetFactsService.swift` - - Make sure **"BitkitWidget"** target is checked - - Click **Add** - -### 3. Configure Target Settings - -#### Bundle Identifier -1. Select the **BitkitWidget** target -2. Go to **General** tab -3. Set **Bundle Identifier** to: `to.bitkit.BitkitWidget` (or match your main app's bundle ID + `.BitkitWidget`) - -#### Deployment Target -1. In the **General** tab -2. Set **Minimum Deployments** to match your main app (iOS 16.0 or higher recommended for widgets) - -#### Entitlements -1. Select the **BitkitWidget** target -2. Go to **Signing & Capabilities** tab -3. Click **"+ Capability"** -4. Add **App Groups** -5. Check/add the app group: `group.bitkit` - -### 4. Update Main App Entitlements (If Needed) - -Make sure the main **Bitkit** target also has the App Groups capability: -1. Select the **Bitkit** target -2. Go to **Signing & Capabilities** tab -3. Verify **App Groups** capability exists with `group.bitkit` - -### 5. Configure Info.plist - -The `BitkitWidget/Info.plist` file should already be configured, but verify: -- `CFBundleDisplayName`: "Bitcoin Facts" -- `NSExtension` → `NSExtensionPointIdentifier`: "com.apple.widgetkit-extension" - -### 6. Build and Run - -1. Select the **BitkitWidget** scheme in Xcode -2. Choose a simulator or device -3. Build and run (Cmd+R) -4. Xcode will launch in widget preview mode -5. You should see the Bitcoin Facts widget in different sizes - -### 7. Test on Device/Simulator - -1. Switch back to the **Bitkit** scheme -2. Run the main app -3. On your home screen, long-press to enter edit mode -4. Tap the **"+"** button in the top-left corner -5. Search for **"Bitkit"** or **"Bitcoin Facts"** -6. Select the Bitcoin Facts widget -7. Choose a size (Small, Medium, or Large) -8. Tap **"Add Widget"** - -## Widget Features - -### Sizes Supported -- **Small**: Shows a single Bitcoin fact (4 lines max) -- **Medium**: Shows a Bitcoin fact (3 lines max) -- **Large**: Shows a Bitcoin fact (8 lines max) - -### Update Frequency -- The widget automatically updates every 15 minutes with a new random fact -- Creates a 2-hour timeline with 8 entries - -### Data Sharing -- The main app shares all Bitcoin facts with the widget via App Groups -- The widget falls back to built-in facts if App Groups aren't accessible - -## Troubleshooting - -### Widget Not Appearing -- Make sure both the main app and widget extension have App Groups enabled -- Verify the app group identifier is exactly `group.bitkit` -- Clean build folder (Cmd+Shift+K) and rebuild - -### Facts Not Updating -- Ensure the main app has been launched at least once to populate shared data -- Check that App Groups entitlement is properly configured -- Try removing and re-adding the widget - -### Build Errors -- Ensure the BitkitWidget target has the correct Deployment Target -- Verify all files are added to the BitkitWidget target (check Target Membership) -- Make sure WidgetKit framework is linked - -## Customization - -You can customize the widget appearance by editing `BitkitWidget.swift`: -- Colors: Modify the `LinearGradient` colors -- Fonts: Adjust the font sizes in `fontForFamily()` -- Layout: Customize the `VStack` spacing and padding -- Update interval: Change the timeline intervals in `getTimeline()` - -## Next Steps - -Consider adding more widget types: -- **Price Widget**: Show current Bitcoin price -- **Balance Widget**: Show wallet balance (with privacy considerations) -- **Activity Widget**: Show recent transactions -- **Block Height Widget**: Show current block height - -Each would follow the same pattern as the Facts widget. -